feat: Implement Scheduler Worker Options and Planner Loop
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added `SchedulerWorkerOptions` class to encapsulate configuration for the scheduler worker.
- Introduced `PlannerBackgroundService` to manage the planner loop, fetching and processing planning runs.
- Created `PlannerExecutionService` to handle the execution logic for planning runs, including impact targeting and run persistence.
- Developed `PlannerExecutionResult` and `PlannerExecutionStatus` to standardize execution outcomes.
- Implemented validation logic within `SchedulerWorkerOptions` to ensure proper configuration.
- Added documentation for the planner loop and impact targeting features.
- Established health check endpoints and authentication mechanisms for the Signals service.
- Created unit tests for the Signals API to ensure proper functionality and response handling.
- Configured options for authority integration and fallback authentication methods.
This commit is contained in:
2025-10-27 09:46:31 +02:00
parent 96d52884e8
commit 730354a1af
135 changed files with 10721 additions and 946 deletions

View File

@@ -109,6 +109,89 @@ jobs:
if-no-files-found: error
retention-days: 14
- name: Export policy run schemas
id: export-policy-schemas
run: |
set -euo pipefail
OUTPUT_DIR="artifacts/policy-schemas/${GITHUB_SHA}"
mkdir -p "$OUTPUT_DIR"
scripts/export-policy-schemas.sh "$OUTPUT_DIR"
- name: Detect policy schema changes
id: detect-policy-schema-changes
run: |
set -euo pipefail
OUTPUT_DIR="artifacts/policy-schemas/${GITHUB_SHA}"
FILES=("policy-run-request.schema.json" "policy-run-status.schema.json" "policy-diff-summary.schema.json" "policy-explain-trace.schema.json")
CHANGED=()
DIFF_FILE="$OUTPUT_DIR/policy-schema-diff.patch"
rm -f "$DIFF_FILE"
for file in "${FILES[@]}"; do
if [ ! -f "$OUTPUT_DIR/$file" ]; then
echo "Missing exported schema: $OUTPUT_DIR/$file" >&2
exit 1
fi
if ! cmp -s "docs/schemas/$file" "$OUTPUT_DIR/$file"; then
CHANGED+=("$file")
{
diff -u "docs/schemas/$file" "$OUTPUT_DIR/$file" || true
} >> "$DIFF_FILE"
printf '\n' >> "$DIFF_FILE"
fi
done
if [ ${#CHANGED[@]} -eq 0 ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
JOINED="$(IFS=', '; echo "${CHANGED[*]}")"
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "changed_files=$JOINED" >> "$GITHUB_OUTPUT"
echo "diff_path=artifacts/policy-schemas/${GITHUB_SHA}/policy-schema-diff.patch" >> "$GITHUB_OUTPUT"
- name: Upload policy schema artifacts
uses: actions/upload-artifact@v4
with:
name: policy-schema-exports
path: artifacts/policy-schemas
if-no-files-found: error
retention-days: 14
- name: Notify policy schema changes
if: steps.detect-policy-schema-changes.outputs.changed == 'true'
env:
SLACK_WEBHOOK: ${{ secrets.POLICY_ENGINE_SCHEMA_WEBHOOK || vars.POLICY_ENGINE_SCHEMA_WEBHOOK }}
CHANGED_FILES: ${{ steps.detect-policy-schema-changes.outputs.changed_files }}
run: |
if [ -z "${SLACK_WEBHOOK:-}" ]; then
echo " POLICY_ENGINE_SCHEMA_WEBHOOK not configured; skipping Slack notification."
exit 0
fi
payload="$(python3 - <<'PY'
import json
import os
changed_files = os.environ.get("CHANGED_FILES", "").strip()
repo = os.environ["GITHUB_REPOSITORY"]
sha = os.environ["GITHUB_SHA"]
commit_url = f"{os.environ['GITHUB_SERVER_URL']}/{repo}/commit/{sha}"
run_url = f"{os.environ['GITHUB_SERVER_URL']}/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}"
lines = [
":scroll: Policy schema export updated.",
f"*Commit:* <{commit_url}|{sha[:7]}>",
]
if changed_files:
lines.append(f"*Files:* {changed_files}")
lines.append(f"*CI run:* <{run_url}|Artifacts & diff>")
print(json.dumps({"text": "\n".join(lines)}))
PY
)"
curl -sSf -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK"
- name: Run release tooling tests
run: python ops/devops/release/test_verify_release.py

View File

@@ -271,7 +271,7 @@ A dedicated write guard refuses `effective_finding_*` writes from any caller tha
### 3.12 Security and tenancy
* Every raw doc carries a `tenant` field.
* Authority enforces `advisory:write` and `vex:write` scopes for ingestion endpoints.
* Authority enforces `advisory:ingest` and `vex:ingest` scopes for ingestion endpoints.
* Crosstenant reads/writes are blocked by default.
* Secrets never logged; signatures verified with pinned trust stores.
@@ -375,7 +375,7 @@ Breakdown by component with exact work items. Each section ends with the imposed
### 6.5 Authority
* [ ] Introduce scopes: `advisory:write`, `advisory:read`, `vex:write`, `vex:read`, `aoc:verify`.
* [ ] Introduce scopes: `advisory:ingest`, `advisory:read`, `vex:ingest`, `vex:read`, `aoc:verify`.
* [ ] Add `tenant` claim propagation to ingestion services.
**Imposed rule:** Work of this type or tasks of this type on this component must also be applied everywhere else it should be applied.

View File

@@ -2,56 +2,24 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description |
| --- | --- | --- | --- | --- | --- | --- |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-NUGET-13-002 | Ensure all solutions/projects prioritize `local-nuget` before public feeds and add restore-order validation. |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-003 | Upgrade `Microsoft.*` dependencies pinned to 8.* to their latest .NET 10 (or 9.x) releases and refresh guidance. |
| Sprint 14 | Release & Offline Ops | ops/deployment/TASKS.md | DONE (2025-10-26) | Deployment Guild | DEVOPS-OPS-14-003 | Deployment/update/rollback automation and channel management documentation. |
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-REL-14-001 | Deterministic build/release pipeline with SBOM/provenance, signing, and manifest generation. |
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild, Scanner Guild | DEVOPS-REL-14-004 | Extend release/offline smoke jobs to cover Python analyzer plug-ins (warm/cold, determinism, signing). |
| Sprint 14 | Release & Offline Ops | ops/licensing/TASKS.md | DONE (2025-10-26) | Licensing Guild | DEVOPS-LIC-14-004 | Registry token service tied to Authority, plan gating, revocation handling, monitoring. |
| Sprint 14 | Release & Offline Ops | ops/offline-kit/TASKS.md | DONE (2025-10-26) | Offline Kit Guild | DEVOPS-OFFLINE-14-002 | Offline kit packaging workflow with integrity verification and documentation. |
| Sprint 15 | Benchmarks | src/StellaOps.Bench/TASKS.md | DONE (2025-10-26) | Bench Guild, Notify Team | BENCH-NOTIFY-15-001 | Notify dispatch throughput bench with results CSV. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-19) | Scheduler Models Guild | SCHED-MODELS-16-101 | Define Scheduler DTOs & validation. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-19) | Scheduler Models Guild | SCHED-MODELS-16-102 | Publish schema docs/sample payloads. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Scheduler Storage Guild | SCHED-STORAGE-16-201 | Mongo schemas/indexes for Scheduler state. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | DONE (2025-10-26) | Scheduler Storage Guild | SCHED-STORAGE-16-202 | Repositories with tenant scoping, TTL, causal consistency. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | DONE (2025-10-26) | Scheduler Storage Guild | SCHED-STORAGE-16-203 | Audit/run stats materialization for UI. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | DONE (2025-10-26) | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-302 | Query APIs for ResolveByPurls/ResolveByVulns/ResolveAll. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | DONE (2025-10-26) | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-301 | Ingest BOM-Index into roaring bitmap store. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | DOING (2025-10-19) | Scheduler WebService Guild | SCHED-WEB-16-101 | Minimal API host with Authority enforcement. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-102 | Schedules CRUD (cron validation, pause/resume, audit). |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-103 | Runs API (list/detail/cancel) + impact previews. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-WEB-16-104 | Feedser/Vexer webhook handlers with security enforcement. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-201 | Planner loop (cron/event triggers, leases, fairness). |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | DOING (2025-10-26) | Scheduler Worker Guild | SCHED-WORKER-16-202 | ImpactIndex targeting and shard planning. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | DOING (2025-10-27) | Scheduler Worker Guild | SCHED-WORKER-16-201 | Planner loop (cron/event triggers, leases, fairness). |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | DONE (2025-10-27) | Scheduler Worker Guild | SCHED-WORKER-16-202 | ImpactIndex targeting and shard planning. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-203 | Runner execution invoking Scanner analysis/content refresh. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-204 | Emit rescan/report events for Notify/UI. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-205 | Metrics/telemetry for Scheduler planners/runners. |
| Sprint 17 | Symbol Intelligence & Forensics | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-RUNTIME-17-004 | Document build-id workflows for SBOMs, runtime events, and debug-store usage. |
| Sprint 17 | Symbol Intelligence & Forensics | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-REL-17-002 | Ship stripped debug artifacts organised by build-id within release/offline kits. |
| Sprint 17 | Symbol Intelligence & Forensics | ops/offline-kit/TASKS.md | DONE (2025-10-26) | Offline Kit Guild, DevOps Guild | DEVOPS-OFFLINE-17-003 | Mirror release debug-store artefacts into Offline Kit packaging and document validation. |
| Sprint 17 | Symbol Intelligence & Forensics | ops/offline-kit/TASKS.md | BLOCKED (2025-10-26) | Offline Kit Guild, DevOps Guild | DEVOPS-OFFLINE-17-004 | Run mirror_debug_store.py once release artefacts exist and archive verification evidence with the Offline Kit. |
| Sprint 17 | Symbol Intelligence & Forensics | ops/devops/TASKS.md | BLOCKED (2025-10-26) | DevOps Guild | DEVOPS-REL-17-004 | Ensure release workflow publishes `out/release/debug` (build-id tree + manifest) and fails when symbols are missing. |
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.Emit/TASKS.md | DONE (2025-10-26) | Emit Guild | SCANNER-EMIT-17-701 | Record GNU build-id for ELF components and surface it in SBOM/diff outputs. |
| Sprint 18 | Launch Readiness | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-LAUNCH-18-001 | Production launch cutover rehearsal and runbook publication. |
| Sprint 18 | Launch Readiness | ops/offline-kit/TASKS.md | DONE (2025-10-26) | Offline Kit Guild, Scanner Guild | DEVOPS-OFFLINE-18-005 | Rebuild Offline Kit with Python analyzer artefacts and refreshed manifest/signature pair. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-AOC-19-001 | Publish aggregation-only contract reference documentation. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Architecture Guild | DOCS-AOC-19-002 | Update architecture overview with AOC boundary diagrams. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Policy Guild | DOCS-AOC-19-003 | Refresh policy engine doc with raw ingestion constraints. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, UI Guild | DOCS-AOC-19-004 | Document console AOC dashboard and drill-down flow. |
> DOCS-AOC-19-004: Architecture overview & policy-engine docs refreshed 2025-10-26 — reuse new AOC boundary diagram + metrics guidance.
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, CLI Guild | DOCS-AOC-19-005 | Document CLI AOC commands and exit codes. |
> DOCS-AOC-19-005: Link to the new AOC reference and architecture overview; include exit code table sourced from those docs.
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Observability Guild | DOCS-AOC-19-006 | Document new AOC metrics, traces, and logs. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Authority Core | DOCS-AOC-19-007 | Document new Authority scopes and tenancy enforcement. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, DevOps Guild | DOCS-AOC-19-008 | Update deployment guide with validator enablement and verify user guidance. |
| Sprint 19 | Aggregation-Only Contract Enforcement | ops/devops/TASKS.md | BLOCKED (2025-10-26) | DevOps Guild, Platform Guild | DEVOPS-AOC-19-001 | Integrate AOC analyzer/guard enforcement into CI pipelines. |
| Sprint 19 | Aggregation-Only Contract Enforcement | ops/devops/TASKS.md | BLOCKED (2025-10-26) | DevOps Guild | DEVOPS-AOC-19-002 | Add CI stage running `stella aoc verify` against seeded snapshots. |
| Sprint 19 | Aggregation-Only Contract Enforcement | ops/devops/TASKS.md | BLOCKED (2025-10-26) | DevOps Guild, QA Guild | DEVOPS-AOC-19-003 | Enforce guard coverage thresholds and export metrics to dashboards. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce new ingestion/auth scopes across Authority. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Authority/TASKS.md | DOING (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce tenant claim propagation and cross-tenant guardrails. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Authority/TASKS.md | DONE (2025-10-27) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce tenant claim propagation and cross-tenant guardrails. |
> AUTH-AOC-19-002: Tenant metadata now flows through rate limiter/audit/token persistence; password grant scope/tenant enforcement landed. Docs/stakeholder walkthrough pending.
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Authority/TASKS.md | TODO | Authority Core & Docs Guild | AUTH-AOC-19-003 | Update Authority docs/config samples for new scopes. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild | CLI-AOC-19-001 | Implement `stella sources ingest --dry-run` command. |
> 2025-10-27 Update: Ingestion scopes require tenant assignment; access tokens propagate tenant claims and reject cross-tenant mismatches with coverage.
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Authority/TASKS.md | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-003 | Update Authority docs/config samples for new scopes. |
> AUTH-AOC-19-003: Scope catalogue, console/CLI docs, and sample config updated to require `aoc:verify` plus read scopes; verification clients now explicitly include tenant hints. Authority test run remains blocked on Concelier build failure (`ImmutableHashSet<string?>`), previously noted under AUTH-AOC-19-002.
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Cli/TASKS.md | DOING (2025-10-27) | DevEx/CLI Guild | CLI-AOC-19-001 | Implement `stella sources ingest --dry-run` command. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild | CLI-AOC-19-002 | Implement `stella aoc verify` command with exit codes. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Cli/TASKS.md | TODO | Docs/CLI Guild | CLI-AOC-19-003 | Update CLI reference and quickstart docs for new AOC commands. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Concelier.Core/TASKS.md | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Implement AOC repository guard rejecting forbidden fields. |
@@ -93,39 +61,18 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Web/TASKS.md | DOING (2025-10-26) | BE-Base Platform Guild | WEB-AOC-19-001 | Provide shared AOC forbidden key set and guard middleware. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-AOC-19-002 | Ship provenance builder and signature helpers for ingestion services. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild, QA Guild | WEB-AOC-19-003 | Author analyzer + shared test fixtures for guard compliance. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-POLICY-20-001 | Publish `/docs/policy/overview.md` with compliance checklist. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-POLICY-20-002 | Document DSL grammar + examples in `/docs/policy/dsl.md`. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Authority Core | DOCS-POLICY-20-003 | Write `/docs/policy/lifecycle.md` covering workflow + roles. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Scheduler Guild | DOCS-POLICY-20-004 | Document policy run modes + cursors in `/docs/policy/runs.md`. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Platform Guild | DOCS-POLICY-20-005 | Produce `/docs/api/policy.md` with endpoint schemas + errors. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, CLI Guild | DOCS-POLICY-20-006 | Author `/docs/cli/policy.md` with commands, exit codes, JSON output. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, UI Guild | DOCS-POLICY-20-007 | Create `/docs/ui/policy-editor.md` covering editor, simulation, approvals. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Architecture Guild | DOCS-POLICY-20-008 | Publish `/docs/architecture/policy-engine.md` with sequence diagrams. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Observability Guild | DOCS-POLICY-20-009 | Document metrics/traces/logs in `/docs/observability/policy.md`. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Security Guild | DOCS-POLICY-20-010 | Publish `/docs/security/policy-governance.md` for scopes + approvals. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Policy Guild | DOCS-POLICY-20-011 | Add example policies under `/docs/examples/policies/` with commentary. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Support Guild | DOCS-POLICY-20-012 | Draft `/docs/faq/policy-faq.md` covering conflicts, determinism, pitfalls. |
| Sprint 20 | Policy Engine v2 | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-POLICY-20-001 | Add DSL lint + compile checks to CI pipelines. |
| Sprint 20 | Policy Engine v2 | ops/devops/TASKS.md | BLOCKED (waiting on POLICY-ENGINE-20-006) | DevOps Guild | DEVOPS-POLICY-20-002 | Run `stella policy simulate` CI stage against golden SBOMs. |
| Sprint 20 | Policy Engine v2 | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild, QA Guild | DEVOPS-POLICY-20-003 | Add determinism CI job diffing repeated policy runs. |
| Sprint 20 | Policy Engine v2 | ops/devops/TASKS.md | DOING (2025-10-26) | DevOps Guild, Scheduler Guild, CLI Guild | DEVOPS-POLICY-20-004 | Automate policy schema exports and change notifications for CLI consumers. |
| Sprint 20 | Policy Engine v2 | samples/TASKS.md | DONE (2025-10-26) | Samples Guild, Policy Guild | SAMPLES-POLICY-20-001 | Commit baseline/serverless/internal-only policy samples + fixtures. |
| Sprint 20 | Policy Engine v2 | samples/TASKS.md | DONE (2025-10-26) | Samples Guild, UI Guild | SAMPLES-POLICY-20-002 | Produce simulation diff fixtures for UI/CLI tests. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Add new policy scopes (`policy:*`, `findings:read`, `effective:write`). |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-POLICY-20-002 | Enforce Policy Engine service identity and scope checks at gateway. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-POLICY-20-003 | Update Authority docs/config samples for policy scopes + workflows. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Bench/TASKS.md | DONE (2025-10-26) | Bench Guild, Policy Guild | BENCH-POLICY-20-001 | Create policy evaluation benchmark suite + baseline metrics. |
| Sprint 20 | Policy Engine v2 | ops/devops/TASKS.md | DONE (2025-10-27) | DevOps Guild, Scheduler Guild, CLI Guild | DEVOPS-POLICY-20-004 | Automate policy schema exports and change notifications for CLI consumers. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Bench/TASKS.md | BLOCKED (waiting on SCHED-WORKER-20-302) | Bench Guild, Scheduler Guild | BENCH-POLICY-20-002 | Add incremental run benchmark capturing delta SLA compliance. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild | CLI-POLICY-20-002 | Implement `stella policy simulate` with diff outputs + exit codes. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Cli/TASKS.md | DONE (2025-10-27) | DevEx/CLI Guild | CLI-POLICY-20-002 | Implement `stella policy simulate` with diff outputs + exit codes. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild, Docs Guild | CLI-POLICY-20-003 | Extend `stella findings` commands with policy filters and explain view. |
> 2025-10-27: Backend helpers drafted but command integration/tests pending; task reset to TODO awaiting follow-up.
| Sprint 20 | Policy Engine v2 | src/StellaOps.Concelier.Core/TASKS.md | TODO | Concelier Core Guild | CONCELIER-POLICY-20-002 | Strengthen linkset builders with equivalence tables + range parsing. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Concelier.Storage.Mongo/TASKS.md | TODO | Concelier Storage Guild | CONCELIER-POLICY-20-003 | Add advisory selection cursors + change-stream checkpoints for policy runs. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Concelier.WebService/TASKS.md | TODO | Concelier WebService Guild | CONCELIER-POLICY-20-001 | Provide advisory selection endpoints for policy engine (batch PURL/ID). |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Excititor.Core/TASKS.md | TODO | Excititor Core Guild | EXCITITOR-POLICY-20-002 | Enhance VEX linkset scope + version resolution for policy accuracy. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | TODO | Excititor Storage Guild | EXCITITOR-POLICY-20-003 | Introduce VEX selection cursors + change-stream checkpoints. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Excititor WebService Guild | EXCITITOR-POLICY-20-001 | Ship VEX selection APIs aligned with policy join requirements. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | DONE (2025-10-26) | Policy Guild, Platform Guild | POLICY-ENGINE-20-000 | Spin up new Policy Engine service host with DI bootstrap and Authority wiring. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | DONE (2025-10-26) | Policy Guild | POLICY-ENGINE-20-001 | Deliver `stella-dsl@1` parser + IR compiler with diagnostics and checksums. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | BLOCKED (2025-10-26) | Policy Guild | POLICY-ENGINE-20-002 | Implement deterministic rule evaluator with priority/first-match semantics. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild, Concelier Core, Excititor Core | POLICY-ENGINE-20-003 | Build SBOM↔advisory↔VEX linkset joiners with deterministic batching. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild, Storage Guild | POLICY-ENGINE-20-004 | Materialize effective findings with append-only history and tenant scoping. |
@@ -134,7 +81,6 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-20-007 | Emit policy metrics, traces, and sampled rule-hit logs. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild, QA Guild | POLICY-ENGINE-20-008 | Add unit/property/golden/perf suites verifying determinism + SLA. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild, Storage Guild | POLICY-ENGINE-20-009 | Define Mongo schemas/indexes + migrations for policies/runs/findings. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-26) | Scheduler Models Guild | SCHED-MODELS-20-001 | Define policy run/diff DTOs + validation helpers. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Scheduler.Models/TASKS.md | TODO | Scheduler Models Guild | SCHED-MODELS-20-002 | Update schema docs with policy run lifecycle samples. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Scheduler.WebService/TASKS.md | TODO | Scheduler WebService Guild | SCHED-WEB-20-001 | Expose policy run scheduling APIs with scope enforcement. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Scheduler.WebService/TASKS.md | TODO | Scheduler WebService Guild | SCHED-WEB-20-002 | Provide simulation trigger endpoint returning diff metadata. |
@@ -149,38 +95,34 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 20 | Policy Engine v2 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-POLICY-20-002 | Add pagination, filters, deterministic ordering to policy listings. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild, QA Guild | WEB-POLICY-20-003 | Map engine errors to `ERR_POL_*` responses with contract tests. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Web/TASKS.md | TODO | Platform Reliability Guild | WEB-POLICY-20-004 | Introduce rate limits/quotas + metrics for simulation endpoints. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core Guild | AUTH-GRAPH-21-001 | Introduce graph scopes (`graph:*`) with configuration binding and defaults. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core Guild | AUTH-GRAPH-21-002 | Enforce graph scopes/identities at gateway with tenant propagation. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-GRAPH-21-003 | Update security docs/config samples for graph access and least privilege. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Bench/TASKS.md | TODO | Bench Guild, Graph Platform Guild | BENCH-GRAPH-21-001 | Graph viewport/path perf harness (50k/100k nodes) measuring Graph API/Indexer latency and cache hit rates. Executed within Sprint 28 Graph program. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Bench/TASKS.md | TODO | Bench Guild, UI Guild | BENCH-GRAPH-21-002 | Headless UI load benchmark for graph canvas interactions (Playwright) tracking render FPS budgets. Executed within Sprint 28 Graph program. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Concelier.Core/TASKS.md | TODO | Concelier Core Guild | CONCELIER-GRAPH-21-001 | Enrich SBOM normalization with relationships, scopes, entrypoint annotations for Cartographer. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Concelier.Core/TASKS.md | TODO | Concelier Core & Scheduler Guilds | CONCELIER-GRAPH-21-002 | Publish SBOM change events with tenant metadata for graph builds. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Cartographer/TASKS.md | TODO | Cartographer Guild | CARTO-GRAPH-21-010 | Replace hard-coded `graph:*` scope strings with shared constants once graph services integrate. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Excititor.Core/TASKS.md | TODO | Excititor Core Guild | EXCITITOR-GRAPH-21-001 | Deliver batched VEX/advisory fetch helpers for inspector linkouts. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Excititor.Core/TASKS.md | TODO | Excititor Core Guild | EXCITITOR-GRAPH-21-002 | Enrich overlay metadata with VEX justification summaries for graph overlays. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | TODO | Excititor Storage Guild | EXCITITOR-GRAPH-21-005 | Create indexes/materialized views for VEX lookups by PURL/policy. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.SbomService/TASKS.md | TODO | SBOM Service Guild | SBOM-SERVICE-21-001 | Expose normalized SBOM projection API with relationships, scopes, entrypoints. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.SbomService/TASKS.md | TODO | SBOM Service & Scheduler Guilds | SBOM-SERVICE-21-002 | Emit SBOM version change events for Cartographer build queue. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.SbomService/TASKS.md | TODO | SBOM Service Guild | SBOM-SERVICE-21-003 | Provide entrypoint management API with tenant overrides. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.SbomService/TASKS.md | TODO | SBOM Service & Observability Guilds | SBOM-SERVICE-21-004 | Add metrics/traces/logs for SBOM projections. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-26) | Scheduler Models Guild | SCHED-MODELS-21-001 | Define job DTOs for graph builds/overlay refresh (`GraphBuildJob`, `GraphOverlayJob`) with deterministic serialization and status enums; document in `src/StellaOps.Scheduler.Models/docs/SCHED-MODELS-21-001-GRAPH-JOBS.md`. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-26) | Scheduler Models Guild | SCHED-MODELS-21-002 | Publish schema docs/sample payloads for graph job lifecycle. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Scheduler.WebService/TASKS.md | TODO | Scheduler WebService Guild | SCHED-WEB-21-002 | Expose overlay lag metrics and job completion hooks for Cartographer. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-GRAPH-21-001 | Add gateway routes for graph APIs with scope enforcement and streaming. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-GRAPH-21-002 | Implement bbox/zoom/path validation and pagination for graph endpoints. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform & QA Guilds | WEB-GRAPH-21-003 | Map graph errors to `ERR_Graph_*` and support export streaming. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base & Policy Guilds | WEB-GRAPH-21-004 | Wire Policy Engine simulation overlays into graph responses. |
| Sprint 22 | Link-Not-Merge v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-LNM-22-001 | Publish advisories aggregation doc with observation/linkset philosophy. |
| Sprint 22 | Link-Not-Merge v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-LNM-22-002 | Publish VEX aggregation doc describing observation/linkset flow. |
| Sprint 22 | Link-Not-Merge v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-LNM-22-005 | Document UI evidence panel with conflict badges/AOC drill-down. |
| Sprint 22 | Link-Not-Merge v1 | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-LNM-22-001 | Execute advisory observation/linkset migration/backfill and automation. |
| Sprint 22 | Link-Not-Merge v1 | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-LNM-22-002 | Run VEX observation/linkset migration/backfill with monitoring/runbook. |
| Sprint 22 | Link-Not-Merge v1 | samples/TASKS.md | TODO | Samples Guild | SAMPLES-LNM-22-001 | Add advisory observation/linkset fixtures with conflicts. |
| Sprint 22 | Link-Not-Merge v1 | samples/TASKS.md | TODO | Samples Guild | SAMPLES-LNM-22-002 | Add VEX observation/linkset fixtures with status disagreements. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Bench/TASKS.md | BLOCKED (2025-10-27) | Bench Guild, Graph Platform Guild | BENCH-GRAPH-21-001 | Graph viewport/path perf harness (50k/100k nodes) measuring Graph API/Indexer latency and cache hit rates. Executed within Sprint 28 Graph program. Upstream Graph API/indexer contracts (`GRAPH-API-28-003`, `GRAPH-INDEX-28-006`) still pending, so benchmarks cannot target stable endpoints yet. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Bench/TASKS.md | BLOCKED (2025-10-27) | Bench Guild, UI Guild | BENCH-GRAPH-21-002 | Headless UI load benchmark for graph canvas interactions (Playwright) tracking render FPS budgets. Executed within Sprint 28 Graph program. Depends on BENCH-GRAPH-21-001 and UI Graph Explorer (`UI-GRAPH-24-001`), both pending. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Concelier.Core/TASKS.md | BLOCKED (2025-10-27) | Concelier Core Guild | CONCELIER-GRAPH-21-001 | Enrich SBOM normalization with relationships, scopes, entrypoint annotations for Cartographer. Requires finalized schemas from `CONCELIER-POLICY-20-002` and Cartographer event contract (`CARTO-GRAPH-21-002`). |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Concelier.Core/TASKS.md | BLOCKED (2025-10-27) | Concelier Core & Scheduler Guilds | CONCELIER-GRAPH-21-002 | Publish SBOM change events with tenant metadata for graph builds. Awaiting projection schema from `CONCELIER-GRAPH-21-001` and Cartographer webhook expectations. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Cartographer/TASKS.md | DONE (2025-10-27) | Cartographer Guild | CARTO-GRAPH-21-010 | Replace hard-coded `graph:*` scope strings with shared constants once graph services integrate. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Excititor.Core/TASKS.md | BLOCKED (2025-10-27) | Excititor Core Guild | EXCITITOR-GRAPH-21-001 | Deliver batched VEX/advisory fetch helpers for inspector linkouts. Waiting on linkset enrichment (`EXCITITOR-POLICY-20-002`) and Cartographer inspector contract (`CARTO-GRAPH-21-005`). |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Excititor.Core/TASKS.md | BLOCKED (2025-10-27) | Excititor Core Guild | EXCITITOR-GRAPH-21-002 | Enrich overlay metadata with VEX justification summaries for graph overlays. Depends on `EXCITITOR-GRAPH-21-001` and Policy overlay schema (`POLICY-ENGINE-30-001`). |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | BLOCKED (2025-10-27) | Excititor Storage Guild | EXCITITOR-GRAPH-21-005 | Create indexes/materialized views for VEX lookups by PURL/policy. Awaiting access pattern specs from `EXCITITOR-GRAPH-21-001`. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.SbomService/TASKS.md | BLOCKED (2025-10-27) | SBOM Service Guild | SBOM-SERVICE-21-001 | Expose normalized SBOM projection API with relationships, scopes, entrypoints. Waiting on Concelier projection schema (`CONCELIER-GRAPH-21-001`). |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.SbomService/TASKS.md | BLOCKED (2025-10-27) | SBOM Service & Scheduler Guilds | SBOM-SERVICE-21-002 | Emit SBOM version change events for Cartographer build queue. Depends on SBOM projection API (`SBOM-SERVICE-21-001`) and Scheduler contracts. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.SbomService/TASKS.md | BLOCKED (2025-10-27) | SBOM Service Guild | SBOM-SERVICE-21-003 | Provide entrypoint management API with tenant overrides. Blocked by SBOM projection API contract. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.SbomService/TASKS.md | BLOCKED (2025-10-27) | SBOM Service & Observability Guilds | SBOM-SERVICE-21-004 | Add metrics/traces/logs for SBOM projections. Requires projection pipeline from `SBOM-SERVICE-21-001`. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-002 | Expose overlay lag metrics and job completion hooks for Cartographer. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Web/TASKS.md | BLOCKED (2025-10-27) | BE-Base Platform Guild | WEB-GRAPH-21-001 | Add gateway routes for graph APIs with scope enforcement and streaming. Upstream Graph API (`GRAPH-API-28-003`) and Authority scope work (`AUTH-VULN-24-001`) pending. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Web/TASKS.md | BLOCKED (2025-10-27) | BE-Base Platform Guild | WEB-GRAPH-21-002 | Implement bbox/zoom/path validation and pagination for graph endpoints. Depends on core proxy routes. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Web/TASKS.md | BLOCKED (2025-10-27) | BE-Base Platform & QA Guilds | WEB-GRAPH-21-003 | Map graph errors to `ERR_Graph_*` and support export streaming. Requires `WEB-GRAPH-21-001`. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Web/TASKS.md | BLOCKED (2025-10-27) | BE-Base & Policy Guilds | WEB-GRAPH-21-004 | Wire Policy Engine simulation overlays into graph responses. Waiting on Graph routes and Policy overlay schema (`POLICY-ENGINE-30-002`). |
| Sprint 22 | Link-Not-Merge v1 | docs/TASKS.md | BLOCKED (2025-10-27) | Docs Guild | DOCS-LNM-22-001 | Publish advisories aggregation doc with observation/linkset philosophy. |
> Blocked by `CONCELIER-LNM-21-001..003`; draft doc exists but final alignment waits for schema/API delivery.
| Sprint 22 | Link-Not-Merge v1 | docs/TASKS.md | BLOCKED (2025-10-27) | Docs Guild | DOCS-LNM-22-002 | Publish VEX aggregation doc describing observation/linkset flow. |
> Blocked by `EXCITITOR-LNM-21-001..003`; draft doc staged pending observation/linkset implementation.
| Sprint 22 | Link-Not-Merge v1 | docs/TASKS.md | BLOCKED (2025-10-27) | Docs Guild | DOCS-LNM-22-005 | Document UI evidence panel with conflict badges/AOC drill-down. |
> Blocked by `UI-LNM-22-001..003`; need shipping UI to capture screenshots and finalize guidance.
| Sprint 22 | Link-Not-Merge v1 | ops/devops/TASKS.md | BLOCKED (2025-10-27) | DevOps Guild | DEVOPS-LNM-22-001 | Execute advisory observation/linkset migration/backfill and automation. |
| Sprint 22 | Link-Not-Merge v1 | ops/devops/TASKS.md | BLOCKED (2025-10-27) | DevOps Guild | DEVOPS-LNM-22-002 | Run VEX observation/linkset migration/backfill with monitoring/runbook. |
| Sprint 22 | Link-Not-Merge v1 | samples/TASKS.md | BLOCKED (2025-10-27) | Samples Guild | SAMPLES-LNM-22-001 | Add advisory observation/linkset fixtures with conflicts. |
| Sprint 22 | Link-Not-Merge v1 | samples/TASKS.md | BLOCKED (2025-10-27) | Samples Guild | SAMPLES-LNM-22-002 | Add VEX observation/linkset fixtures with status disagreements. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Authority/TASKS.md | TODO | Authority Core Guild | AUTH-AOC-22-001 | Roll out new advisory/vex ingest/read scopes. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Bench/TASKS.md | DONE (2025-10-26) | Bench Guild | BENCH-LNM-22-001 | Benchmark advisory observation ingest/correlation throughput. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Bench/TASKS.md | DONE (2025-10-26) | Bench Guild | BENCH-LNM-22-002 | Benchmark VEX ingest/correlation latency and event emission. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild | CLI-LNM-22-001 | Implement advisory observation/linkset CLI commands with JSON/OSV export. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild | CLI-LNM-22-002 | Implement VEX observation/linkset CLI commands. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Concelier.Core/TASKS.md | TODO | Concelier Core Guild | CONCELIER-LNM-21-001 | Define immutable advisory observation schema with AOC metadata. |
@@ -203,23 +145,14 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-LNM-22-003 | Add VEX evidence tab with conflict indicators and exports. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-LNM-21-001 | Surface advisory observation/linkset APIs through gateway with RBAC. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-LNM-21-002 | Expose VEX observation/linkset endpoints with export handling. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-001 | Publish `/docs/ui/console-overview.md` (IA, tenant model, filters, AOC alignment). |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-002 | Author `/docs/ui/navigation.md` with route map, filters, keyboard shortcuts, deep links. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-003 | Document `/docs/ui/sbom-explorer.md` covering catalog, graph, overlays, exports. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-004 | Produce `/docs/ui/advisories-and-vex.md` detailing aggregation-not-merge UX. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-005 | Write `/docs/ui/findings.md` with filters, explain, exports, CLI parity notes. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-006 | Publish `/docs/ui/policies.md` (editor, simulation, approvals, RBAC). |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-007 | Document `/docs/ui/runs.md` with SSE monitoring, diff, retries, evidence downloads. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-008 | Draft `/docs/ui/admin.md` covering tenants, roles, tokens, integrations, fresh-auth. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-27) | Docs Guild | DOCS-CONSOLE-23-009 | Publish `/docs/ui/downloads.md` aligning manifest with commands and offline flow. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-27) | Docs Guild, Deployment Guild, Console Guild | DOCS-CONSOLE-23-010 | Write `/docs/deploy/console.md` (Helm, ingress, TLS, env vars, health checks). |
| Sprint 23 | StellaOps Console | docs/TASKS.md | TODO | Docs Guild | DOCS-CONSOLE-23-011 | Update `/docs/install/docker.md` to include console image, compose/Helm/offline examples. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | TODO | Docs Guild | DOCS-CONSOLE-23-012 | Publish `/docs/security/console-security.md` covering OIDC, scopes, CSP, evidence handling. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | TODO | Docs Guild | DOCS-CONSOLE-23-013 | Write `/docs/observability/ui-telemetry.md` cataloguing metrics/logs/dashboards/alerts. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | TODO | Docs Guild | DOCS-CONSOLE-23-014 | Maintain `/docs/cli-vs-ui-parity.md` matrix with CI drift detection guidance. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-28) | Docs Guild | DOCS-CONSOLE-23-011 | Update `/docs/install/docker.md` to include console image, compose/Helm/offline examples. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-28) | Docs Guild | DOCS-CONSOLE-23-012 | Publish `/docs/security/console-security.md` covering OIDC, scopes, CSP, evidence handling. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-28) | Docs Guild | DOCS-CONSOLE-23-013 | Write `/docs/observability/ui-telemetry.md` cataloguing metrics/logs/dashboards/alerts. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-28) | Docs Guild | DOCS-CONSOLE-23-014 | Maintain `/docs/cli-vs-ui-parity.md` matrix with CI drift detection guidance. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | TODO | Docs Guild | DOCS-CONSOLE-23-015 | Produce `/docs/architecture/console.md` describing packages, data flow, SSE design. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | TODO | Docs Guild | DOCS-CONSOLE-23-016 | Refresh `/docs/accessibility.md` with console keyboard flows, tokens, testing tools. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-28) | Docs Guild | DOCS-CONSOLE-23-016 | Refresh `/docs/accessibility.md` with console keyboard flows, tokens, testing tools. <br>2025-10-28: Published guide covering keyboard matrix, screen-reader behaviour, colour tokens, testing workflow, offline guidance, and compliance checklist. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | TODO | Docs Guild | DOCS-CONSOLE-23-017 | Create `/docs/examples/ui-tours.md` walkthroughs with annotated screenshots/GIFs. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | TODO | Docs Guild | DOCS-CONSOLE-23-018 | Execute console security checklist and record Security Guild sign-off. |
| Sprint 23 | StellaOps Console | ops/deployment/TASKS.md | TODO | Deployment Guild | DOWNLOADS-CONSOLE-23-001 | Maintain signed downloads manifest pipeline feeding Console + docs parity checks. |
| Sprint 23 | StellaOps Console | ops/devops/TASKS.md | BLOCKED (2025-10-26) | DevOps Guild | DEVOPS-CONSOLE-23-001 | Stand up console CI pipeline (pnpm cache, lint, tests, Playwright, Lighthouse, offline runners). |
| Sprint 23 | StellaOps Console | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-CONSOLE-23-002 | Deliver `stella-console` container + Helm overlays with SBOM/provenance and offline packaging. |
@@ -246,7 +179,9 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 23 | StellaOps Console | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-CONSOLE-23-004 | Implement `/console/search` fan-out router for CVE/GHSA/PURL/SBOM lookups with caching and RBAC. |
| Sprint 23 | StellaOps Console | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild, DevOps Guild | WEB-CONSOLE-23-005 | Serve `/console/downloads` manifest with signed image metadata and offline guidance. |
| Sprint 24 | Graph & Vuln Explorer v1 | src/StellaOps.Authority/TASKS.md | TODO | Authority Core Guild | AUTH-VULN-24-001 | Extend scopes (`vuln:read`) and signed permalinks. |
| Sprint 24 | Graph & Vuln Explorer v1 | src/StellaOps.Concelier.Core/TASKS.md | DOING (2025-10-27) | Concelier Core Guild | CONCELIER-GRAPH-24-001 | Surface raw advisory observations/linksets for overlay services (no derived aggregation in ingestion). |
> 2025-10-27: Scope enforcement spike paused; no production change landed.
| Sprint 24 | Graph & Vuln Explorer v1 | src/StellaOps.Concelier.Core/TASKS.md | TODO | Concelier Core Guild | CONCELIER-GRAPH-24-001 | Surface raw advisory observations/linksets for overlay services (no derived aggregation in ingestion). |
> 2025-10-27: Prototype not merged (query layer + CLI consumer under review); resetting to TODO.
| Sprint 24 | Graph & Vuln Explorer v1 | src/StellaOps.Excititor.Core/TASKS.md | TODO | Excititor Core Guild | EXCITITOR-GRAPH-24-001 | Surface raw VEX statements/linksets for overlay services (no suppression/precedence logic here). |
| Sprint 24 | Graph & Vuln Explorer v1 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-60-001 | Maintain Redis effective decision maps for overlays. |
| Sprint 24 | Graph & Vuln Explorer v1 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-60-002 | Provide simulation bridge for graph what-if APIs. |
@@ -255,7 +190,7 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 25 | Exceptions v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-EXC-25-001 | Document exception governance concepts/workflow. |
| Sprint 25 | Exceptions v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-EXC-25-002 | Document approvals routing / MFA requirements. |
| Sprint 25 | Exceptions v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-EXC-25-003 | Publish API documentation for exceptions endpoints. |
| Sprint 25 | Exceptions v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-EXC-25-004 | Document policy exception effects + simulation. |
| Sprint 25 | Exceptions v1 | docs/TASKS.md | DONE (2025-10-27) | Docs Guild | DOCS-EXC-25-004 | Document policy exception effects + simulation. |
| Sprint 25 | Exceptions v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-EXC-25-005 | Document UI exception center + badges. |
| Sprint 25 | Exceptions v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-EXC-25-006 | Update CLI docs for exception commands. |
| Sprint 25 | Exceptions v1 | docs/TASKS.md | TODO | Docs Guild | DOCS-EXC-25-007 | Write migration guide for governed exceptions. |
@@ -263,12 +198,12 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 25 | Exceptions v1 | src/StellaOps.Authority/TASKS.md | TODO | Authority Core & Docs Guild | AUTH-EXC-25-002 | Update docs/config samples for exception governance. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild | CLI-EXC-25-001 | Implement CLI exception workflow commands. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild | CLI-EXC-25-002 | Extend policy simulate with exception overrides. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-70-001 | Add exception evaluation layer with specificity + effects. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Policy.Engine/TASKS.md | DONE (2025-10-27) | Policy Guild | POLICY-ENGINE-70-001 | Add exception evaluation layer with specificity + effects. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-70-002 | Create exception collections/bindings storage + repos. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-70-003 | Implement Redis exception cache + invalidation. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-70-004 | Add metrics/tracing/logging for exception application. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-70-005 | Hook workers/events for activation/expiry. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Policy/TASKS.md | TODO | Policy Guild | POLICY-EXC-25-001 | Extend SPL schema to reference exception effects and routing. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Policy/TASKS.md | DONE (2025-10-27) | Policy Guild | POLICY-EXC-25-001 | Extend SPL schema to reference exception effects and routing. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-25-101 | Implement exception lifecycle worker for activation/expiry. |
| Sprint 25 | Exceptions v1 | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-25-102 | Add expiring notification job & metrics. |
| Sprint 25 | Exceptions v1 | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-EXC-25-001 | Deliver Exception Center (list/kanban) with workflows. |
@@ -300,11 +235,11 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 26 | Reachability v1 | src/StellaOps.Policy/TASKS.md | TODO | Policy Guild | POLICY-SPL-24-001 | Extend SPL schema with reachability predicates/actions. |
| Sprint 26 | Reachability v1 | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-26-201 | Implement reachability joiner worker. |
| Sprint 26 | Reachability v1 | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-26-202 | Implement staleness monitor + notifications. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | TODO | Signals Guild | SIGNALS-24-001 | Stand up Signals API skeleton with RBAC + health checks. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | TODO | Signals Guild | SIGNALS-24-002 | Implement callgraph ingestion/normalization pipeline. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | TODO | Signals Guild | SIGNALS-24-003 | Ingest runtime facts and persist context data with AOC provenance. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | TODO | Signals Guild | SIGNALS-24-004 | Deliver reachability scoring engine writing reachability facts. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | TODO | Signals Guild | SIGNALS-24-005 | Implement caches + signals events. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | BLOCKED (2025-10-27) | Signals Guild, Authority Guild | SIGNALS-24-001 | Stand up Signals API skeleton with RBAC + health checks. Host scaffold ready, waiting on `AUTH-SIG-26-001` to finalize scope issuance and tenant enforcement. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | BLOCKED (2025-10-27) | Signals Guild | SIGNALS-24-002 | Implement callgraph ingestion/normalization pipeline. Waiting on SIGNALS-24-001 skeleton deployment. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | BLOCKED (2025-10-27) | Signals Guild | SIGNALS-24-003 | Ingest runtime facts and persist context data with AOC provenance. Depends on SIGNALS-24-001 base host. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | BLOCKED (2025-10-27) | Signals Guild | SIGNALS-24-004 | Deliver reachability scoring engine writing reachability facts. Blocked until ingestion pipelines unblock. |
| Sprint 26 | Reachability v1 | src/StellaOps.Signals/TASKS.md | BLOCKED (2025-10-27) | Signals Guild | SIGNALS-24-005 | Implement caches + signals events. Downstream of SIGNALS-24-004. |
| Sprint 26 | Reachability v1 | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SIG-26-001 | Add reachability columns/badges to Vulnerability Explorer. |
| Sprint 26 | Reachability v1 | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SIG-26-002 | Enhance Why drawer with call path/timeline. |
| Sprint 26 | Reachability v1 | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SIG-26-003 | Add reachability overlay/time slider to SBOM Graph. |
@@ -312,20 +247,34 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 26 | Reachability v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-SIG-26-001 | Expose signals proxy endpoints with pagination and RBAC. |
| Sprint 26 | Reachability v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-SIG-26-002 | Join reachability data into policy/vuln responses. |
| Sprint 26 | Reachability v1 | src/StellaOps.Web/TASKS.md | TODO | BE-Base Platform Guild | WEB-SIG-26-003 | Support reachability overrides in simulate APIs. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Policy Guilds | DOCS-POLICY-27-001 | Publish `/docs/policy/studio-overview.md` with lifecycle + roles. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Console Guilds | DOCS-POLICY-27-002 | Write `/docs/policy/authoring.md` with templates/snippets/lint rules. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Policy Registry Guilds | DOCS-POLICY-27-003 | Document `/docs/policy/versioning-and-publishing.md`. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Scheduler Guilds | DOCS-POLICY-27-004 | Publish `/docs/policy/simulation.md` with quick vs batch guidance. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Product Ops | DOCS-POLICY-27-005 | Author `/docs/policy/review-and-approval.md`. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Policy Guilds | DOCS-POLICY-27-006 | Publish `/docs/policy/promotion.md` covering canary + rollback. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & DevEx/CLI Guilds | DOCS-POLICY-27-007 | Update `/docs/policy/cli.md` with new commands + JSON schemas. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Policy Registry Guilds | DOCS-POLICY-27-008 | Publish `/docs/policy/api.md` aligning with Registry OpenAPI. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Security Guilds | DOCS-POLICY-27-009 | Create `/docs/security/policy-attestations.md`. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Architecture Guilds | DOCS-POLICY-27-010 | Write `/docs/architecture/policy-registry.md`. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Observability Guilds | DOCS-POLICY-27-011 | Publish `/docs/observability/policy-telemetry.md`. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Ops Guilds | DOCS-POLICY-27-012 | Write `/docs/runbooks/policy-incident.md`. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Policy Guilds | DOCS-POLICY-27-013 | Update `/docs/examples/policy-templates.md` with new templates/snippets. |
| Sprint 27 | Policy Studio | docs/TASKS.md | TODO | Docs & Policy Registry Guilds | DOCS-POLICY-27-014 | Refresh `/docs/aoc/aoc-guardrails.md` with Studio guardrails. |
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Policy Guilds | DOCS-POLICY-27-001 | Publish `/docs/policy/studio-overview.md` with lifecycle + roles. |
> Blocked by `REGISTRY-API-27-001` and `POLICY-ENGINE-27-001`; revisit once spec and compile enrichments land.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Console Guilds | DOCS-POLICY-27-002 | Write `/docs/policy/authoring.md` with templates/snippets/lint rules. |
> Blocked by `CONSOLE-STUDIO-27-001` pending; waiting on Studio authoring UX.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Policy Registry Guilds | DOCS-POLICY-27-003 | Document `/docs/policy/versioning-and-publishing.md`. |
> Blocked by `REGISTRY-API-27-007` pending publish/sign pipeline.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Scheduler Guilds | DOCS-POLICY-27-004 | Publish `/docs/policy/simulation.md` with quick vs batch guidance. |
> Blocked by `REGISTRY-API-27-005`/`SCHED-WORKER-27-301` pending batch simulation.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Product Ops | DOCS-POLICY-27-005 | Author `/docs/policy/review-and-approval.md`. |
> Blocked by `REGISTRY-API-27-006` review workflow outstanding.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Policy Guilds | DOCS-POLICY-27-006 | Publish `/docs/policy/promotion.md` covering canary + rollback. |
> Blocked by `REGISTRY-API-27-008` promotion APIs not ready.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & DevEx/CLI Guilds | DOCS-POLICY-27-007 | Update `/docs/policy/cli.md` with new commands + JSON schemas. |
> Blocked by `CLI-POLICY-27-001..004` CLI commands missing.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Policy Registry Guilds | DOCS-POLICY-27-008 | Publish `/docs/policy/api.md` aligning with Registry OpenAPI. |
> Blocked by Registry OpenAPI (`REGISTRY-API-27-001..008`) incomplete.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Security Guilds | DOCS-POLICY-27-009 | Create `/docs/security/policy-attestations.md`. |
> Blocked by `AUTH-POLICY-27-002` signing integration pending.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Architecture Guilds | DOCS-POLICY-27-010 | Write `/docs/architecture/policy-registry.md`. |
> Blocked by `REGISTRY-API-27-001` & `SCHED-WORKER-27-301` not delivered.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Observability Guilds | DOCS-POLICY-27-011 | Publish `/docs/observability/policy-telemetry.md`. |
> Blocked by `DEVOPS-POLICY-27-004` observability work outstanding.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Ops Guilds | DOCS-POLICY-27-012 | Write `/docs/runbooks/policy-incident.md`. |
> Blocked by `DEPLOY-POLICY-27-002` ops playbooks pending.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Policy Guilds | DOCS-POLICY-27-013 | Update `/docs/examples/policy-templates.md`. |
> Blocked by `CONSOLE-STUDIO-27-001`/`REGISTRY-API-27-002` templates missing.
| Sprint 27 | Policy Studio | docs/TASKS.md | BLOCKED (2025-10-27) | Docs & Policy Registry Guilds | DOCS-POLICY-27-014 | Refresh `/docs/aoc/aoc-guardrails.md` with Studio guardrails. |
> Blocked by `REGISTRY-API-27-003` & `WEB-POLICY-27-001` guardrails not implemented.
| Sprint 27 | Policy Studio | ops/deployment/TASKS.md | TODO | Deployment & Policy Registry Guilds | DEPLOY-POLICY-27-001 | Create Helm/Compose overlays for Policy Registry + workers with signing config. |
| Sprint 27 | Policy Studio | ops/deployment/TASKS.md | TODO | Deployment & Policy Guilds | DEPLOY-POLICY-27-002 | Document policy rollout/rollback playbooks in runbook. |
| Sprint 27 | Policy Studio | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-POLICY-27-001 | Add CI stage for policy lint/compile/test + secret scanning and artifacts. |
@@ -411,9 +360,6 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 28 | Graph Explorer | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-30-001 | Finalize graph overlay contract + projection API. |
| Sprint 28 | Graph Explorer | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ENGINE-30-002 | Implement simulation overlay bridge for Graph Explorer queries. |
| Sprint 28 | Graph Explorer | src/StellaOps.Policy.Engine/TASKS.md | TODO | Policy & Scheduler Guilds | POLICY-ENGINE-30-003 | Emit change events for effective findings supporting graph overlays. |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-001 | Provide graph build/overlay job APIs; see `docs/SCHED-WEB-21-001-GRAPH-APIS.md`. |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-002 | Provide overlay lag metrics endpoint/webhook; see `docs/SCHED-WEB-21-001-GRAPH-APIS.md`. |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild, Authority Core Guild | SCHED-WEB-21-003 | Replace header auth with Authority scopes using `StellaOpsScopes`; dev fallback only when `Scheduler:Authority:Enabled=false`. |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.WebService/TASKS.md | DOING (2025-10-26) | Scheduler WebService Guild, Scheduler Storage Guild | SCHED-WEB-21-004 | Persist graph jobs + emit completion events/webhook. |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-21-201 | Run graph build worker for SBOM snapshots with retries/backoff. |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-21-202 | Execute overlay refresh worker subscribing to change events. |
@@ -783,10 +729,8 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | docs/TASKS.md | TODO | Docs Guild | DOCS-OBS-50-003 | Publish structured logging guide `/docs/observability/logging.md` with examples and imposed rule banner. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | docs/TASKS.md | TODO | Docs Guild | DOCS-OBS-50-004 | Publish tracing guide `/docs/observability/tracing.md` covering context propagation and sampling. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | docs/TASKS.md | TODO | Docs Guild | DOCS-SEC-OBS-50-001 | Update `/docs/security/redaction-and-privacy.md` for telemetry privacy controls. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-OBS-50-001 | Deploy default OpenTelemetry collector manifests with secure OTLP pipeline. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | ops/devops/TASKS.md | DOING (2025-10-26) | DevOps Guild | DEVOPS-OBS-50-002 | Stand up multi-tenant metrics/logs/traces backends with retention and isolation. |
> Staging rollout plan recorded in `docs/ops/telemetry-storage.md`; waiting on Authority-issued tokens and namespace bootstrap.
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-OBS-50-003 | Package telemetry stack configs for offline/air-gapped installs with signatures. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | src/StellaOps.Authority/TASKS.md | TODO | Authority Core & Security Guild | AUTH-OBS-50-001 | Introduce observability/timeline/evidence/attestation scopes and update discovery metadata. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI Guild | CLI-OBS-50-001 | Propagate trace headers from CLI commands and print correlation IDs. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | src/StellaOps.Concelier.Core/TASKS.md | TODO | Concelier Core Guild | CONCELIER-OBS-50-001 | Replace ad-hoc logging with telemetry core across advisory ingestion/linking. |

84
SPRINTS_PRIOR_20251027.md Normal file
View File

@@ -0,0 +1,84 @@
This file describe implementation of Stella Ops (docs/README.md). Implementation must respect rules from AGENTS.md (read if you have not).
| Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description |
| --- | --- | --- | --- | --- | --- | --- |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-NUGET-13-002 | Ensure all solutions/projects prioritize `local-nuget` before public feeds and add restore-order validation. |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-003 | Upgrade `Microsoft.*` dependencies pinned to 8.* to their latest .NET 10 (or 9.x) releases and refresh guidance. |
| Sprint 14 | Release & Offline Ops | ops/deployment/TASKS.md | DONE (2025-10-26) | Deployment Guild | DEVOPS-OPS-14-003 | Deployment/update/rollback automation and channel management documentation. |
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-REL-14-001 | Deterministic build/release pipeline with SBOM/provenance, signing, and manifest generation. |
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild, Scanner Guild | DEVOPS-REL-14-004 | Extend release/offline smoke jobs to cover Python analyzer plug-ins (warm/cold, determinism, signing). |
| Sprint 14 | Release & Offline Ops | ops/licensing/TASKS.md | DONE (2025-10-26) | Licensing Guild | DEVOPS-LIC-14-004 | Registry token service tied to Authority, plan gating, revocation handling, monitoring. |
| Sprint 14 | Release & Offline Ops | ops/offline-kit/TASKS.md | DONE (2025-10-26) | Offline Kit Guild | DEVOPS-OFFLINE-14-002 | Offline kit packaging workflow with integrity verification and documentation. |
| Sprint 15 | Benchmarks | src/StellaOps.Bench/TASKS.md | DONE (2025-10-26) | Bench Guild, Notify Team | BENCH-NOTIFY-15-001 | Notify dispatch throughput bench with results CSV. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-19) | Scheduler Models Guild | SCHED-MODELS-16-101 | Define Scheduler DTOs & validation. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-19) | Scheduler Models Guild | SCHED-MODELS-16-102 | Publish schema docs/sample payloads. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Scheduler Storage Guild | SCHED-STORAGE-16-201 | Mongo schemas/indexes for Scheduler state. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | DONE (2025-10-26) | Scheduler Storage Guild | SCHED-STORAGE-16-202 | Repositories with tenant scoping, TTL, causal consistency. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | DONE (2025-10-26) | Scheduler Storage Guild | SCHED-STORAGE-16-203 | Audit/run stats materialization for UI. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | DONE (2025-10-26) | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-302 | Query APIs for ResolveByPurls/ResolveByVulns/ResolveAll. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | DONE (2025-10-26) | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-301 | Ingest BOM-Index into roaring bitmap store. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-102 | Schedules CRUD (cron validation, pause/resume, audit). |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-103 | Runs API (list/detail/cancel) + impact previews. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-WEB-16-104 | Feedser/Vexer webhook handlers with security enforcement. |
| Sprint 17 | Symbol Intelligence & Forensics | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-RUNTIME-17-004 | Document build-id workflows for SBOMs, runtime events, and debug-store usage. |
| Sprint 17 | Symbol Intelligence & Forensics | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-REL-17-002 | Ship stripped debug artifacts organised by build-id within release/offline kits. |
| Sprint 17 | Symbol Intelligence & Forensics | ops/offline-kit/TASKS.md | DONE (2025-10-26) | Offline Kit Guild, DevOps Guild | DEVOPS-OFFLINE-17-003 | Mirror release debug-store artefacts into Offline Kit packaging and document validation. |
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.Emit/TASKS.md | DONE (2025-10-26) | Emit Guild | SCANNER-EMIT-17-701 | Record GNU build-id for ELF components and surface it in SBOM/diff outputs. |
| Sprint 18 | Launch Readiness | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-LAUNCH-18-001 | Production launch cutover rehearsal and runbook publication. |
| Sprint 18 | Launch Readiness | ops/offline-kit/TASKS.md | DONE (2025-10-26) | Offline Kit Guild, Scanner Guild | DEVOPS-OFFLINE-18-005 | Rebuild Offline Kit with Python analyzer artefacts and refreshed manifest/signature pair. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-AOC-19-001 | Publish aggregation-only contract reference documentation. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Architecture Guild | DOCS-AOC-19-002 | Update architecture overview with AOC boundary diagrams. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Policy Guild | DOCS-AOC-19-003 | Refresh policy engine doc with raw ingestion constraints. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, UI Guild | DOCS-AOC-19-004 | Document console AOC dashboard and drill-down flow. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, CLI Guild | DOCS-AOC-19-005 | Document CLI AOC commands and exit codes. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Observability Guild | DOCS-AOC-19-006 | Document new AOC metrics, traces, and logs. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Authority Core | DOCS-AOC-19-007 | Document new Authority scopes and tenancy enforcement. |
| Sprint 19 | Aggregation-Only Contract Enforcement | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, DevOps Guild | DOCS-AOC-19-008 | Update deployment guide with validator enablement and verify user guidance. |
| Sprint 19 | Aggregation-Only Contract Enforcement | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce new ingestion/auth scopes across Authority. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-POLICY-20-001 | Publish `/docs/policy/overview.md` with compliance checklist. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-POLICY-20-002 | Document DSL grammar + examples in `/docs/policy/dsl.md`. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Authority Core | DOCS-POLICY-20-003 | Write `/docs/policy/lifecycle.md` covering workflow + roles. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Scheduler Guild | DOCS-POLICY-20-004 | Document policy run modes + cursors in `/docs/policy/runs.md`. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Platform Guild | DOCS-POLICY-20-005 | Produce `/docs/api/policy.md` with endpoint schemas + errors. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, CLI Guild | DOCS-POLICY-20-006 | Author `/docs/cli/policy.md` with commands, exit codes, JSON output. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, UI Guild | DOCS-POLICY-20-007 | Create `/docs/ui/policy-editor.md` covering editor, simulation, approvals. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Architecture Guild | DOCS-POLICY-20-008 | Publish `/docs/architecture/policy-engine.md` with sequence diagrams. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Observability Guild | DOCS-POLICY-20-009 | Document metrics/traces/logs in `/docs/observability/policy.md`. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Security Guild | DOCS-POLICY-20-010 | Publish `/docs/security/policy-governance.md` for scopes + approvals. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Policy Guild | DOCS-POLICY-20-011 | Add example policies under `/docs/examples/policies/` with commentary. |
| Sprint 20 | Policy Engine v2 | docs/TASKS.md | DONE (2025-10-26) | Docs Guild, Support Guild | DOCS-POLICY-20-012 | Draft `/docs/faq/policy-faq.md` covering conflicts, determinism, pitfalls. |
| Sprint 20 | Policy Engine v2 | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-POLICY-20-001 | Add DSL lint + compile checks to CI pipelines. |
| Sprint 20 | Policy Engine v2 | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild, QA Guild | DEVOPS-POLICY-20-003 | Add determinism CI job diffing repeated policy runs. |
| Sprint 20 | Policy Engine v2 | samples/TASKS.md | DONE (2025-10-26) | Samples Guild, Policy Guild | SAMPLES-POLICY-20-001 | Commit baseline/serverless/internal-only policy samples + fixtures. |
| Sprint 20 | Policy Engine v2 | samples/TASKS.md | DONE (2025-10-26) | Samples Guild, UI Guild | SAMPLES-POLICY-20-002 | Produce simulation diff fixtures for UI/CLI tests. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Add new policy scopes (`policy:*`, `findings:read`, `effective:write`). |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-POLICY-20-002 | Enforce Policy Engine service identity and scope checks at gateway. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-POLICY-20-003 | Update Authority docs/config samples for policy scopes + workflows. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Bench/TASKS.md | DONE (2025-10-26) | Bench Guild, Policy Guild | BENCH-POLICY-20-001 | Create policy evaluation benchmark suite + baseline metrics. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | DONE (2025-10-26) | Policy Guild, Platform Guild | POLICY-ENGINE-20-000 | Spin up new Policy Engine service host with DI bootstrap and Authority wiring. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Policy.Engine/TASKS.md | DONE (2025-10-26) | Policy Guild | POLICY-ENGINE-20-001 | Deliver `stella-dsl@1` parser + IR compiler with diagnostics and checksums. |
| Sprint 20 | Policy Engine v2 | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-26) | Scheduler Models Guild | SCHED-MODELS-20-001 | Define policy run/diff DTOs + validation helpers. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core Guild | AUTH-GRAPH-21-001 | Introduce graph scopes (`graph:*`) with configuration binding and defaults. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core Guild | AUTH-GRAPH-21-002 | Enforce graph scopes/identities at gateway with tenant propagation. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Authority/TASKS.md | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-GRAPH-21-003 | Update security docs/config samples for graph access and least privilege. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-26) | Scheduler Models Guild | SCHED-MODELS-21-001 | Define job DTOs for graph builds/overlay refresh (`GraphBuildJob`, `GraphOverlayJob`) with deterministic serialization and status enums; document in `src/StellaOps.Scheduler.Models/docs/SCHED-MODELS-21-001-GRAPH-JOBS.md`. |
| Sprint 21 | Graph Explorer v1 | src/StellaOps.Scheduler.Models/TASKS.md | DONE (2025-10-26) | Scheduler Models Guild | SCHED-MODELS-21-002 | Publish schema docs/sample payloads for graph job lifecycle. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Bench/TASKS.md | DONE (2025-10-26) | Bench Guild | BENCH-LNM-22-001 | Benchmark advisory observation ingest/correlation throughput. |
| Sprint 22 | Link-Not-Merge v1 | src/StellaOps.Bench/TASKS.md | DONE (2025-10-26) | Bench Guild | BENCH-LNM-22-002 | Benchmark VEX ingest/correlation latency and event emission. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-001 | Publish `/docs/ui/console-overview.md` (IA, tenant model, filters, AOC alignment). |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-002 | Author `/docs/ui/navigation.md` with route map, filters, keyboard shortcuts, deep links. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-003 | Document `/docs/ui/sbom-explorer.md` covering catalog, graph, overlays, exports. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-004 | Produce `/docs/ui/advisories-and-vex.md` detailing aggregation-not-merge UX. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-005 | Write `/docs/ui/findings.md` with filters, explain, exports, CLI parity notes. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-006 | Publish `/docs/ui/policies.md` (editor, simulation, approvals, RBAC). |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-007 | Document `/docs/ui/runs.md` with SSE monitoring, diff, retries, evidence downloads. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-26) | Docs Guild | DOCS-CONSOLE-23-008 | Draft `/docs/ui/admin.md` covering tenants, roles, tokens, integrations, fresh-auth. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-27) | Docs Guild | DOCS-CONSOLE-23-009 | Publish `/docs/ui/downloads.md` aligning manifest with commands and offline flow. |
| Sprint 23 | StellaOps Console | docs/TASKS.md | DONE (2025-10-27) | Docs Guild, Deployment Guild, Console Guild | DOCS-CONSOLE-23-010 | Write `/docs/deploy/console.md` (Helm, ingress, TLS, env vars, health checks). |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-001 | Provide graph build/overlay job APIs; see `docs/SCHED-WEB-21-001-GRAPH-APIS.md`. |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-002 | Provide overlay lag metrics endpoint/webhook; see `docs/SCHED-WEB-21-001-GRAPH-APIS.md`. |
| Sprint 28 | Graph Explorer | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-26) | Scheduler WebService Guild, Authority Core Guild | SCHED-WEB-21-003 | Replace header auth with Authority scopes using `StellaOpsScopes`; dev fallback only when `Scheduler:Authority:Enabled=false`. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-OBS-50-001 | Deploy default OpenTelemetry collector manifests with secure OTLP pipeline. |
| Sprint 50 | Observability & Forensics Phase 1 Baseline Telemetry | ops/devops/TASKS.md | DONE (2025-10-26) | DevOps Guild | DEVOPS-OBS-50-003 | Package telemetry stack configs for offline/air-gapped installs with signatures. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-WEB-16-101 | Minimal API host with Authority enforcement. |
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | DONE (2025-10-27) | Scheduler Worker Guild | SCHED-WORKER-16-202 | ImpactIndex targeting and shard planning. |

View File

@@ -49,15 +49,17 @@ Authority persists every issued token in MongoDB so operators can audit or revok
### Expectations for resource servers
Resource servers (Concelier WebService, Backend, Agent) **must not** assume in-memory caches are authoritative. They should:
- cache `/jwks` and `/revocations/export` responses within configured lifetimes;
- cache `/jwks` and `/revocations/export` responses within configured lifetimes;
- honour `revokedReason` metadata when shaping audit trails;
- treat `status != "valid"` or missing tokens as immediate denial conditions.
- propagate the `tenant` claim (`X-Stella-Tenant` header in REST calls) and reject requests when the tenant supplied by Authority does not match the resource server's scope; Concelier and Excititor guard endpoints refuse cross-tenant tokens.
### Tenant propagation
- Client provisioning (bootstrap or plug-in) accepts a `tenant` hint. Authority normalises the value (`trim().ToLowerInvariant()`) and persists it alongside the registration. Clients without an explicit tenant remain global.
- Issued principals include the `stellaops:tenant` claim. `PersistTokensHandler` mirrors this claim into `authority_tokens.tenant`, enabling per-tenant revocation and reporting.
- Rate limiter metadata now tags requests with `authority.tenant`, unlocking per-tenant throughput metrics and diagnostic filters. Audit events (`authority.client_credentials.grant`, `authority.password.grant`, bootstrap flows) surface the tenant and login attempt documents index on `{tenant, occurredAt}` for quick queries.
- Client credentials that request `advisory:ingest`, `advisory:read`, `vex:ingest`, `vex:read`, or `aoc:verify` now fail fast when the client registration lacks a tenant hint. Issued tokens are re-validated against persisted tenant metadata, and Authority rejects any cross-tenant replay (`invalid_client`/`invalid_token`), ensuring aggregation-only workloads remain tenant-scoped.
- Password grant flows reuse the client registration's tenant and enforce the configured scope allow-list. Requested scopes outside that list (or mismatched tenants) trigger `invalid_scope`/`invalid_client` failures, ensuring cross-tenant access is denied before token issuance.
### Default service scopes
@@ -66,7 +68,7 @@ Resource servers (Concelier WebService, Backend, Agent) **must not** assume in-m
|----------------------|---------------------------------------|--------------------------------------|-------------------|-----------------|
| `concelier-ingest` | Concelier raw advisory ingestion | `advisory:ingest`, `advisory:read` | `dpop` | `tenant-default` |
| `excitor-ingest` | Excititor raw VEX ingestion | `vex:ingest`, `vex:read` | `dpop` | `tenant-default` |
| `aoc-verifier` | Aggregation-only contract verification | `aoc:verify` | `dpop` | `tenant-default` |
| `aoc-verifier` | Aggregation-only contract verification | `aoc:verify`, `advisory:read`, `vex:read` | `dpop` | `tenant-default` |
| `cartographer-service` | Graph snapshot construction | `graph:write`, `graph:read` | `dpop` | `tenant-default` |
| `graph-api` | Graph Explorer gateway/API | `graph:read`, `graph:export`, `graph:simulate` | `dpop` | `tenant-default` |
| `vuln-explorer-ui` | Vuln Explorer UI/API | `vuln:read` | `dpop` | `tenant-default` |
@@ -188,6 +190,13 @@ POST /internal/clients
For environments with multiple tenants, repeat the call per tenant-specific client (e.g. `concelier-tenant-a`, `concelier-tenant-b`) or append suffixes to the client identifier.
### Aggregation-only verification tokens
- Issue a dedicated client (e.g. `aoc-verifier`) with the scopes `aoc:verify`, `advisory:read`, and `vex:read` for each tenant that runs guard checks. Authority refuses to mint tokens for these scopes unless the client registration provides a tenant hint.
- The CLI (`stella aoc verify --tenant <tenant>`) and Console verification panel both call `/aoc/verify` on Concelier and Excititor. Tokens that omit the tenant claim or present a tenant that does not match the stored registration are rejected with `invalid_client`/`invalid_token`.
- Verification responses map guard failures to `ERR_AOC_00x` codes and Authority emits `authority.client_credentials.grant` + `authority.token.validate_access` audit records containing the tenant and scopes so operators can trace who executed a run.
- For air-gapped or offline replicas, pre-issue verification tokens per tenant and rotate them alongside ingest credentials; the guard endpoints never mutate data and remain safe to expose through the offline kit schedule.
## 7. Configuration Reference
| Section | Key | Description | Notes |

View File

@@ -1,12 +1,12 @@
# component_architecture_concelier.md — **StellaOps Concelier** (2025Q4)
# component_architecture_concelier.md — **StellaOps Concelier** (Sprint22)
> **Scope.** Implementationready architecture for **Concelier**: the vulnerability ingest/normalize/merge/export subsystem that produces deterministic advisory data for the Scanner + Policy + Excititor pipeline. Covers domain model, connectors, merge rules, storage schema, exports, APIs, performance, security, and test matrices.
> **Scope.** Implementation-ready architecture for **Concelier**: the advisory ingestion and Link-Not-Merge (LNM) observation pipeline that produces deterministic raw observations, correlation linksets, and evidence events consumed by Policy Engine, Console, CLI, and Export centers. Covers domain models, connectors, observation/linkset builders, storage schema, events, APIs, performance, security, and test matrices.
---
## 0) Mission & boundaries
**Mission.** Acquire authoritative **vulnerability advisories** (vendor PSIRTs, distros, OSS ecosystems, CERTs), normalize them into a **canonical model**, reconcile aliases and version ranges, and export **deterministic artifacts** (JSON, Trivy DB) for fast backend joins.
**Mission.** Acquire authoritative **vulnerability advisories** (vendor PSIRTs, distros, OSS ecosystems, CERTs), persist them as immutable **observations** under the Aggregation-Only Contract (AOC), construct **linksets** that correlate observations without merging or precedence, and export deterministic evidence bundles (JSON, Trivy DB, Offline Kit) for downstream policy evaluation and operator tooling.
**Boundaries.**
@@ -21,10 +21,12 @@
**Process shape:** single ASP.NET Core service `StellaOps.Concelier.WebService` hosting:
* **Scheduler** with distributed locks (Mongo backed).
* **Connectors** (fetch/parse/map).
* **Merger** (canonical record assembly + precedence).
* **Exporters** (JSON, Trivy DB).
* **Minimal REST** for health/status/trigger/export.
* **Connectors** (fetch/parse/map) that emit immutable observation candidates.
* **Observation writer** enforcing AOC invariants via `AOCWriteGuard`.
* **Linkset builder** that correlates observations into `advisory_linksets` and annotates conflicts.
* **Event publisher** emitting `advisory.observation.updated` and `advisory.linkset.updated` messages.
* **Exporters** (JSON, Trivy DB, Offline Kit slices) fed from observation/linkset stores.
* **Minimal REST** for health/status/trigger/export and observation/linkset reads.
**Scale:** HA by running N replicas; **locks** prevent overlapping jobs per source/exporter.
@@ -36,113 +38,96 @@
### 2.1 Core entities
**Advisory**
#### AdvisoryObservation
```
advisoryId // internal GUID
advisoryKey // stable string key (e.g., CVE-2025-12345 or vendor ID)
title // short title (best-of from sources)
summary // normalized summary (English; i18n optional)
published // earliest source timestamp
modified // latest source timestamp
severity // normalized {none, low, medium, high, critical}
cvss // {v2?, v3?, v4?} objects (vector, baseScore, severity, source)
exploitKnown // bool (e.g., KEV/active exploitation flags)
references[] // typed links (advisory, kb, patch, vendor, exploit, blog)
sources[] // provenance for traceability (doc digests, URIs)
```
**Alias**
```
advisoryId
scheme // CVE, GHSA, RHSA, DSA, USN, MSRC, etc.
value // e.g., "CVE-2025-12345"
```
**Affected**
```
advisoryId
productKey // canonical product identity (see 2.2)
rangeKind // semver | evr | nvra | apk | rpm | deb | generic | exact
introduced? // string (format depends on rangeKind)
fixed? // string (format depends on rangeKind)
lastKnownSafe? // optional explicit safe floor
arch? // arch or platform qualifier if source declares (x86_64, aarch64)
distro? // distro qualifier when applicable (rhel:9, debian:12, alpine:3.19)
ecosystem? // npm|pypi|maven|nuget|golang|…
notes? // normalized notes per source
```
**Reference**
```
advisoryId
url
kind // advisory | patch | kb | exploit | mitigation | blog | cvrf | csaf
sourceTag // e.g., vendor/redhat, distro/debian, oss/ghsa
```
**MergeEvent**
```
advisoryKey
beforeHash // canonical JSON hash before merge
afterHash // canonical JSON hash after merge
mergedAt
inputs[] // source doc digests that contributed
```
**AdvisoryStatement (event log)**
```
statementId // GUID (immutable)
vulnerabilityKey // canonical advisory key (e.g., CVE-2025-12345)
advisoryKey // merge snapshot advisory key (may reference variant)
statementHash // canonical hash of advisory payload
asOf // timestamp of snapshot (UTC)
recordedAt // persistence timestamp (UTC)
inputDocuments[] // document IDs contributing to the snapshot
payload // canonical advisory document (BSON / canonical JSON)
```
**AdvisoryConflict**
```
conflictId // GUID
vulnerabilityKey // canonical advisory key
conflictHash // deterministic hash of conflict payload
asOf // timestamp aligned with originating statement set
recordedAt // persistence timestamp
statementIds[] // related advisoryStatement identifiers
details // structured conflict explanation / merge reasoning
```
- `AdvisoryEventLog` (Concelier.Core) provides the public API for appending immutable statements/conflicts and querying replay history. Inputs are normalized by trimming and lower-casing `vulnerabilityKey`, serializing advisories with `CanonicalJsonSerializer`, and computing SHA-256 hashes (`statementHash`, `conflictHash`) over the canonical JSON payloads. Consumers can replay by key with an optional `asOf` filter to obtain deterministic snapshots ordered by `asOf` then `recordedAt`.
- Conflict explainers are serialized as deterministic `MergeConflictExplainerPayload` records (type, reason, source ranks, winning values); replay clients can parse the payload to render human-readable rationales without re-computing precedence.
- Concelier.WebService exposes the immutable log via `GET /concelier/advisories/{vulnerabilityKey}/replay[?asOf=UTC_ISO8601]`, returning the latest statements (with hex-encoded hashes) and any conflict explanations for downstream exporters and APIs.
**AdvisoryObservation (new in Sprint 24)**
```
observationId // deterministic id: {tenant}:{source}:{upstreamId}:{revision}
```jsonc
observationId // deterministic id: {tenant}:{source.vendor}:{upstreamId}:{revision}
tenant // issuing tenant (lower-case)
source{vendor,stream,api,collectorVersion}
source{
vendor, stream, api, collectorVersion
}
upstream{
upstreamId, documentVersion, contentHash,
fetchedAt, receivedAt, signature{present,format,keyId,signature}}
content{format,specVersion,raw,metadata}
linkset{aliases[], purls[], cpes[], references[{type,url}]}
upstreamId, documentVersion, fetchedAt, receivedAt,
contentHash, signature{present, format?, keyId?, signature?}
}
content{
format, specVersion, raw, metadata?
}
identifiers{
cve?, ghsa?, vendorIds[], aliases[]
}
linkset{
purls[], cpes[], aliases[], references[{type,url}],
reconciledFrom[]
}
createdAt // when Concelier recorded the observation
attributes // optional provenance metadata (e.g., batch, connector)
```
attributes // optional provenance metadata (batch ids, ingest cursor)
```jsonc
The observation is an immutable projection of the raw ingestion document (post provenance validation, pre-merge) that powers LinkNotMerge overlays and Vuln Explorer. Observations live in the `advisory_observations` collection, keyed by tenant + upstream identity. `linkset` provides normalized aliases/PURLs/CPES that downstream services (Graph/Vuln Explorer) join against without triggering merge logic. Concelier.Core exposes strongly-typed models (`AdvisoryObservation`, `AdvisoryObservationLinkset`, etc.) and a Mongo-backed store for filtered queries by tenant/alias; this keeps overlay consumers read-only while preserving AOC guarantees.
#### AdvisoryLinkset
**ExportState**
```jsonc
linksetId // sha256 over sorted (tenant, product/vuln tuple, observation ids)
tenant
key{
vulnerabilityId,
productKey,
confidence // low|medium|high
}
observations[] = [
{
observationId,
sourceVendor,
statement{
status?, severity?, references?, notes?
},
collectedAt
}
]
aliases{
primary,
others[]
}
purls[]
cpes[]
conflicts[]? // see AdvisoryLinksetConflict
createdAt
updatedAt
```jsonc
```
#### AdvisoryLinksetConflict
```jsonc
conflictId // deterministic hash
type // severity-mismatch | affected-range-divergence | reference-clash | alias-inconsistency | metadata-gap
field? // optional JSON pointer (e.g., /statement/severity/vector)
observations[] // per-source values contributing to the conflict
confidence // low|medium|high (heuristic weight)
detectedAt
```jsonc
#### ObservationEvent / LinksetEvent
```jsonc
eventId // ULID
tenant
type // advisory.observation.updated | advisory.linkset.updated
key{
observationId? // on observation event
linksetId? // on linkset event
vulnerabilityId?,
productKey?
}
delta{
added[], removed[], changed[] // normalized summary for consumers
}
hash // canonical hash of serialized delta payload
occurredAt
```jsonc
#### ExportState
```jsonc
exportKind // json | trivydb
baseExportId? // last full baseline
baseDigest? // digest of last full baseline
@@ -150,7 +135,9 @@ lastFullDigest? // digest of last full export
lastDeltaDigest? // digest of last delta export
cursor // per-kind incremental cursor
files[] // last manifest snapshot (path → sha256)
```
```jsonc
Legacy `Advisory`, `Affected`, and merge-centric entities remain in the repository for historical exports and replay but are being phased out as Link-Not-Merge takes over. New code paths must interact with `AdvisoryObservation` / `AdvisoryLinkset` exclusively and emit conflicts through the structured payloads described above.
### 2.2 Product identity (`productKey`)
@@ -193,7 +180,7 @@ public interface IFeedConnector {
Task ParseAsync(IServiceProvider sp, CancellationToken ct); // -> dto collection (validated)
Task MapAsync(IServiceProvider sp, CancellationToken ct); // -> advisory/alias/affected/reference
}
```
```jsonc
* **Fetch**: windowed (cursor), conditional GET (ETag/LastModified), retry/backoff, rate limiting.
* **Parse**: schema validation (JSON Schema, XSD/CSAF), content type checks; write **DTO** with normalized casing.
@@ -215,63 +202,106 @@ public interface IFeedConnector {
---
## 5) Merge engine
## 5) Observation & linkset pipeline
### 5.1 Keying & identity
> **Goal:** deterministically ingest raw documents into immutable observations, correlate them into evidence-rich linksets, and broadcast changes without precedence or mutation.
* Identity graph: **CVE** is primary node; vendor/distro IDs resolved via **Alias** edges (from connectors and Conceliers alias tables).
* `advisoryKey` is the canonical primary key (CVE if present, else vendor/distro key).
### 5.1 Observation flow
### 5.2 Merge algorithm (deterministic)
1. **Connector fetch/parse/map** connectors download upstream payloads, validate signatures, and map to DTOs (identifiers, references, raw payload, provenance).
2. **AOC guard** `AOCWriteGuard` verifies forbidden keys, provenance completeness, tenant claims, timestamp normalization, and content hash idempotency. Violations raise `ERR_AOC_00x` mapped to structured logs and metrics.
3. **Append-only write** observations insert into `advisory_observations`; duplicates by `(tenant, source.vendor, upstream.upstreamId, upstream.contentHash)` become no-ops; new content for same upstream id creates a supersedes chain.
4. **Change feed + event** Mongo change streams trigger `advisory.observation.updated@1` events with deterministic payloads (IDs, hash, supersedes pointer, linkset summary). Policy Engine, Offline Kit builder, and guard dashboards subscribe.
1. **Gather** all rows for `advisoryKey` (across sources).
2. **Select title/summary** by precedence source (vendor>distro>ecosystem>cert).
3. **Union aliases** (dedupe by scheme+value).
4. **Merge `Affected`** with rules:
### 5.2 Linkset correlation
* Prefer **vendor** ranges for vendor products; prefer **distro** for **distroshipped** packages.
* If both exist for same `productKey`, keep **both**; mark `sourceTag` and `precedence` so **Policy** can decide.
* Never collapse range semantics across different families (e.g., rpm EVR vs semver).
5. **CVSS/severity**: record all CVSS sets; compute **effectiveSeverity** = max (unless policy override).
6. **References**: union with type precedence (advisory > patch > kb > exploit > blog); dedupe by URL; preserve `sourceTag`.
7. Produce **canonical JSON**; compute **afterHash**; store **MergeEvent** with inputs and hashes.
1. **Queue** observation deltas enqueue correlation jobs keyed by `(tenant, vulnerabilityId, productKey)` candidates derived from identifiers + alias graph.
2. **Canonical grouping** builder resolves aliases using Conceliers alias store and deterministic heuristics (vendor > distro > cert), deriving normalized product keys (purl preferred) and confidence scores.
3. **Linkset materialization** `advisory_linksets` documents store sorted observation references, alias sets, product keys, range metadata, and conflict payloads. Writes are idempotent; unchanged hashes skip updates.
4. **Conflict detection** builder emits structured conflicts (`severity-mismatch`, `affected-range-divergence`, `reference-clash`, `alias-inconsistency`, `metadata-gap`). Conflicts carry per-observation values for explainability.
5. **Event emission** `advisory.linkset.updated@1` summarizes deltas (`added`, `removed`, `changed` observation IDs, conflict updates, confidence changes) and includes a canonical hash for replay validation.
> The merge is **pure** given inputs. Any change in inputs or precedence matrices changes the **hash** predictably.
### 5.3 Event contract
| Event | Schema | Notes |
|-------|--------|-------|
| `advisory.observation.updated@1` | `events/advisory.observation.updated@1.json` | Fired on new or superseded observations. Includes `observationId`, source metadata, `linksetSummary` (aliases/purls), supersedes pointer (if any), SHA-256 hash, and `traceId`. |
| `advisory.linkset.updated@1` | `events/advisory.linkset.updated@1.json` | Fired when correlation changes. Includes `linksetId`, `key{vulnerabilityId, productKey, confidence}`, observation deltas, conflicts, `updatedAt`, and canonical hash. |
Events are emitted via NATS (primary) and Redis Stream (fallback). Consumers acknowledge idempotently using the hash; duplicates are safe. Offline Kit captures both topics during bundle creation for air-gapped replay.
---
## 6) Storage schema (MongoDB)
**Collections & indexes**
### Collections & indexes (LNM path)
* `source` `{_id, type, baseUrl, enabled, notes}`
* `source_state` `{sourceName(unique), enabled, cursor, lastSuccess, backoffUntil, paceOverrides}`
* `document` `{_id, sourceName, uri, fetchedAt, sha256, contentType, status, metadata, gridFsId?, etag?, lastModified?}`
* `concelier.sources` `{_id, type, baseUrl, enabled, notes}` connector catalog.
* `concelier.source_state` `{sourceName(unique), enabled, cursor, lastSuccess, backoffUntil, paceOverrides}` run-state (TTL indexes on `backoffUntil`).
* `concelier.documents` `{_id, sourceName, uri, fetchedAt, sha256, contentType, status, metadata, gridFsId?, etag?, lastModified?}` raw payload registry.
* Indexes: `{sourceName:1, uri:1}` unique; `{fetchedAt:-1}` for recent fetches.
* `concelier.dto` `{_id, sourceName, documentId, schemaVer, payload, validatedAt}` normalized connector DTOs used for replay.
* Index: `{sourceName:1, documentId:1}`.
* `concelier.advisory_observations`
* Index: `{sourceName:1, uri:1}` unique, `{fetchedAt:-1}`
* `dto` `{_id, sourceName, documentId, schemaVer, payload, validatedAt}`
```
{
_id: "tenant:vendor:upstreamId:revision",
tenant,
source: { vendor, stream, api, collectorVersion },
upstream: { upstreamId, documentVersion, fetchedAt, receivedAt, contentHash, signature },
content: { format, specVersion, raw, metadata? },
identifiers: { cve?, ghsa?, vendorIds[], aliases[] },
linkset: { purls[], cpes[], aliases[], references[], reconciledFrom[] },
supersedes?: "prevObservationId",
createdAt,
attributes?: object
}
```
* Index: `{sourceName:1, documentId:1}`
* `advisory` `{_id, advisoryKey, title, summary, published, modified, severity, cvss, exploitKnown, sources[]}`
* Indexes: `{tenant:1, upstream.upstreamId:1}`, `{tenant:1, source.vendor:1, linkset.purls:1}`, `{tenant:1, linkset.aliases:1}`, `{tenant:1, createdAt:-1}`.
* `concelier.advisory_linksets`
* Index: `{advisoryKey:1}` unique, `{modified:-1}`, `{severity:1}`, text index (title, summary)
* `alias` `{advisoryId, scheme, value}`
```
{
_id: "sha256:...",
tenant,
key: { vulnerabilityId, productKey, confidence },
observations: [
{ observationId, sourceVendor, statement, collectedAt }
],
aliases: { primary, others: [] },
purls: [],
cpes: [],
conflicts: [],
createdAt,
updatedAt
}
```
* Index: `{scheme:1,value:1}`, `{advisoryId:1}`
* `affected` `{advisoryId, productKey, rangeKind, introduced?, fixed?, arch?, distro?, ecosystem?}`
* Indexes: `{tenant:1, key.vulnerabilityId:1, key.productKey:1}`, `{tenant:1, purls:1}`, `{tenant:1, aliases.primary:1}`, `{tenant:1, updatedAt:-1}`.
* `concelier.advisory_events`
* Index: `{productKey:1}`, `{advisoryId:1}`, `{productKey:1, rangeKind:1}`
* `reference` `{advisoryId, url, kind, sourceTag}`
```
{
_id: ObjectId,
tenant,
type: "advisory.observation.updated" | "advisory.linkset.updated",
key,
delta,
hash,
occurredAt
}
```
* Index: `{advisoryId:1}`, `{kind:1}`
* `merge_event` `{advisoryKey, beforeHash, afterHash, mergedAt, inputs[]}`
* Index: `{advisoryKey:1, mergedAt:-1}`
* `export_state` `{_id(exportKind), baseExportId?, baseDigest?, lastFullDigest?, lastDeltaDigest?, cursor, files[]}`
* TTL index on `occurredAt` (configurable retention), `{type:1, occurredAt:-1}` for replay.
* `concelier.export_state` `{_id(exportKind), baseExportId?, baseDigest?, lastFullDigest?, lastDeltaDigest?, cursor, files[]}`
* `locks` `{_id(jobKey), holder, acquiredAt, heartbeatAt, leaseMs, ttlAt}` (TTL cleans dead locks)
* `jobs` `{_id, type, args, state, startedAt, heartbeatAt, endedAt, error}`
**GridFS buckets**: `fs.documents` for raw payloads.
**Legacy collections** (`advisory`, `alias`, `affected`, `reference`, `merge_event`) remain read-only during the migration window to support back-compat exports. New code must not write to them; scheduled cleanup removes them after Link-Not-Merge GA.
**GridFS buckets**: `fs.documents` for raw payloads (immutable); `fs.exports` for historical JSON/Trivy archives.
---
@@ -287,7 +317,7 @@ public interface IFeedConnector {
* Builds Bolt DB archives compatible with Trivy; supports **full** and **delta** modes.
* In delta, unchanged blobs are reused from the base; metadata captures:
```
```json
{
"mode": "delta|full",
"baseExportId": "...",
@@ -409,7 +439,7 @@ concelier:
## 10) Security & compliance
* **Outbound allowlist** per connector (domains, protocols); proxy support; TLS pinning where possible.
* **Signature verification** for raw docs (PGP/cosign/x509) with results stored in `document.metadata.sig`. Docs failing verification may still be ingested but flagged; **merge** can downweight or ignore them by config.
* **Signature verification** for raw docs (PGP/cosign/x509) with results stored in `document.metadata.sig`. Docs failing verification may still be ingested but flagged; Policy Engine or downstream policy can down-weight them.
* **No secrets in logs**; auth material via `env:` or mounted files; HTTP redaction of `Authorization` headers.
* **Multitenant**: pertenant DBs or prefixes; pertenant S3 prefixes; tenantscoped API tokens.
* **Determinism**: canonical JSON writer; export digests stable across runs given same inputs.
@@ -419,8 +449,9 @@ concelier:
## 11) Performance targets & scale
* **Ingest**: ≥ 5k documents/min on 4 cores (CSAF/OpenVEX/JSON).
* **Normalize/map**: ≥ 50k `Affected` rows/min on 4 cores.
* **Merge**: ≤ 10ms P95 per advisory at steadystate updates.
* **Normalize/map**: ≥ 50k observation statements/min on 4 cores.
* **Observation write**: ≤ 5ms P95 per document (including guard + Mongo write).
* **Linkset build**: ≤ 15ms P95 per `(vulnerabilityId, productKey)` update, even with 20+ contributing observations.
* **Export**: 1M advisories JSON in ≤ 90s (streamed, zstd), Trivy DB in ≤ 60s on 8 cores.
* **Memory**: hard cap per job; chunked streaming writers; backpressure to avoid GC spikes.
@@ -435,11 +466,13 @@ concelier:
* `concelier.fetch.docs_total{source}`
* `concelier.fetch.bytes_total{source}`
* `concelier.parse.failures_total{source}`
* `concelier.map.affected_total{source}`
* `concelier.merge.changed_total`
* `concelier.map.statements_total{source}`
* `concelier.observations.write_total{result=ok|noop|error}`
* `concelier.linksets.updated_total{result=ok|skip|error}`
* `concelier.linksets.conflicts_total{type}`
* `concelier.export.bytes{kind}`
* `concelier.export.duration_seconds{kind}`
* **Tracing** around fetch/parse/map/merge/export.
* **Tracing** around fetch/parse/map/observe/linkset/export.
* **Logs**: structured with `source`, `uri`, `docDigest`, `advisoryKey`, `exportId`.
---
@@ -448,7 +481,7 @@ concelier:
* **Connectors:** fixture suites for each provider/format (happy path; malformed; signature fail).
* **Version semantics:** EVR vs dpkg vs semver edge cases (epoch bumps, tilde versions, prereleases).
* **Merge:** conflicting sources (vendor vs distro vs OSV); verify precedence & dual retention.
* **Linkset correlation:** multi-source conflicts (severity, range, alias) produce deterministic conflict payloads; ensure confidence scoring stable.
* **Export determinism:** byteforbyte stable outputs across runs; digest equality.
* **Performance:** soak tests with 1M advisories; cap memory; verify backpressure.
* **API:** pagination, filters, RBAC, error envelopes (RFC 7807).
@@ -470,7 +503,8 @@ concelier:
* **Trigger all sources:** `POST /api/v1/concelier/sources/*/trigger`
* **Force full export JSON:** `POST /api/v1/concelier/exports/json { "full": true, "force": true }`
* **Force Trivy DB delta publish:** `POST /api/v1/concelier/exports/trivy { "full": false, "publish": true }`
* **Inspect advisory:** `GET /api/v1/concelier/advisories?scheme=CVE&value=CVE-2025-12345`
* **Inspect observation:** `GET /api/v1/concelier/observations/{observationId}`
* **Query linkset:** `GET /api/v1/concelier/linksets?vulnerabilityId=CVE-2025-12345&productKey=pkg:rpm/redhat/openssl`
* **Pause noisy source:** `POST /api/v1/concelier/sources/osv/pause`
---
@@ -482,4 +516,3 @@ concelier:
3. **Attestation handoff**: integrate with **Signer/Attestor** (optional).
4. **Scale & diagnostics**: provider dashboards, staleness alerts, export cache reuse.
5. **Offline kit**: endtoend verified bundles for airgap.

View File

@@ -1,17 +1,17 @@
# component_architecture_excititor.md — **StellaOps Excititor** (2025Q4)
> **Scope.** This document specifies the **Excititor** service: its purpose, trust model, data structures, APIs, plugin contracts, storage schema, normalization/consensus algorithms, performance budgets, testing matrix, and how it integrates with Scanner, Policy, Concelier, and the attestation chain. It is implementationready.
# component_architecture_excititor.md — **StellaOps Excititor** (Sprint22)
> **Scope.** This document specifies the **Excititor** service: its purpose, trust model, data structures, observation/linkset pipelines, APIs, plug-in contracts, storage schema, performance budgets, testing matrix, and how it integrates with Concelier, Policy Engine, and evidence surfaces. It is implementation-ready.
---
## 0) Mission & role in the platform
**Mission.** Convert heterogeneous **VEX** statements (OpenVEX, CSAF VEX, CycloneDX VEX; vendor/distro/platform sources) into **canonical, queryable claims**; compute **deterministic consensus** per *(vuln, product)*; preserve **conflicts with provenance**; publish **stable, attestable exports** that the backend uses to suppress nonexploitable findings, prioritize remaining risk, and explain decisions.
**Mission.** Convert heterogeneous **VEX** statements (OpenVEX, CSAF VEX, CycloneDX VEX; vendor/distro/platform sources) into immutable **VEX observations**, correlate them into **linksets** that retain provenance/conflicts without precedence, and publish deterministic evidence exports and events that Policy Engine, Console, and CLI use to suppress or explain findings.
**Boundaries.**
* Excititor **does not** decide PASS/FAIL. It supplies **evidence** (statuses + justifications + provenance weights).
* Excititor preserves **conflicting claims** unchanged; consensus encodes how we would pick, but the raw set is always exportable.
* Excititor preserves **conflicting observations** unchanged; consensus (when enabled) merely annotates how policy might choose, but raw evidence remains exportable.
* VEX consumption is **backendonly**: Scanner never applies VEX. The backends **Policy Engine** asks Excititor for status evidence and then decides what to show.
---
@@ -27,38 +27,121 @@
All connectors register **source metadata**: provider identity, trust tier, signature expectations (PGP/cosign/PKI), fetch windows, rate limits, and time anchors.
### 1.2 Canonical model (normalized)
Every incoming statement becomes a set of **VexClaim** records:
```
VexClaim
- providerId // 'redhat', 'suse', 'ubuntu', 'github', 'vendorX'
- vulnId // 'CVE-2025-12345', 'GHSA-xxxx', canonicalized
- productKey // canonical product identity (see §2.2)
- status // affected | not_affected | fixed | under_investigation
- justification? // for 'not_affected'/'affected' where provided
- introducedVersion? // semantics per provider (range or exact)
- fixedVersion? // where provided (range or exact)
- lastObserved // timestamp from source or fetch time
- provenance // doc digest, signature status, fetch URI, line/offset anchors
- evidence[] // raw source snippets for explainability
- supersedes? // optional cross-doc chain (docDigest → docDigest)
```
### 1.3 Exports (consumption)
* **VexConsensus** per `(vulnId, productKey)` with:
* `rollupStatus` (after policy weights/justification gates),
* `sources[]` (winning + losing claims with weights & reasons),
* `policyRevisionId` (identifier of the Excititor policy used),
* `consensusDigest` (stable SHA256 over canonical JSON).
* **Raw claims** export for auditing (unchanged, with provenance).
* **Provider snapshots** (per source, last N days) for operator debugging.
* **Index** optimized for backend joins: `(productKey, vulnId) → (status, confidence, sourceSet)`.
All exports are **deterministic**, and (optionally) **attested** via DSSE and logged to Rekor v2.
### 1.2 Canonical model (observations & linksets)
#### VexObservation
```jsonc
observationId // {tenant}:{providerId}:{upstreamId}:{revision}
tenant
providerId // e.g., redhat, suse, ubuntu, osv
streamId // connector stream (csaf, openvex, cyclonedx, attestation)
upstream{
upstreamId,
documentVersion?,
fetchedAt,
receivedAt,
contentHash,
signature{present, format?, keyId?, signature?}
}
statements[
{
vulnerabilityId,
productKey,
status, // affected | not_affected | fixed | under_investigation
justification?,
introducedVersion?,
fixedVersion?,
lastObserved,
locator?, // JSON Pointer/line for provenance
evidence?[]
}
]
content{
format,
specVersion?,
raw
}
linkset{
aliases[], // CVE/GHSA/vendor IDs
purls[],
cpes[],
references[{type,url}],
reconciledFrom[]
}
supersedes?
createdAt
attributes?
```
#### VexLinkset
```jsonc
linksetId // sha256 over sorted (tenant, vulnId, productKey, observationIds)
tenant
key{
vulnerabilityId,
productKey,
confidence // low|medium|high
}
observations[] = [
{
observationId,
providerId,
status,
justification?,
introducedVersion?,
fixedVersion?,
evidence?,
collectedAt
}
]
aliases{
primary,
others[]
}
purls[]
cpes[]
conflicts[]? // see VexLinksetConflict
createdAt
updatedAt
```
#### VexLinksetConflict
```jsonc
conflictId
type // status-mismatch | justification-divergence | version-range-clash | non-joinable-overlap | metadata-gap
field? // optional pointer for UI rendering
statements[] // per-observation values with providerId + status/justification/version data
confidence
detectedAt
```
#### VexConsensus (optional)
```jsonc
consensusId // sha256(vulnerabilityId, productKey, policyRevisionId)
vulnerabilityId
productKey
rollupStatus // derived by Excititor policy adapter (linkset aware)
sources[] // observation references with weight, accepted flag, reason
policyRevisionId
evaluatedAt
consensusDigest
```
Consensus persists only when Excititor policy adapters require pre-computed rollups (e.g., Offline Kit). Policy Engine can also compute consensus on demand from linksets.
### 1.3 Exports & evidence bundles
* **Raw observations** — JSON tree per observation for auditing/offline.
* **Linksets** — grouped evidence for policy/Console/CLI consumption.
* **Consensus (optional)** — if enabled, mirrors existing API contracts.
* **Provider snapshots** — last N days of observations per provider to support diagnostics.
* **Index** — `(productKey, vulnerabilityId) → {status candidates, confidence, observationIds}` for high-speed joins.
All exports remain deterministic and, when configured, attested via DSSE + Rekor v2.
---
@@ -98,73 +181,106 @@ enabled: bool
createdAt, modifiedAt
```
**`vex.raw`** (immutable raw documents)
**`vex.raw`** (immutable raw documents)
```
_id: sha256(doc bytes)
providerId
uri
ingestedAt
contentType
sig: { verified: bool, method: pgp|cosign|x509|none, keyId|certSubject, bundle? }
payload: GridFS pointer (if large)
disposition: kept|replaced|superseded
correlation: { replaces?: sha256, replacedBy?: sha256 }
```
**`vex.observations`**
```
{
_id: "tenant:providerId:upstreamId:revision",
tenant,
providerId,
streamId,
upstream: { upstreamId, documentVersion?, fetchedAt, receivedAt, contentHash, signature },
statements: [
{
vulnerabilityId,
productKey,
status,
justification?,
introducedVersion?,
fixedVersion?,
lastObserved,
locator?,
evidence?
}
],
content: { format, specVersion?, raw },
linkset: { aliases[], purls[], cpes[], references[], reconciledFrom[] },
supersedes?,
createdAt,
attributes?
}
```
* Indexes: `{tenant:1, providerId:1, upstream.upstreamId:1}`, `{tenant:1, statements.vulnerabilityId:1}`, `{tenant:1, linkset.purls:1}`, `{tenant:1, createdAt:-1}`.
**`vex.linksets`**
```
{
_id: "sha256:...",
tenant,
key: { vulnerabilityId, productKey, confidence },
observations: [
{ observationId, providerId, status, justification?, introducedVersion?, fixedVersion?, evidence?, collectedAt }
],
aliases: { primary, others: [] },
purls: [],
cpes: [],
conflicts: [],
createdAt,
updatedAt
}
```
* Indexes: `{tenant:1, key.vulnerabilityId:1, key.productKey:1}`, `{tenant:1, purls:1}`, `{tenant:1, updatedAt:-1}`.
```
_id: sha256(doc bytes)
providerId
uri
ingestedAt
contentType
sig: { verified: bool, method: pgp|cosign|x509|none, keyId|certSubject, bundle? }
payload: GridFS pointer (if large)
disposition: kept|replaced|superseded
correlation: { replaces?: sha256, replacedBy?: sha256 }
```
**`vex.statements`** (immutable normalized rows; append-only event log)
```
_id: ObjectId
providerId
vulnId
productKey
status
justification?
introducedVersion?
fixedVersion?
lastObserved
docDigest
provenance { uri, line?, pointer?, signatureState }
evidence[] { key, value, locator }
signals? {
severity? { scheme, score?, label?, vector? }
kev?: bool
epss?: double
}
insertedAt
indices:
- {vulnId:1, productKey:1}
- {providerId:1, insertedAt:-1}
- {docDigest:1}
- {status:1}
- text index (optional) on evidence.value for debugging
```
**`vex.consensus`** (rollups)
```
_id: sha256(canonical(vulnId, productKey, policyRevision))
vulnId
productKey
rollupStatus
sources[]: [
{ providerId, status, justification?, weight, lastObserved, accepted:bool, reason }
]
policyRevisionId
evaluatedAt
signals? {
severity? { scheme, score?, label?, vector? }
kev?: bool
epss?: double
}
consensusDigest // same as _id
indices:
- {vulnId:1, productKey:1}
- {policyRevisionId:1, evaluatedAt:-1}
```
**`vex.exports`** (manifest of emitted artifacts)
**`vex.events`** (observation/linkset events, optional long retention)
```
{
_id: ObjectId,
tenant,
type: "vex.observation.updated" | "vex.linkset.updated",
key,
delta,
hash,
occurredAt
}
```
* Indexes: `{type:1, occurredAt:-1}`, TTL on `occurredAt` for configurable retention.
**`vex.consensus`** (optional rollups)
```
_id: sha256(canonical(vulnerabilityId, productKey, policyRevisionId))
vulnerabilityId
productKey
rollupStatus
sources[] // observation references with weights/reasons
policyRevisionId
evaluatedAt
signals? // optional severity/kev/epss hints
consensusDigest
```
* Indexes: `{vulnerabilityId:1, productKey:1}`, `{policyRevisionId:1, evaluatedAt:-1}`.
**`vex.exports`** (manifest of emitted artifacts)
```
_id
@@ -177,23 +293,15 @@ policyRevisionId
cacheable: bool
```
**`vex.cache`**
```
querySignature -> exportId (for fast reuse)
ttl, hits
```
**`vex.migrations`**
* ordered migrations applied at bootstrap to ensure indexes.
* `20251019-consensus-signals-statements` introduces the statements log indexes and the `policyRevisionId + evaluatedAt` lookup for consensus — rerun consensus writers once to hydrate newly persisted signals.
### 3.2 Indexing strategy
* Hot path queries use exact `(vulnId, productKey)` and timebounded windows; compound indexes cover both.
* Providers list view by `lastObserved` for monitoring staleness.
* `vex.consensus` keyed by `(vulnId, productKey, policyRevision)` for deterministic reuse.
**`vex.cache`** — observation/linkset export cache: `{querySignature, exportId, ttl, hits}`.
**`vex.migrations`** — ordered migrations ensuring new indexes (`20251027-linksets-introduced`, etc.).
### 3.2 Indexing strategy
* Hot path queries rely on `{tenant, key.vulnerabilityId, key.productKey}` covering linkset lookup.
* Observability queries use `{tenant, updatedAt}` to monitor staleness.
* Consensus (if enabled) keyed by `{vulnerabilityId, productKey, policyRevisionId}` for deterministic reuse.
---
@@ -202,30 +310,30 @@ ttl, hits
### 4.1 Connector contract
```csharp
public interface IVexConnector
{
string ProviderId { get; }
Task FetchAsync(VexConnectorContext ctx, CancellationToken ct); // raw docs
Task NormalizeAsync(VexConnectorContext ctx, CancellationToken ct); // raw -> VexClaim[]
}
```
* **Fetch** must implement: window scheduling, conditional GET (ETag/IfModifiedSince), rate limiting, retry/backoff.
* **Normalize** parses the format, validates schema, maps product identities deterministically, emits `VexClaim` records with **provenance**.
public interface IVexConnector
{
string ProviderId { get; }
Task FetchAsync(VexConnectorContext ctx, CancellationToken ct); // raw docs
Task NormalizeAsync(VexConnectorContext ctx, CancellationToken ct); // raw -> ObservationStatements[]
}
```
* **Fetch** must implement: window scheduling, conditional GET (ETag/IfModifiedSince), rate limiting, retry/backoff.
* **Normalize** parses the format, validates schema, maps product identities deterministically, emits observation statements with **provenance** metadata (locator, justification, version ranges).
### 4.2 Signature verification (per provider)
* **cosign (keyless or keyful)** for OCI referrers or HTTPserved JSON with Sigstore bundles.
* **PGP** (provider keyrings) for distro/vendor feeds that sign docs.
* **x509** (mutual TLS / providerpinned certs) where applicable.
* Signature state is stored on **vex.raw.sig** and copied into **provenance.signatureState** on claims.
> Claims from sources failing signature policy are marked `"signatureState.verified=false"` and **policy** can downweight or ignore them.
* Signature state is stored on **vex.raw.sig** and copied into `statements[].signatureState` so downstream policy can gate by verification result.
> Observation statements from sources failing signature policy are marked `"signatureState.verified=false"` and policy can down-weight or ignore them.
### 4.3 Time discipline
* For each doc, prefer **providers document timestamp**; if absent, use fetch time.
* Claims carry `lastObserved` which drives **tiebreaking** within equal weight tiers.
* Statements carry `lastObserved` which drives **tie-breaking** within equal weight tiers.
---
@@ -235,7 +343,7 @@ public interface IVexConnector
* **purl** first; **cpe** second; OS package NVRA/EVR mapping helpers (distro connectors) produce purls via canonical tables (e.g., rpm→purl:rpm, deb→purl:deb).
* Where a provider publishes **platformlevel** VEX (e.g., “RHEL 9 not affected”), connectors expand to known product inventory rules (e.g., map to sets of packages/components shipped in the platform). Expansion tables are versioned and kept per provider; every expansion emits **evidence** indicating the rule applied.
* If expansion would be speculative, the claim remains **platformscoped** with `productKey="platform:redhat:rhel:9"` and is flagged **nonjoinable**; backend can decide to use platform VEX only when Scanner proves the platform runtime.
* If expansion would be speculative, the statement remains **platform-scoped** with `productKey="platform:redhat:rhel:9"` and is flagged **non-joinable**; backend can decide to use platform VEX only when Scanner proves the platform runtime.
### 5.2 Status + justification mapping
@@ -254,11 +362,11 @@ public interface IVexConnector
## 6) Consensus algorithm
**Goal:** produce a **stable**, explainable `rollupStatus` per `(vulnId, productKey)` given possibly conflicting claims.
**Goal:** produce a **stable**, explainable `rollupStatus` per `(vulnId, productKey)` when consumers opt into Excititor-managed consensus derived from linksets.
### 6.1 Inputs
* Set **S** of `VexClaim` for the key.
* Set **S** of observation statements drawn from the current `VexLinkset` for `(tenant, vulnId, productKey)`.
* **Excititor policy snapshot**:
* **weights** per provider tier and per provider overrides.
@@ -268,19 +376,19 @@ public interface IVexConnector
### 6.2 Steps
1. **Filter invalid** claims by signature policy & justification gates → set `S'`.
2. **Score** each claim:
`score = weight(provider) * freshnessFactor(lastObserved)` where freshnessFactor ∈ [0.8, 1.0] for staleness decay (configurable; small effect).
3. **Aggregate** scores per status: `W(status) = Σ score(claims with that status)`.
1. **Filter invalid** statements by signature policy & justification gates → set `S'`.
2. **Score** each statement:
`score = weight(provider) * freshnessFactor(lastObserved)` where freshnessFactor ∈ [0.8, 1.0] for staleness decay (configurable; small effect). Observations lacking verified signatures receive policy-configured penalties.
3. **Aggregate** scores per status: `W(status) = Σ score(statements with that status)`.
4. **Pick** `rollupStatus = argmax_status W(status)`.
5. **Tiebreakers** (in order):
* Higher **max single** provider score wins (vendor > distro > platform > hub).
* More **recent** lastObserved wins.
* Deterministic lexicographic order of status (`fixed` > `not_affected` > `under_investigation` > `affected`) as final tiebreaker.
6. **Explain**: mark accepted sources (`accepted=true; reason="weight"`/`"freshness"`), mark rejected sources with explicit `reason` (`"insufficient_justification"`, `"signature_unverified"`, `"lower_weight"`).
6. **Explain**: mark accepted observations (`accepted=true; reason="weight"`/`"freshness"`/`"confidence"`) and rejected ones with explicit `reason` (`"insufficient_justification"`, `"signature_unverified"`, `"lower_weight"`, `"low_confidence_linkset"`).
> The algorithm is **pure** given S and policy snapshot; result is reproducible and hashed into `consensusDigest`.
> The algorithm is **pure** given `S` and policy snapshot; result is reproducible and hashed into `consensusDigest`.
---
@@ -291,9 +399,13 @@ All endpoints are versioned under `/api/v1/vex`.
### 7.1 Query (online)
```
POST /claims/search
body: { vulnIds?: string[], productKeys?: string[], providers?: string[], since?: timestamp, limit?: int, pageToken?: string }
→ { claims[], nextPageToken? }
POST /observations/search
body: { vulnIds?: string[], productKeys?: string[], providers?: string[], since?: timestamp, limit?: int, pageToken?: string }
→ { observations[], nextPageToken? }
POST /linksets/search
body: { vulnIds?: string[], productKeys?: string[], confidence?: string[], since?: timestamp, limit?: int, pageToken?: string }
→ { linksets[], nextPageToken? }
POST /consensus/search
body: { vulnIds?: string[], productKeys?: string[], policyRevisionId?: string, since?: timestamp, limit?: int, pageToken?: string }
@@ -301,7 +413,7 @@ POST /consensus/search
POST /excititor/resolve (scope: vex.read)
body: { productKeys?: string[], purls?: string[], vulnerabilityIds: string[], policyRevisionId?: string }
→ { policy, resolvedAt, results: [ { vulnerabilityId, productKey, status, sources[], conflicts[], decisions[], signals?, summary?, envelope: { artifact, contentSignature?, attestation?, attestationEnvelope?, attestationSignature? } } ] }
→ { policy, resolvedAt, results: [ { vulnerabilityId, productKey, status, observations[], conflicts[], linksetConfidence, consensus?, signals?, envelope? } ] }
```
### 7.2 Exports (cacheable snapshots)
@@ -407,17 +519,18 @@ Run the ingestion endpoint once after applying migration `20251019-consensus-sig
* **Connector allowlists**: outbound fetch constrained to configured domains.
* **Tenant isolation**: pertenant DB prefixes or separate DBs; pertenant S3 prefixes; pertenant policies.
* **AuthN/Z**: Authorityissued OpToks; RBAC roles (`vex.read`, `vex.admin`, `vex.export`).
* **No secrets in logs**; deterministic logging contexts include providerId, docDigest, claim keys.
* **No secrets in logs**; deterministic logging contexts include providerId, docDigest, observationId, and linksetId.
---
## 11) Performance & scale
* **Targets:**
* Normalize 10k VEX claims/minute/core.
* Consensus compute ≤ 50ms for 1k unique `(vuln, product)` pairs in hot cache.
* Export (consensus) 1M rows in ≤ 60s on 8 cores with streaming writer.
* **Targets:**
* Normalize 10k observation statements/minute/core.
* Linkset rebuild ≤ 20ms P95 for 1k unique `(vuln, product)` pairs in hot cache.
* Consensus (when enabled) compute ≤ 50ms for 1k unique `(vuln, product)` pairs.
* Export (observations + linksets) 1M rows in ≤60s on 8 cores with streaming writer.
* **Scaling:**
@@ -465,26 +578,29 @@ Excititor.Worker ships with a background refresh service that re-evaluates stale
## 12) Observability
* **Metrics:**
* `vex.ingest.docs_total{provider}`
* `vex.normalize.claims_total{provider}`
* `vex.signature.failures_total{provider,method}`
* `vex.consensus.conflicts_total{vulnId}`
* `vex.exports.bytes{format}` / `vex.exports.latency_seconds`
* **Tracing:** spans for fetch, verify, parse, map, consensus, export.
* **Dashboards:** provider staleness, top conflicting vulns/components, signature posture, export cache hitrate.
* **Metrics:**
* `vex.fetch.requests_total{provider}` / `vex.fetch.bytes_total{provider}`
* `vex.fetch.failures_total{provider,reason}` / `vex.signature.failures_total{provider,method}`
* `vex.normalize.statements_total{provider}`
* `vex.observations.write_total{result}`
* `vex.linksets.updated_total{result}` / `vex.linksets.conflicts_total{type}`
* `vex.consensus.rollup_total{status}` (when enabled)
* `vex.exports.bytes_total{format}` / `vex.exports.latency_seconds{format}`
* **Tracing:** spans for fetch, verify, parse, map, observe, linkset, consensus, export.
* **Dashboards:** provider staleness, linkset conflict hot spots, signature posture, export cache hit-rate.
---
## 13) Testing matrix
* **Connectors:** golden raw docs → deterministic claims (fixtures per provider/format).
* **Connectors:** golden raw docs → deterministic observation statements (fixtures per provider/format).
* **Signature policies:** valid/invalid PGP/cosign/x509 samples; ensure rejects are recorded but not accepted.
* **Normalization edge cases:** platformonly claims, freetext justifications, nonpurl products.
* **Consensus:** conflict scenarios across tiers; check tiebreakers; justification gates.
* **Performance:** 1Mrow export timing; memory ceilings; stream correctness.
* **Determinism:** same inputs + policy → identical `consensusDigest` and export bytes.
* **Normalization edge cases:** platform-scoped statements, free-text justifications, non-purl products.
* **Linksets:** conflict scenarios across tiers; verify confidence scoring + conflict payload stability.
* **Consensus (optional):** ensure tie-breakers honour policy weights/justification gates.
* **Performance:** 1M-row observation/linkset export timing; memory ceilings; stream correctness.
* **Determinism:** same inputs + policy → identical linkset hashes, conflict payloads, optional `consensusDigest`, and export bytes.
* **API contract tests:** pagination, filters, RBAC, rate limits.
---
@@ -493,8 +609,8 @@ Excititor.Worker ships with a background refresh service that re-evaluates stale
* **Backend Policy Engine** (in Scanner.WebService): calls `POST /excititor/resolve` (scope `vex.read`) with batched `(purl, vulnId)` pairs to fetch `rollupStatus + sources`.
* **Concelier**: provides alias graph (CVE↔vendor IDs) and may supply VEXadjacent metadata (e.g., KEV flag) for policy escalation.
* **UI**: VEX explorer screens use `/claims/search` and `/consensus/search`; show conflicts & provenance.
* **CLI**: `stellaops vex export --consensus --since 7d --out vex.json` for audits.
* **UI**: VEX explorer screens use `/observations/search`, `/linksets/search`, and `/consensus/search`; show conflicts & provenance.
* **CLI**: `stella vex linksets export --since 7d --out vex-linksets.json` (optionally `--include-consensus`) for audits and Offline Kit parity.
---

View File

@@ -58,6 +58,8 @@ Everything here is opensource and versioned— when you check out a git ta
- **10[Scanner Cache Configuration](dev/SCANNER_CACHE_CONFIGURATION.md)**
- **30[Excititor Connector Packaging Guide](dev/30_EXCITITOR_CONNECTOR_GUIDE.md)**
- **31[Aggregation-Only Contract Reference](ingestion/aggregation-only-contract.md)**
- **31[Advisory Observations & Linksets](advisories/aggregation.md)**
- **31[VEX Observations & Linksets](vex/aggregation.md)**
- **30Developer Templates**
- [Excititor Connector Skeleton](dev/templates/excititor-connector/)
- **11[Authority Service](11_AUTHORITY.md)**
@@ -65,28 +67,34 @@ Everything here is opensource and versioned— when you check out a git ta
- **12[Performance Workbook](12_PERFORMANCE_WORKBOOK.md)**
- **13[ReleaseEngineering Playbook](13_RELEASE_ENGINEERING_PLAYBOOK.md)**
- **20[CLI AOC Commands Reference](cli/cli-reference.md)**
- **20[Console CLI Parity Matrix](cli-vs-ui-parity.md)**
- **60[Policy Engine Overview](policy/overview.md)**
- **61[Policy DSL Grammar](policy/dsl.md)**
- **62[Policy Lifecycle & Approvals](policy/lifecycle.md)**
- **63[Policy Runs & Orchestration](policy/runs.md)**
- **64[Policy Engine REST API](api/policy.md)**
- **65[Policy CLI Guide](cli/policy.md)**
- **66[Policy Editor Workspace](ui/policy-editor.md)**
- **67[Policy Observability](observability/policy.md)**
- **68[Policy Governance & Least Privilege](security/policy-governance.md)**
- **69[Policy Examples](examples/policies/README.md)**
- **70[Policy FAQ](faq/policy-faq.md)**
- **71[Policy Run DTOs](../src/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md)**
- **64[Policy Exception Effects](policy/exception-effects.md)**
- **65[Policy Engine REST API](api/policy.md)**
- **66[Policy CLI Guide](cli/policy.md)**
- **67[Policy Editor Workspace](ui/policy-editor.md)**
- **68[Policy Observability](observability/policy.md)**
- **69[Console Observability](observability/ui-telemetry.md)**
- **70[Policy Governance & Least Privilege](security/policy-governance.md)**
- **71[Policy Examples](examples/policies/README.md)**
- **72[Policy FAQ](faq/policy-faq.md)**
- **73[Policy Run DTOs](../src/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md)**
- **30[Fixture Maintenance](dev/fixtures.md)**
### User & operator guides
- **14[Glossary](14_GLOSSARY_OF_TERMS.md)**
- **15[UI Guide](15_UI_GUIDE.md)**
- **16[Console AOC Dashboard](ui/console.md)**
- **16[Console Accessibility Guide](accessibility.md)**
- **17[Security Hardening Guide](17_SECURITY_HARDENING_GUIDE.md)**
- **17[Console Security Posture](security/console-security.md)**
- **18[Coding Standards](18_CODING_STANDARDS.md)**
- **19[TestSuite Overview](19_TEST_SUITE_OVERVIEW.md)**
- **21[Install Guide](21_INSTALL_GUIDE.md)**
- **21[Docker Install Recipes](install/docker.md)**
- **22[CI/CD Recipes Library](ci/20_CI_RECIPES.md)**
- **23[FAQ](23_FAQ_MATRIX.md)**
- **24[Offline Update Kit Admin Guide](24_OFFLINE_KIT.md)**

View File

@@ -128,11 +128,15 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| DOCS-LNM-22-001 | TODO | Docs Guild, Concelier Guild | CONCELIER-LNM-21-001..003 | Author `/docs/advisories/aggregation.md` covering observation vs linkset, conflict handling, AOC requirements, and reviewer checklist. | Doc merged with examples + checklist; lint passes. |
| DOCS-LNM-22-002 | TODO | Docs Guild, Excititor Guild | EXCITITOR-LNM-21-001..003 | Publish `/docs/vex/aggregation.md` describing VEX observation/linkset model, product matching, conflicts. | Doc merged with fixtures; checklist appended. |
| DOCS-LNM-22-003 | TODO | Docs Guild, BE-Base Platform Guild | WEB-LNM-21-001..003 | Update `/docs/api/advisories.md` and `/docs/api/vex.md` for new endpoints, parameters, errors, exports. | API docs aligned with OpenAPI; examples validated. |
| DOCS-LNM-22-001 | BLOCKED (2025-10-27) | Docs Guild, Concelier Guild | CONCELIER-LNM-21-001..003 | Author `/docs/advisories/aggregation.md` covering observation vs linkset, conflict handling, AOC requirements, and reviewer checklist. | Draft doc merged with examples + checklist; final sign-off blocked until Concelier schema/API tasks land. |
> Blocker (2025-10-27): `CONCELIER-LNM-21-001..003` still TODO; update doc + fixtures once schema/API implementations are available.
| DOCS-LNM-22-002 | BLOCKED (2025-10-27) | Docs Guild, Excititor Guild | EXCITITOR-LNM-21-001..003 | Publish `/docs/vex/aggregation.md` describing VEX observation/linkset model, product matching, conflicts. | Draft doc merged with fixtures; final approval blocked until Excititor observation/linkset work ships. |
> Blocker (2025-10-27): `EXCITITOR-LNM-21-001..003` remain TODO; refresh doc, fixtures, and examples post-implementation.
| DOCS-LNM-22-003 | BLOCKED (2025-10-27) | Docs Guild, BE-Base Platform Guild | WEB-LNM-21-001..003 | Update `/docs/api/advisories.md` and `/docs/api/vex.md` for new endpoints, parameters, errors, exports. | Draft pending gateway/API delivery; unblock once endpoints + OpenAPI specs are available. |
> Blocker (2025-10-27): `WEB-LNM-21-001..003` all TODO—no gateway endpoints/OpenAPI to document yet.
| DOCS-LNM-22-004 | TODO | Docs Guild, Policy Guild | POLICY-ENGINE-40-001 | Create `/docs/policy/effective-severity.md` detailing severity selection strategies from multiple sources. | Doc merged with policy examples; checklist included. |
| DOCS-LNM-22-005 | TODO | Docs Guild, UI Guild | UI-LNM-22-001..003 | Document `/docs/ui/evidence-panel.md` with screenshots, conflict badges, accessibility guidance. | UI doc merged; accessibility checklist completed. |
| DOCS-LNM-22-005 | BLOCKED (2025-10-27) | Docs Guild, UI Guild | UI-LNM-22-001..003 | Document `/docs/ui/evidence-panel.md` with screenshots, conflict badges, accessibility guidance. | Awaiting UI implementation to capture screenshots + flows; unblock once Evidence panel ships. |
> Blocker (2025-10-27): `UI-LNM-22-001..003` all TODO; documentation requires final UI states and accessibility audit artifacts.
## StellaOps Console (Sprint 23)
@@ -148,14 +152,18 @@
| DOCS-CONSOLE-23-008 | DONE (2025-10-26) | Docs Guild, Authority Guild | AUTH-CONSOLE-23-002, CONSOLE-FEAT-23-108 | Draft `/docs/ui/admin.md` describing users/roles, tenants, tokens, integrations, fresh-auth prompts, and RBAC mapping. | Doc merged with tables for scopes vs roles, screenshots, compliance checklist. |
| DOCS-CONSOLE-23-009 | DONE (2025-10-27) | Docs Guild, DevOps Guild | DOWNLOADS-CONSOLE-23-001, CONSOLE-FEAT-23-109 | Publish `/docs/ui/downloads.md` listing product images, commands, offline instructions, parity with CLI, and compliance checklist. | Doc merged; manifest sample included; copy-to-clipboard guidance documented; checklist complete. |
| DOCS-CONSOLE-23-010 | DONE (2025-10-27) | Docs Guild, Deployment Guild, Console Guild | DEVOPS-CONSOLE-23-002, CONSOLE-REL-23-301 | Write `/docs/deploy/console.md` (Helm, ingress, TLS, CSP, env vars, health checks) with compliance checklist. | Deploy doc merged; templates validated; CSP guidance included; checklist appended. |
| DOCS-CONSOLE-23-011 | DOING (2025-10-27) | Docs Guild, Deployment Guild | DOCS-CONSOLE-23-010 | Update `/docs/install/docker.md` to cover Console image, Compose/Helm usage, offline tarballs, parity with CLI. | Doc updated with new sections; commands validated; compliance checklist appended. |
| DOCS-CONSOLE-23-012 | TODO | Docs Guild, Security Guild | AUTH-CONSOLE-23-003, WEB-CONSOLE-23-002 | Publish `/docs/security/console-security.md` detailing OIDC flows, scopes, CSP, fresh-auth, evidence handling, and compliance checklist. | Security doc merged; threat model notes included; checklist appended. |
| DOCS-CONSOLE-23-013 | TODO | Docs Guild, Observability Guild | TELEMETRY-CONSOLE-23-001, CONSOLE-QA-23-403 | Write `/docs/observability/ui-telemetry.md` cataloguing metrics/logs/traces, dashboards, alerts, and feature flags. | Doc merged with instrumentation tables, dashboard screenshots, checklist appended. |
| DOCS-CONSOLE-23-014 | TODO | Docs Guild, Console Guild, CLI Guild | CONSOLE-DOC-23-502 | Maintain `/docs/cli-vs-ui-parity.md` matrix and integrate CI check guidance. | Matrix published with parity status, CI workflow documented, compliance checklist appended. |
| DOCS-CONSOLE-23-011 | DONE (2025-10-28) | Docs Guild, Deployment Guild | DOCS-CONSOLE-23-010 | Update `/docs/install/docker.md` to cover Console image, Compose/Helm usage, offline tarballs, parity with CLI. | Doc updated with new sections; commands validated; compliance checklist appended. |
| DOCS-CONSOLE-23-012 | DONE (2025-10-28) | Docs Guild, Security Guild | AUTH-CONSOLE-23-003, WEB-CONSOLE-23-002 | Publish `/docs/security/console-security.md` detailing OIDC flows, scopes, CSP, fresh-auth, evidence handling, and compliance checklist. | Security doc merged; threat model notes included; checklist appended. |
| DOCS-CONSOLE-23-013 | DONE (2025-10-28) | Docs Guild, Observability Guild | TELEMETRY-CONSOLE-23-001, CONSOLE-QA-23-403 | Write `/docs/observability/ui-telemetry.md` cataloguing metrics/logs/traces, dashboards, alerts, and feature flags. | Doc merged with instrumentation tables, dashboard screenshots, checklist appended. |
| DOCS-CONSOLE-23-014 | DONE (2025-10-28) | Docs Guild, Console Guild, CLI Guild | CONSOLE-DOC-23-502 | Maintain `/docs/cli-vs-ui-parity.md` matrix and integrate CI check guidance. | Matrix published with parity status, CI workflow documented, compliance checklist appended. |
> 2025-10-28: Install Docker guide references pending CLI commands (`stella downloads manifest`, `stella downloads mirror`, `stella console status`). Update once CLI parity lands.
| DOCS-CONSOLE-23-015 | TODO | Docs Guild, Architecture Guild | CONSOLE-CORE-23-001, WEB-CONSOLE-23-001 | Produce `/docs/architecture/console.md` describing frontend packages, data flow diagrams, SSE design, performance budgets. | Architecture doc merged with diagrams + compliance checklist; reviewers approve. |
| DOCS-CONSOLE-23-016 | TODO | Docs Guild, Accessibility Guild | CONSOLE-QA-23-402, CONSOLE-FEAT-23-102 | Refresh `/docs/accessibility.md` with Console-specific keyboard flows, color tokens, testing tools, and compliance checklist updates. | Accessibility doc updated; audits referenced; checklist appended. |
| DOCS-CONSOLE-23-016 | DONE (2025-10-28) | Docs Guild, Accessibility Guild | CONSOLE-QA-23-402, CONSOLE-FEAT-23-102 | Refresh `/docs/accessibility.md` with Console-specific keyboard flows, color tokens, testing tools, and compliance checklist updates. | Accessibility doc updated; audits referenced; checklist appended. |
> 2025-10-28: Added guide covering keyboard matrix, screen reader behaviour, colour/focus tokens, testing workflow, offline guidance, and compliance checklist.
| DOCS-CONSOLE-23-017 | TODO | Docs Guild, Console Guild | CONSOLE-FEAT-23-101..109 | Create `/docs/examples/ui-tours.md` providing triage, audit, policy rollout walkthroughs with annotated screenshots and GIFs. | UI tours doc merged; media assets stored; compliance checklist appended. |
| DOCS-LNM-22-006 | TODO | Docs Guild, Architecture Guild | CONCELIER-LNM-21-001..005, EXCITITOR-LNM-21-001..005 | Refresh `/docs/architecture/conseiller.md` and `/docs/architecture/excitator.md` describing observation/linkset pipelines and event contracts. | Architecture docs updated with diagrams; checklist appended. |
| DOCS-CONSOLE-23-018 | TODO | Docs Guild, Security Guild | DOCS-CONSOLE-23-012 | Execute console security compliance checklist and capture Security Guild sign-off in Sprint 23 log. | Checklist completed; findings addressed or tickets filed; sign-off noted in updates file. |
| DOCS-LNM-22-006 | DONE (2025-10-27) | Docs Guild, Architecture Guild | CONCELIER-LNM-21-001..005, EXCITITOR-LNM-21-001..005 | Refresh `/docs/architecture/conseiller.md` and `/docs/architecture/excitator.md` describing observation/linkset pipelines and event contracts. | Architecture docs updated with observation/linkset flow + event tables; revisit once service implementations land. |
> Follow-up: align diagrams/examples after `CONCELIER-LNM-21` & `EXCITITOR-LNM-21` work merges (currently TODO).
| DOCS-LNM-22-007 | TODO | Docs Guild, Observability Guild | CONCELIER-LNM-21-005, EXCITITOR-LNM-21-005, DEVOPS-LNM-22-002 | Publish `/docs/observability/aggregation.md` with metrics/traces/logs/SLOs. | Observability doc merged; dashboards referenced; checklist appended. |
| DOCS-LNM-22-008 | TODO | Docs Guild, DevOps Guild | MERGE-LNM-21-001, CONCELIER-LNM-21-102 | Write `/docs/migration/no-merge.md` describing migration plan, backfill steps, rollback, feature flags. | Migration doc approved by stakeholders; checklist appended. |
@@ -193,7 +201,7 @@
| DOCS-EXC-25-001 | TODO | Docs Guild, Governance Guild | WEB-EXC-25-001 | Author `/docs/governance/exceptions.md` covering lifecycle, scope patterns, examples, compliance checklist. | Doc merged; reviewers sign off; checklist included. |
| DOCS-EXC-25-002 | TODO | Docs Guild, Authority Core | AUTH-EXC-25-001 | Publish `/docs/governance/approvals-and-routing.md` detailing roles, routing matrix, MFA rules, audit trails. | Doc merged; routing examples validated; checklist appended. |
| DOCS-EXC-25-003 | TODO | Docs Guild, BE-Base Platform Guild | WEB-EXC-25-001..003 | Create `/docs/api/exceptions.md` with endpoints, payloads, errors, idempotency notes. | API doc aligned with OpenAPI; examples tested; checklist appended. |
| DOCS-EXC-25-004 | TODO | Docs Guild, Policy Guild | POLICY-ENGINE-70-001 | Document `/docs/policy/exception-effects.md` explaining evaluation order, conflicts, simulation. | Doc merged; tests cross-referenced; checklist appended. |
| DOCS-EXC-25-004 | DONE (2025-10-27) | Docs Guild, Policy Guild | POLICY-ENGINE-70-001 | Document `/docs/policy/exception-effects.md` explaining evaluation order, conflicts, simulation. | Doc merged; tests cross-referenced; checklist appended. |
| DOCS-EXC-25-005 | TODO | Docs Guild, UI Guild | UI-EXC-25-001..004 | Write `/docs/ui/exception-center.md` with UI walkthrough, badges, accessibility, shortcuts. | Doc merged with screenshots; accessibility checklist completed. |
| DOCS-EXC-25-006 | TODO | Docs Guild, DevEx/CLI Guild | CLI-EXC-25-001..002 | Update `/docs/cli/exceptions.md` covering command usage and exit codes. | CLI doc updated; examples validated; checklist appended. |
| DOCS-EXC-25-007 | TODO | Docs Guild, DevOps Guild | SCHED-WORKER-25-101, DEVOPS-GRAPH-24-003 | Publish `/docs/migration/exception-governance.md` describing cutover from legacy suppressions, notifications, rollback. | Migration doc approved; checklist included. |
@@ -249,20 +257,48 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| DOCS-POLICY-27-001 | TODO | Docs Guild, Policy Guild | REGISTRY-API-27-001, POLICY-ENGINE-27-001 | Publish `/docs/policy/studio-overview.md` covering lifecycle, roles, glossary, and compliance checklist. | Doc merged with diagrams + lifecycle table; checklist appended; stakeholders sign off. |
| DOCS-POLICY-27-002 | TODO | Docs Guild, Console Guild | CONSOLE-STUDIO-27-001 | Write `/docs/policy/authoring.md` detailing workspace templates, snippets, lint rules, IDE shortcuts, and best practices. | Authoring doc includes annotated screenshots, snippet catalog, compliance checklist. |
| DOCS-POLICY-27-003 | TODO | Docs Guild, Policy Registry Guild | REGISTRY-API-27-007 | Document `/docs/policy/versioning-and-publishing.md` (semver rules, attestations, rollback) with compliance checklist. | Doc merged with flow diagrams; attestation steps documented; checklist appended. |
| DOCS-POLICY-27-004 | TODO | Docs Guild, Scheduler Guild | REGISTRY-API-27-005, SCHED-WORKER-27-301 | Write `/docs/policy/simulation.md` covering quick vs batch sim, thresholds, evidence bundles, CLI examples. | Simulation doc includes charts, sample manifests, checklist appended. |
| DOCS-POLICY-27-005 | TODO | Docs Guild, Product Ops | REGISTRY-API-27-006 | Publish `/docs/policy/review-and-approval.md` with approver requirements, comments, webhooks, audit trail guidance. | Doc merged with role matrix + webhook schema; checklist appended. |
| DOCS-POLICY-27-006 | TODO | Docs Guild, Policy Guild | REGISTRY-API-27-008 | Author `/docs/policy/promotion.md` covering environments, canary, rollback, and monitoring steps. | Promotion doc includes examples + checklist; verified by Policy Ops. |
| DOCS-POLICY-27-007 | TODO | Docs Guild, DevEx/CLI Guild | CLI-POLICY-27-001..004 | Update `/docs/policy/cli.md` with new commands, JSON schemas, CI usage, and compliance checklist. | CLI doc merged with transcripts; schema references validated; checklist appended. |
| DOCS-POLICY-27-008 | TODO | Docs Guild, Policy Registry Guild | REGISTRY-API-27-001..008 | Publish `/docs/policy/api.md` describing Registry endpoints, request/response schemas, errors, and feature flags. | API doc aligned with OpenAPI; examples validated; checklist appended. |
| DOCS-POLICY-27-009 | TODO | Docs Guild, Security Guild | AUTH-POLICY-27-002 | Create `/docs/security/policy-attestations.md` covering signing, verification, key rotation, and compliance checklist. | Security doc approved by Security Guild; verifier steps documented; checklist appended. |
| DOCS-POLICY-27-010 | TODO | Docs Guild, Architecture Guild | REGISTRY-API-27-001, SCHED-WORKER-27-301 | Author `/docs/architecture/policy-registry.md` (service design, schemas, queues, failure modes) with diagrams and checklist. | Architecture doc merged; diagrams committed; checklist appended. |
| DOCS-POLICY-27-011 | TODO | Docs Guild, Observability Guild | DEVOPS-POLICY-27-004 | Publish `/docs/observability/policy-telemetry.md` with metrics/log tables, dashboards, alerts, and compliance checklist. | Observability doc merged; dashboards linked; checklist appended. |
| DOCS-POLICY-27-012 | TODO | Docs Guild, Ops Guild | DEPLOY-POLICY-27-002 | Write `/docs/runbooks/policy-incident.md` detailing rollback, freeze, forensic steps, notifications. | Runbook merged; rehearsal recorded; checklist appended. |
| DOCS-POLICY-27-013 | TODO | Docs Guild, Policy Guild | CONSOLE-STUDIO-27-001, REGISTRY-API-27-002 | Update `/docs/examples/policy-templates.md` with new templates, snippets, and sample policies. | Examples committed with commentary; lint passes; checklist appended. |
| DOCS-POLICY-27-014 | TODO | Docs Guild, Policy Registry Guild | REGISTRY-API-27-003, WEB-POLICY-27-001 | Refresh `/docs/aoc/aoc-guardrails.md` to include Studio-specific guardrails and validation scenarios. | Doc updated with Studio guardrails; compliance checklist appended. |
| DOCS-POLICY-27-001 | BLOCKED (2025-10-27) | Docs Guild, Policy Guild | REGISTRY-API-27-001, POLICY-ENGINE-27-001 | Publish `/docs/policy/studio-overview.md` covering lifecycle, roles, glossary, and compliance checklist. | Doc merged with diagrams + lifecycle table; checklist appended; stakeholders sign off. |
> Blocked by `REGISTRY-API-27-001` and `POLICY-ENGINE-27-001`; need spec + compile data.
> Blocker: Registry OpenAPI (`REGISTRY-API-27-001`) and policy compile enrichments (`POLICY-ENGINE-27-001`) are still TODO; need final interfaces before drafting overview.
| DOCS-POLICY-27-002 | BLOCKED (2025-10-27) | Docs Guild, Console Guild | CONSOLE-STUDIO-27-001 | Write `/docs/policy/authoring.md` detailing workspace templates, snippets, lint rules, IDE shortcuts, and best practices. | Authoring doc includes annotated screenshots, snippet catalog, compliance checklist. |
> Blocked by `CONSOLE-STUDIO-27-001` Studio authoring UI pending.
> Blocker: Console Studio authoring UI (`CONSOLE-STUDIO-27-001`) not implemented; awaiting UX to capture flows/snippets.
| DOCS-POLICY-27-003 | BLOCKED (2025-10-27) | Docs Guild, Policy Registry Guild | REGISTRY-API-27-007 | Document `/docs/policy/versioning-and-publishing.md` (semver rules, attestations, rollback) with compliance checklist. | Doc merged with flow diagrams; attestation steps documented; checklist appended. |
> Blocked by `REGISTRY-API-27-007` publish/sign pipeline outstanding.
> Blocker: Registry publish/sign workflow (`REGISTRY-API-27-007`) pending.
| DOCS-POLICY-27-004 | BLOCKED (2025-10-27) | Docs Guild, Scheduler Guild | REGISTRY-API-27-005, SCHED-WORKER-27-301 | Write `/docs/policy/simulation.md` covering quick vs batch sim, thresholds, evidence bundles, CLI examples. | Simulation doc includes charts, sample manifests, checklist appended. |
> Blocked by `REGISTRY-API-27-005`/`SCHED-WORKER-27-301` batch simulation not ready.
> Blocker: Batch simulation APIs/workers (`REGISTRY-API-27-005`, `SCHED-WORKER-27-301`) still TODO.
| DOCS-POLICY-27-005 | BLOCKED (2025-10-27) | Docs Guild, Product Ops | REGISTRY-API-27-006 | Publish `/docs/policy/review-and-approval.md` with approver requirements, comments, webhooks, audit trail guidance. | Doc merged with role matrix + webhook schema; checklist appended. |
> Blocked by `REGISTRY-API-27-006` review workflow not implemented.
> Blocker: Review workflow (`REGISTRY-API-27-006`) not landed.
| DOCS-POLICY-27-006 | BLOCKED (2025-10-27) | Docs Guild, Policy Guild | REGISTRY-API-27-008 | Author `/docs/policy/promotion.md` covering environments, canary, rollback, and monitoring steps. | Promotion doc includes examples + checklist; verified by Policy Ops. |
> Blocked by `REGISTRY-API-27-008` promotion APIs pending.
> Blocker: Promotion/canary APIs (`REGISTRY-API-27-008`) outstanding.
| DOCS-POLICY-27-007 | BLOCKED (2025-10-27) | Docs Guild, DevEx/CLI Guild | CLI-POLICY-27-001..004 | Update `/docs/policy/cli.md` with new commands, JSON schemas, CI usage, and compliance checklist. | CLI doc merged with transcripts; schema references validated; checklist appended. |
> Blocked by `CLI-POLICY-27-001..004` CLI commands missing.
> Blocker: Policy CLI commands (`CLI-POLICY-27-001..004`) yet to implement.
| DOCS-POLICY-27-008 | BLOCKED (2025-10-27) | Docs Guild, Policy Registry Guild | REGISTRY-API-27-001..008 | Publish `/docs/policy/api.md` describing Registry endpoints, request/response schemas, errors, and feature flags. | API doc aligned with OpenAPI; examples validated; checklist appended. |
> Blocked by `REGISTRY-API-27-001..008` OpenAPI + endpoints incomplete.
> Blocker: Registry OpenAPI/spec suite (`REGISTRY-API-27-001..008`) incomplete.
| DOCS-POLICY-27-009 | BLOCKED (2025-10-27) | Docs Guild, Security Guild | AUTH-POLICY-27-002 | Create `/docs/security/policy-attestations.md` covering signing, verification, key rotation, and compliance checklist. | Security doc approved by Security Guild; verifier steps documented; checklist appended. |
> Blocked by `AUTH-POLICY-27-002` signing enforcement pending.
> Blocker: Authority signing enforcement (`AUTH-POLICY-27-002`) pending.
| DOCS-POLICY-27-010 | BLOCKED (2025-10-27) | Docs Guild, Architecture Guild | REGISTRY-API-27-001, SCHED-WORKER-27-301 | Author `/docs/architecture/policy-registry.md` (service design, schemas, queues, failure modes) with diagrams and checklist. | Architecture doc merged; diagrams committed; checklist appended. |
> Blocked by `REGISTRY-API-27-001` & `SCHED-WORKER-27-301` need delivery.
> Blocker: Policy Registry schema/workers not delivered (see `REGISTRY-API-27-001`, `SCHED-WORKER-27-301`).
| DOCS-POLICY-27-011 | BLOCKED (2025-10-27) | Docs Guild, Observability Guild | DEVOPS-POLICY-27-004 | Publish `/docs/observability/policy-telemetry.md` with metrics/log tables, dashboards, alerts, and compliance checklist. | Observability doc merged; dashboards linked; checklist appended. |
> Blocked by `DEVOPS-POLICY-27-004` observability dashboards outstanding.
> Blocker: Observability dashboards (`DEVOPS-POLICY-27-004`) not built.
| DOCS-POLICY-27-012 | BLOCKED (2025-10-27) | Docs Guild, Ops Guild | DEPLOY-POLICY-27-002 | Write `/docs/runbooks/policy-incident.md` detailing rollback, freeze, forensic steps, notifications. | Runbook merged; rehearsal recorded; checklist appended. |
> Blocked by `DEPLOY-POLICY-27-002` incident runbook inputs pending.
> Blocker: Ops runbook inputs (`DEPLOY-POLICY-27-002`) pending.
| DOCS-POLICY-27-013 | BLOCKED (2025-10-27) | Docs Guild, Policy Guild | CONSOLE-STUDIO-27-001, REGISTRY-API-27-002 | Update `/docs/examples/policy-templates.md` with new templates, snippets, and sample policies. | Examples committed with commentary; lint passes; checklist appended. |
> Blocked by `CONSOLE-STUDIO-27-001`/`REGISTRY-API-27-002` templates missing.
> Blocker: Studio templates and registry storage (`CONSOLE-STUDIO-27-001`, `REGISTRY-API-27-002`) not available.
| DOCS-POLICY-27-014 | BLOCKED (2025-10-27) | Docs Guild, Policy Registry Guild | REGISTRY-API-27-003, WEB-POLICY-27-001 | Refresh `/docs/aoc/aoc-guardrails.md` to include Studio-specific guardrails and validation scenarios. | Doc updated with Studio guardrails; compliance checklist appended. |
> Blocked by `REGISTRY-API-27-003` & `WEB-POLICY-27-001` guardrails not implemented.
> Blocker: Registry compile pipeline/web proxy (`REGISTRY-API-27-003`, `WEB-POLICY-27-001`) outstanding.
## Vulnerability Explorer (Sprint 29)

131
docs/accessibility.md Normal file
View File

@@ -0,0 +1,131 @@
# StellaOps Console Accessibility Guide
> **Audience:** Accessibility Guild, Console Guild, Docs Guild, QA.
> **Scope:** Keyboard interaction model, screen-reader behaviour, colour & focus tokens, testing workflows, offline considerations, and compliance checklist for the StellaOps Console (Sprint23).
The console targets **WCAG2.2 AA** across all supported browsers (Chromium, Firefox ESR) and honours StellaOps sovereign/offline constraints. Every build must keep keyboard-only users, screen-reader users, and high-contrast operators productive without relying on third-party services.
---
## 1·Accessibility Principles
1. **Deterministic navigation** Focus order, shortcuts, and announcements remain stable across releases; URLs encode state for deep links.
2. **Keyboard-first design** Every actionable element is reachable via keyboard; shortcuts provide accelerators, and remapping is available via *Settings → Accessibility → Keyboard shortcuts*.
3. **Assistive technology parity** ARIA roles and live regions mirror visual affordances (status banners, SSE tickers, progress drawers). Screen readers receive polite/atomic updates to avoid chatter.
4. **Colour & contrast tokens** All palettes derive from design tokens that achieve ≥4.5:1 contrast (text) and ≥3:1 for graphical indicators; tokens pass automated contrast linting.
5. **Offline equivalence** Accessibility features (shortcuts, offline banners, focus restoration) behave the same in sealed environments, with guidance when actions require online authority.
---
## 2·Keyboard Interaction Map
### 2.1 Global shortcuts
| Action | Macs | Windows/Linux | Notes |
|--------|------|---------------|-------|
| Command palette | `⌘K` | `CtrlK` | Focuses palette search; respects tenant scope. |
| Tenant picker | `⌘T` | `CtrlT` | Opens modal; `Enter` confirms, `Esc` cancels. |
| Filter tray toggle | `⇧F` | `ShiftF` | Focus lands on first filter; `Tab` cycles filters before returning to page. |
| Saved view presets | `⌘1-9` | `Ctrl1-9` | Bound per tenant; missing preset triggers tooltip. |
| Keyboard reference | `?` | `?` | Opens overlay listing context-specific shortcuts; `Esc` closes. |
| Global search (context) | `/` | `/` | When the filter tray is closed, focuses inline search field. |
### 2.2 Module-specific shortcuts
| Module | Action | Macs | Windows/Linux | Notes |
|--------|--------|------|---------------|-------|
| Findings | Explain search | `⌘ /` | `Ctrl/` | Only when Explain drawer open; announces results via live region. |
| SBOM Explorer | Toggle overlays | `⌘G` | `CtrlG` | Persists per session (see `/docs/ui/sbom-explorer.md`). |
| Advisories & VEX | Provider filter | `⌘F` | `CtrlAltF` | Moves focus to provider chip row. |
| Runs | Refresh snapshot | `⌘R` | `CtrlR` | Soft refresh of SSE state; no full page reload. |
| Policies | Save draft | `⌘S` | `CtrlS` | Requires edit scope; exposes toast + status live update. |
| Downloads | Copy CLI command | `⇧D` | `ShiftD` | Copies manifest or export command; toast announces scope hints. |
All shortcuts are remappable. Remaps persist in IndexedDB (per tenant) and export as part of profile bundles so operators can restore preferences offline.
---
## 3·Screen Reader & Focus Behaviour
- **Skip navigation** Each route exposes a “Skip to content” link revealed on keyboard focus. Focus order: global header → page breadcrumb → action shelf → data grid/list → drawers/dialogs.
- **Live regions** Status ticker and SSE progress bars use `aria-live="polite"` with throttling to avoid flooding AT. Error toasts use `aria-live="assertive"` and auto-focus dismiss buttons.
- **Drawers & modals** Dialog components trap focus, support `Esc` to close, and restore focus to the launching control. Screen readers announce title + purpose.
- **Tables & grids** Large tables (Findings, SBOM inventory) switch to virtualised rows but retain ARIA grid semantics (`aria-rowcount`, `aria-colindex`). Column headers include sorting state via `aria-sort`.
- **Tenancy context** Tenant badge exposes `aria-describedby` linking to context summary (environment, offline snapshot). Switching tenant queues a polite announcement summarising new scope.
- **Command palette** Uses `role="dialog"` with search input labelled. Keyboard navigation within results uses `Up/Down`; screen readers announce result category + command.
- **Offline banner** When offline, a dismissible banner announces reason and includes instructions for CLI fallback. The banner has `role="status"` so it announces once without stealing focus.
---
## 4·Colour & Focus Tokens
Console consumes design tokens published by the Console Guild (tracked via CONSOLE-FEAT-23-102). Tokens live in the design system bundle (`ui/design/tokens/colors.json`, mirrored at build time). Key tokens:
| Token | Purpose | Contrast target |
|-------|---------|-----------------|
| `so-color-surface-base` | Primary surface/background | ≥4.5:1 against `so-color-text-primary`. |
| `so-color-surface-raised` | Cards, drawers, modals | ≥3:1 against surrounding surfaces. |
| `so-color-text-primary` | Default text colour | ≥4.5:1 against base surfaces. |
| `so-color-text-inverted` | Text on accent buttons | ≥4.5:1 against accent fills. |
| `so-color-accent-primary` | Action buttons, focus headings | ≥3:1 against surface. |
| `so-color-status-critical` | Error toasts, violation chips | ≥4.5:1 for text; `critical-bg` provides >3:1 on neutral surface. |
| `so-color-status-warning` | Warning banners | Meets 3:1 on surface and 4.5:1 for text overlays. |
| `so-color-status-success` | Success toasts, pass badges | ≥3:1 for iconography; text uses `text-primary`. |
| `so-focus-ring` | 2px outline used across focusable elements | 3:1 against both light/dark surfaces. |
Colour tokens undergo automated linting (**axe-core contrast checks** + custom luminance script) during build. Any new token must include dark/light variants and pass the token contract tests.
---
## 5·Testing Workflow
| Layer | Tooling | Frequency | Notes |
|-------|---------|-----------|-------|
| Component a11y | Storybook + axe-core addon | On PR (story CI) | Fails when axe detects violations. |
| Route regression | Playwright a11y sweep (`pnpm test:a11y`) | Nightly & release pipeline | Executes keyboard navigation, checks focus trap, runs Axe on key routes (Dashboard, Findings, SBOM, Admin). |
| Colour contrast lint | Token validator (`tools/a11y/check-contrast.ts`) | On token change | Guards design token updates. |
| CI parity | Pending `scripts/check-console-cli-parity.sh` (CONSOLE-DOC-23-502) | Release CI | Ensures CLI commands documented for parity features. |
| Screen-reader spot checks | Manual NVDA + VoiceOver scripts | Pre-release checklist | Scenarios: tenant switch, explain drawer, downloads parity copy. |
| Offline smoke | `stella offline kit import` + Playwright sealed-mode run | Prior to Offline Kit cut | Validates offline banners, disabled actions, keyboard flows without Authority. |
Accessibility QA (CONSOLE-QA-23-402) tracks failing scenarios via Playwright snapshots and publishes reports in the Downloads parity channel (`kind = "parity.report"` placeholder until CLI parity CI lands).
---
## 6·Offline & Internationalisation Considerations
- Offline mode surfaces staleness badges and disables remote-only palette entries; keyboard focus skips disabled controls.
- Saved shortcuts, presets, and remaps serialise into Offline Kit bundles so operators can restore preferences post-import.
- Locale switching (future feature flag) will load translations at runtime; ensure ARIA labels use i18n tokens rather than hard-coded strings.
- For sealed installs, guidance panels include CLI equivalents (`stella auth fresh-auth`, `stella runs export`) to unblock tasks when Authority is unavailable.
---
## 7·Compliance Checklist
- [ ] Keyboard shortcut matrix validated (default + remapped) and documented.
- [ ] Screen-reader pass recorded for tenant switch, Explain drawer, Downloads copy-to-clipboard.
- [ ] Colour tokens audited; contrast reports stored with release artifacts.
- [ ] Automated a11y pipelines (Storybook axe, Playwright a11y) green; failures feed the `#console-qa` channel.
- [ ] Offline kit a11y smoke executed before publishing each bundle.
- [ ] CLI parity gaps logged in `/docs/cli-vs-ui-parity.md`; UI callouts reference fallback commands until parity closes.
- [ ] Accessibility Guild sign-off captured in sprint log and release notes reference this guide.
- [ ] References cross-checked (`/docs/ui/navigation.md`, `/docs/ui/downloads.md`, `/docs/security/console-security.md`, `/docs/observability/ui-telemetry.md`).
---
## 8·References
- `/docs/ui/navigation.md` shortcut definitions, URL schema.
- `/docs/ui/downloads.md` CLI parity and offline copy workflows.
- `/docs/ui/console-overview.md` tenant model, filter behaviours.
- `/docs/security/console-security.md` security metrics and DPoP/fresh-auth requirements.
- `/docs/observability/ui-telemetry.md` telemetry metrics mapped to accessibility features.
- `/docs/cli-vs-ui-parity.md` parity status per console feature.
- `CONSOLE-QA-23-402` Accessibility QA backlog (Playwright + manual checks).
- `CONSOLE-FEAT-23-102` Design tokens & theming delivery.
---
*Last updated: 2025-10-28 (Sprint23).*

View File

@@ -0,0 +1,218 @@
# Advisory Observations & Linksets
> Imposed rule: Work of this type or tasks of this type on this component must also
> be applied everywhere else it should be applied.
The Link-Not-Merge (LNM) initiative replaces the legacy "merge" pipeline with
immutable observations and correlation linksets. This guide explains how
Concelier ingests advisory statements, preserves upstream truth, and produces
linksets that downstream services (Policy Engine, Vuln Explorer, Console) can
use without collapsing sources together.
---
## 1. Model overview
### 1.1 Observation lifecycle
1. **Ingest** Connectors fetch upstream payloads (CSAF, OSV, vendor feeds),
validate signatures, and drop any derived fields prohibited by the
Aggregation-Only Contract (AOC).
2. **Persist** Concelier writes immutable `advisory_observations` scoped by
`tenant`, `(source.vendor, upstreamId)`, and `contentHash`. Supersedes chains
capture revisions without mutating history.
3. **Expose** WebService surfaces paged/read APIs; Offline Kit snapshots
include the same documents for air-gapped installs.
Observation schema highlights:
```text
observationId = {tenant}:{source.vendor}:{upstreamId}:{revision}
tenant, source{vendor, stream, api, collectorVersion}
upstream{upstreamId, documentVersion, fetchedAt, receivedAt,
contentHash, signature{present, format, keyId, signature}}
content{format, specVersion, raw}
identifiers{cve?, ghsa?, aliases[], osvIds[]}
linkset{purls[], cpes[], aliases[], references[], conflicts[]?}
createdAt, attributes{batchId?, replayCursor?}
```
- **Immutable raw** (`content.raw`) mirrors upstream payloads exactly.
- **Provenance** (`source.*`, `upstream.*`) satisfies AOC guardrails and enables
cryptographic attestations.
- **Identifiers** retain lossless extracts (CVE, GHSA, vendor aliases) that seed
linksets.
- **Linkset** captures join hints but never merges or adds derived severity.
### 1.2 Linkset lifecycle
Linksets correlate observations that describe the same vulnerable product while
keeping each source intact.
1. **Seed** Observations emit normalized identifiers (`purl`, `cpe`,
`alias`) during ingestion.
2. **Correlate** Linkset builder groups observations by tenant, product
coordinates, and equivalence signals (PURL alias graph, CVE overlap, CVSS
vector equality, fuzzy titles).
3. **Annotate** Detected conflicts (severity disagreements, affected-range
mismatch, incompatible references) are recorded with structured payloads and
preserved for UI/API export.
4. **Persist** Results land in `advisory_linksets` with deterministic IDs
(`linksetId = {tenant}:{hash(aliases+purls+seedIds)}`) and append-only history
for reproducibility.
Linksets never suppress or prefer one source; they provide aligned evidence so
other services can apply policy.
---
## 2. Observation vs. linkset
- **Purpose**
- Observation: Immutable record per vendor and revision.
- Linkset: Correlates observations that share product identity.
- **Mutation**
- Observation: Append-only via supersedes chain.
- Linkset: Rebuilt deterministically from canonical signals.
- **Allowed fields**
- Observation: Raw payload, provenance, identifiers, join hints.
- Linkset: Observation references, normalized product metadata, conflicts.
- **Forbidden fields**
- Observation: Derived severity, policy status, opinionated dedupe.
- Linkset: Derived severity (conflicts recorded but unresolved).
- **Consumers**
- Observation: Evidence API, Offline Kit, CLI exports.
- Linkset: Policy Engine overlay, UI evidence panel, Vuln Explorer.
### 2.1 Example sequence
1. Red Hat PSIRT publishes RHSA-2025:1234 for OpenSSL; Concelier inserts an
observation for vendor `redhat` with `pkg:rpm/redhat/openssl@1.1.1w-12`.
2. NVD issues CVE-2025-0001; a second observation is inserted for vendor `nvd`.
3. Linkset builder runs, groups the two observations, records alias and PURL
overlap, and flags a CVSS disagreement (`7.5` vs `7.2`).
4. Policy Engine reads the linkset, recognises the severity variance, and relies
on configured rules to decide the effective output.
---
## 3. Conflict handling
Conflicts record disagreements without altering source payloads. The builder
emits structured entries:
```json
{
"type": "severity-mismatch",
"field": "cvss.baseScore",
"observations": [
{
"source": "redhat",
"value": "7.5",
"vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"
},
{
"source": "nvd",
"value": "7.2",
"vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N"
}
],
"confidence": "medium",
"detectedAt": "2025-10-27T14:00:00Z"
}
```
Supported conflict classes:
- `severity-mismatch` CVSS or qualitative severities differ.
- `affected-range-divergence` Product ranges, fixed versions, or platforms
disagree.
- `statement-disagreement` One observation declares `not_affected` while
another states `affected`.
- `reference-clash` URL or classifier collisions (for example, exploit URL vs
conflicting advisory).
- `alias-inconsistency` Aliases map to different canonical IDs (GHSA vs CVE).
- `metadata-gap` Required provenance missing on one source; logged as a
warning.
Conflict surfaces:
- WebService endpoints (`GET /advisories/linksets/{id}``conflicts[]`).
- UI evidence panel chips and conflict badges.
- CLI exports (JSON/OSV) exposed through LNM commands.
- Observability metrics (`advisory_linkset_conflicts_total{type}`).
---
## 4. AOC alignment
Observations and linksets must satisfy Aggregation-Only Contract invariants:
- **No derived severity** `content.raw` may include upstream severity, but the
observation body never injects or edits severity.
- **No merges** Each upstream document stays separate; linksets reference
observations via deterministic IDs.
- **Provenance mandatory** Missing `signature` or `source` metadata is an AOC
violation (`ERR_AOC_004`).
- **Idempotent writes** Duplicate `contentHash` yields a no-op; supersedes
pointer captures new revisions.
- **Deterministic output** Linkset builder sorts keys, normalizes timestamps
(UTC ISO-8601), and uses canonical JSON hashing.
Violations trigger guard errors (`ERR_AOC_00x`), emit `aoc_violation_total`
metrics, and block persistence until corrected.
---
## 5. Downstream consumption
- **Policy Engine** Computes effective severity and risk overlays from linkset
evidence and conflicts.
- **Console UI** Renders per-source statements, signed hashes, and conflict
banners inside the evidence panel.
- **CLI (`stella advisories linkset …`)** Exports observations and linksets as
JSON or OSV for offline triage.
- **Offline Kit** Shipping snapshots include observation and linkset
collections for air-gap parity.
- **Observability** Dashboards track ingestion latency, conflict counts, and
supersedes depth.
When adding new consumers, ensure they honour append-only semantics and do not
mutate observation or linkset collections.
---
## 6. Validation & testing
- **Unit tests** (`StellaOps.Concelier.Core.Tests`) validate schema guards,
deterministic linkset hashing, conflict detection fixtures, and supersedes
chains.
- **Mongo integration tests** (`StellaOps.Concelier.Storage.Mongo.Tests`) verify
indexes and idempotent writes under concurrency.
- **CLI smoke suites** confirm `stella advisories observations` and `stella
advisories linksets` export stable JSON.
- **Determinism checks** replay identical upstream payloads and assert that the
resulting observation and linkset documents match byte for byte.
- **Offline kit verification** simulates air-gapped bootstrap to confirm that
snapshots align with live data.
Add fixtures whenever a new conflict type or correlation signal is introduced.
Ensure canonical JSON serialization remains stable across .NET runtime updates.
---
## 7. Reviewer checklist
- Observation schema segment matches the latest `StellaOps.Concelier.Models`
contract.
- Linkset lifecycle covers correlation signals, conflict classes, and
deterministic IDs.
- AOC invariants are explicitly called out with violation codes.
- Examples include multi-source correlation plus conflict annotation.
- Downstream consumer guidance reflects active APIs and CLI features.
- Testing section lists required suites (Core, Storage, CLI, Offline).
- Imposed rule reminder is present at the top of the document.
Confirmed against Concelier Link-Not-Merge tasks:
`CONCELIER-LNM-21-001..005`, `CONCELIER-LNM-21-101..103`,
`CONCELIER-LNM-21-201..203`.

View File

@@ -117,10 +117,10 @@ sequenceDiagram
| Scope | Holder | Purpose | Notes |
|-------|--------|---------|-------|
| `advisory:write` / `vex:write` | Concelier / Excititor collectors | Append raw documents through ingestion endpoints. | Paired with tenant claims; requests without tenant are rejected. |
| `advisory:verify` / `vex:verify` | DevOps verify identity, CLI | Run `stella aoc verify` or call `/aoc/verify`. | Read-only; cannot mutate raw docs. |
| `advisory:ingest` / `vex:ingest` | Concelier / Excititor collectors | Append raw documents through ingestion endpoints. | Paired with tenant claims; requests without tenant are rejected. |
| `advisory:read` / `vex:read` | DevOps verify identity, CLI | Run `stella aoc verify` or call `/aoc/verify`. | Read-only; cannot mutate raw docs. |
| `effective:write` | Policy Engine | Materialise `effective_finding_*` overlays. | Only Policy Engine identity may hold; ingestion contexts receive `ERR_AOC_006` if they attempt. |
| `effective:read` | Console, CLI, exports | Consume derived findings. | Enforced by Gateway and downstream services. |
| `findings:read` | Console, CLI, exports | Consume derived findings. | Enforced by Gateway and downstream services. |
---

155
docs/cli-vs-ui-parity.md Normal file
View File

@@ -0,0 +1,155 @@
# Console CLI ↔ UI Parity Matrix
> **Audience:** Docs Guild, Console Guild, CLI Guild, DevOps automation.
> **Scope:** Track feature-level parity between the StellaOps Console and the `stella` CLI, surface pending work, and describe the parity CI check owned by CONSOLE-DOC-23-502.
Status key:
- **✅ Available** command exists in `StellaOps.Cli` and is documented.
- **🟡 In progress** command implemented but still under active delivery (task status `DOING`).
- **🟩 Planned** command specd but not yet implemented (task `TODO`).
- **⚪ UI-only** no CLI equivalent required.
- **🔴 Gap** CLI feature missing with no active task; file a task before sprint exit.
---
## 1·Navigation & Tenancy
| UI capability | CLI command(s) | Status | Notes / Tasks |
|---------------|----------------|--------|---------------|
| Login / token cache status (`/console/profile`) | `stella auth login`, `stella auth status`, `stella auth whoami` | ✅ Available | Command definitions in `CommandFactory.BuildAuthCommand`. |
| Fresh-auth challenge for sensitive actions | `stella auth fresh-auth` | ✅ Available | Referenced in `/docs/ui/admin.md`. |
| Tenant switcher (UI shell) | `--tenant` flag across CLI commands | ✅ Available | All multi-tenant commands require explicit `--tenant`. |
| Tenant creation / suspension | *(pending CLI)* | 🟩 Planned | No `stella auth tenant *` commands yet track via `CLI-TEN-47-001` (scopes & tenancy). |
---
## 2·Policies & Findings
| UI capability | CLI command(s) | Status | Notes / Tasks |
|---------------|----------------|--------|---------------|
| Policy simulation diff, explain | `stella policy simulate` | 🟡 In progress | Implementation present; task `CLI-POLICY-20-002` marked DOING. |
| Promote / activate policy | `stella policy promote`, `stella policy activate` | 🟩 Planned | Spec tracked under `CLI-POLICY-23-005`. |
| History & explain trees | `stella policy history`, `stella policy explain` | 🟩 Planned | `CLI-POLICY-23-006`. |
| Findings explorer export | `stella findings get`, `stella findings export` | 🟩 Planned | Part of `CLI-POLICY-20-003`. |
| Explain drawer JSON | `stella policy simulate --format json` | 🟡 In progress | Same command; JSON output flagged for CLI tests. |
---
## 3·Runs & Evidence
| UI capability | CLI command(s) | Status | Notes / Tasks |
|---------------|----------------|--------|---------------|
| Run retry / cancel | `stella runs retry`, `stella runs cancel` | 🟩 Planned | Included in export suite task `CLI-EXPORT-35-001`. |
| Manual run submit / preview | `stella runs submit`, `stella runs preview` | 🟩 Planned | `CLI-EXPORT-35-001`. |
| Evidence bundle export | `stella runs export --run <id> --bundle` | 🟩 Planned | `CLI-EXPORT-35-001`. |
| Run status polling | `stella runs status` | 🟩 Planned | Same task. |
---
## 4·Advisories, VEX, SBOM
| UI capability | CLI command(s) | Status | Notes / Tasks |
|---------------|----------------|--------|---------------|
| Advisory observations search | `stella vuln observations` | ✅ Available | Implemented via `BuildVulnCommand`. |
| Advisory linkset export | `stella advisory linkset show/export` | 🟩 Planned | `CLI-LNM-22-001`. |
| VEX observations / linksets | `stella vex obs get/linkset show` | 🟩 Planned | `CLI-LNM-22-002`. |
| SBOM overlay export | `stella sbom overlay apply/export` | 🟩 Planned | Scoped to upcoming SBOM CLI sprint (`SBOM-CONSOLE-23-001/002` + CLI backlog). |
---
## 5·Downloads & Offline Kit
| UI capability | CLI command(s) | Status | Notes / Tasks |
|---------------|----------------|--------|---------------|
| Manifest lookup (Console Downloads) | `stella downloads manifest show --artifact <id>` | 🟩 Planned | Delivered with `CONSOLE-DOC-23-502` + CLI parity commands. |
| Mirror digest to OCI archive | `stella downloads mirror --artifact <id> --to <target>` | 🟩 Planned | Same task bundle (`CONSOLE-DOC-23-502`). |
| Console health check | `stella console status --endpoint <url>` | 🟩 Planned | Tracked in `CONSOLE-DOC-23-502`; interim use `curl` as documented. |
| Offline kit import/export | `stella offline kit import`, `stella offline kit export` | ✅ Available | Implemented (see `CommandHandlers.HandleOfflineKitImportAsync/HandleOfflineKitPullAsync`). |
---
## 6·Admin & Security
| UI capability | CLI command(s) | Status | Notes / Tasks |
|---------------|----------------|--------|---------------|
| Client creation / rotation | `stella auth client create` *(planned)* | 🟩 Planned | Pending tenancy backlog `CLI-TEN-47-001`. |
| Token revoke | `stella auth revoke export/verify` | ✅ Available | Already implemented. |
| Audit export | `stella auth audit export` | 🟩 Planned | Needs CLI work item (Authority guild). |
| Signing key rotation | `stella auth signing rotate` | 🟩 Planned | To be added with AUTH-CONSOLE-23-003 follow-up. |
---
## 7·Telemetry & Observability
| UI capability | CLI command(s) | Status | Notes / Tasks |
|---------------|----------------|--------|---------------|
| Telemetry dashboard parity | `stella obs top`, `stella obs trace`, `stella obs logs` | 🟩 Planned | CLI observability epic (`CLI-OBS-51-001`, `CLI-OBS-52-001`). |
| Incident mode toggle | `stella obs incident-mode enable|disable|status` | 🟩 Planned | CLI task `CLI-OBS-55-001`. |
| Verify console telemetry health | `stella console status --telemetry` | 🟩 Planned | Part of `CONSOLE-DOC-23-502`. |
---
## 8·Parity Gaps & Follow-up
- **Tenant and client lifecycle CLI**: create/suspend tenants, manage clients. Coordinate with Authority CLI epic (`CLI-TEN-47-001`, `CLI-TEN-49-001`).
- **Downloads parity commands**: blocked on `CONSOLE-DOC-23-502` and DevOps pipeline `DOWNLOADS-CONSOLE-23-001`.
- **Policy promotion/history**: requires completion of CLI policy epic (`CLI-POLICY-23-005`/`23-006`).
- **Runs/evidence exports**: waiting on `CLI-EXPORT-35-001`.
- **Observability tooling**: deliver `stella obs` commands before enabling parity CI checks for telemetry.
Document updates should occur whenever a row changes status. When promoting a command from Planned → Available, ensure:
1. CLI command merged with help text.
2. Relevant UI doc references updated to remove “pending” callouts.
3. This matrix row status updated to ✅ and task IDs moved to release notes.
---
## 9·Parity CI Check (CONSOLE-DOC-23-502)
- **Owner:** Docs Guild + DevEx/CLI Guild.
- **Artefact:** Planned `.gitea/workflows/cli-parity-console.yml`.
- **What it does:** Runs `scripts/check-console-cli-parity.sh` (to be committed with the workflow) which:
1. Parses this matrix (YAML view exported from Markdown) to identify rows marked ✅.
2. Executes `stella --help` to confirm listed commands exist.
3. Optionally triggers smoke commands in sandbox mode (e.g., `stella policy simulate --help`).
- **Failure action:** Workflow fails when a listed command is missing or when a row marked ✅ still contains “pending” notes. Update the matrix or fix CLI implementation before merging.
Until the workflow lands, run the checker locally:
```bash
# Pending CONSOLE-DOC-23-502 placeholder command
./scripts/check-console-cli-parity.sh
```
The script should emit a parity report that feeds into the Downloads workspace (`kind = "parity.report"`).
---
## 10·Compliance checklist
- [ ] Matrix reflects latest command availability (statuses accurate, task IDs linked).
- [ ] Notes include owning backlog items for every 🟩 / 🟡 row.
- [ ] CLI commands marked ✅ have corresponding entries in `/docs/cli/*.md` or module-specific docs.
- [ ] CI parity workflow description kept in sync with CONSOLE-DOC-23-502 implementation.
- [ ] Downloads workspace links to latest parity report.
- [ ] Install / observability guides reference this matrix for pending CLI parity.
- [ ] Offline workflows capture CLI fallbacks when commands are pending.
- [ ] Docs Guild review recorded in sprint log once parity CI lands.
---
## 11·References
- `/docs/ui/*.md` per-surface UI parity callouts.
- `/docs/install/docker.md` CLI parity section for deployments.
- `/docs/observability/ui-telemetry.md` telemetry metrics referencing CLI checks.
- `/docs/security/console-security.md` security metrics & CLI parity expectations.
- `src/StellaOps.Cli/TASKS.md` authoritative status for CLI backlog.
- `/docs/updates/2025-10-28-docs-guild.md` coordination note for Authority/Security follow-up.
---
*Last updated: 2025-10-28 (Sprint23).*

View File

@@ -11,8 +11,9 @@ Both commands are designed to enforce the AOC guardrails documented in the [aggr
- CLI version: `stella`0.19.0 (AOC feature gate enabled).
- Required scopes (DPoP-bound):
- `advisory:verify` for Concelier sources.
- `vex:verify` for Excititor sources (optional but required for VEX checks).
- `advisory:read` for Concelier sources.
- `vex:read` for Excititor sources (optional but required for VEX checks).
- `aoc:verify` to invoke guard verification endpoints.
- `tenant:select` if your deployment uses tenant switching.
- Connectivity: direct access to Concelier/Excititor APIs or Offline Kit snapshot (see §4).
- Environment: set `STELLA_AUTHORITY_URL`, `STELLA_TENANT`, and export a valid OpTok via `stella auth login` or existing token cache.

View File

@@ -209,6 +209,13 @@ stella policy run cancel run:P-7:2025-10-26:auto
Replay downloads sealed bundle for deterministic verification.
### 4.4 Schema artefacts for CLI validation
- CI publishes canonical JSON Schema exports for `PolicyRunRequest`, `PolicyRunStatus`, `PolicyDiffSummary`, and `PolicyExplainTrace` as the `policy-schema-exports` artifact (see `.gitea/workflows/build-test-deploy.yml`).
- Each run writes the files to `artifacts/policy-schemas/<commit>/` and stores a unified diff (`policy-schema-diff.patch`) comparing them with the tracked baseline in `docs/schemas/`.
- Schema changes trigger an alert in Slack `#policy-engine` via the `POLICY_ENGINE_SCHEMA_WEBHOOK` secret so CLI maintainers know to refresh fixtures or validation rules.
- Consume these artefacts in CLI tests to keep payload validation aligned without committing generated files into the repo.
---
## 5·Findings & Explainability
@@ -273,7 +280,7 @@ All non-zero exits emit structured error envelope on stderr when `--format json`
- [ ] **Help text synced:** `stella policy --help` matches documented flags/examples (update during release pipeline).
- [ ] **Exit codes mapped:** Table above reflects CLI implementation and CI asserts mapping for `ERR_POL_*`.
- [ ] **JSON schemas verified:** Example payloads validated against OpenAPI/SDK contracts before publishing.
- [ ] **JSON schemas verified:** Example payloads validated against OpenAPI/SDK contracts before publishing. (_CI now exports canonical schemas as `policy-schema-exports`; wire tests to consume them._)
- [ ] **Scope guidance present:** Each command lists required Authority scopes.
- [ ] **Offline guidance included:** Sealed-mode steps and bundle workflows documented.
- [ ] **Cross-links tested:** Links to DSL, lifecycle, runs, and API docs resolve locally (`yarn docs:lint`).
@@ -281,4 +288,4 @@ All non-zero exits emit structured error envelope on stderr when `--format json`
---
*Last updated: 2025-10-26 (Sprint 20).*
*Last updated: 2025-10-27 (Sprint 20).*

View File

@@ -206,7 +206,7 @@ Troubleshooting steps:
- `deploy/helm/stellaops/values-*.yaml` - environment-specific overrides.
- `deploy/compose/docker-compose.console.yaml` - Compose bundle.
- `/docs/ui/downloads.md` - manifest and offline bundle guidance.
- `/docs/security/console-security.md` (pending) - CSP and Authority scopes.
- `/docs/security/console-security.md` - CSP and Authority scopes.
- `/docs/24_OFFLINE_KIT.md` - Offline kit packaging and verification.
- `/docs/ops/deployment-runbook.md` (pending) - wider platform deployment steps.
@@ -226,4 +226,3 @@ Troubleshooting steps:
---
*Last updated: 2025-10-27 (Sprint 23).*

View File

@@ -68,7 +68,7 @@ Ensure `AOC_VERIFIER_USER` exists in Authority with `aoc:verify` scope and no wr
clients:
- clientId: stella-aoc-verify
grantTypes: [client_credentials]
scopes: [aoc:verify, advisory:verify, vex:verify]
scopes: [aoc:verify, advisory:read, vex:read]
tenants: [default]
```

View File

@@ -17,9 +17,11 @@ The exporter builds against `StellaOps.Scheduler.Models` and emits:
- `policy-diff-summary.schema.json`
- `policy-explain-trace.schema.json`
The build pipeline (`.gitea/workflows/build-test-deploy.yml`, job **Export policy run schemas**) runs this script on every push and pull request. Exports land under `artifacts/policy-schemas/<commit>/`, are published as the `policy-schema-exports` artifact, and changes trigger a Slack post to `#policy-engine` via the `POLICY_ENGINE_SCHEMA_WEBHOOK` secret. A unified diff is stored alongside the exports for downstream consumers.
## CI integration checklist
- [ ] Invoke the script in the DevOps pipeline (see `DEVOPS-POLICY-20-004`).
- [ ] Publish the generated schemas as pipeline artifacts.
- [ ] Notify downstream consumers when schemas change (Slack `#policy-engine`, changelog snippet).
- [x] Invoke the script in the DevOps pipeline (see `DEVOPS-POLICY-20-004`).
- [x] Publish the generated schemas as pipeline artifacts.
- [x] Notify downstream consumers when schemas change (Slack `#policy-engine`, changelog snippet).
- [ ] Gate CLI validation once schema artifacts are available.

View File

@@ -5,6 +5,17 @@ metadata:
- serverless
- prod
- strict
exceptions:
effects:
- id: suppress-canary
name: Canary Freeze
effect: suppress
routingTemplate: secops-approvers
maxDurationDays: 14
routingTemplates:
- id: secops-approvers
authorityRouteId: governance.secops
requireMfa: true
rules:
- name: Block High And Above
severity: [High, Critical]

View File

@@ -124,7 +124,7 @@ Consumers should map these codes to CLI exit codes and structured log events so
- `POST /aoc/verify`: runs guard checks over recent documents and returns summary totals plus first violations.
- **Excititor ingestion** (`StellaOps.Excititor.WebService`) mirrors the same surface for VEX documents.
- **CLI workflows** (`stella aoc verify`, `stella sources ingest --dry-run`) surface pre-flight verification; documentation will live in `/docs/cli/` alongside Sprint 19 CLI updates.
- **Authority scopes**: new `advisory:write`, `advisory:verify`, `vex:write`, and `vex:verify` scopes enforce least privilege; see [Authority Architecture](../ARCHITECTURE_AUTHORITY.md) for scope grammar.
- **Authority scopes**: new `advisory:ingest`, `advisory:read`, `vex:ingest`, and `vex:read` scopes enforce least privilege; see [Authority Architecture](../ARCHITECTURE_AUTHORITY.md) for scope grammar.
## 7. Idempotency and Supersedes Rules
@@ -154,7 +154,7 @@ Consumers should map these codes to CLI exit codes and structured log events so
## 10. Security and Tenancy Checklist
- Enforce Authority scopes (`advisory:write`, `vex:write`, `advisory:verify`, `vex:verify`) and require tenant claims on every request.
- Enforce Authority scopes (`advisory:ingest`, `vex:ingest`, `advisory:read`, `vex:read`) and require tenant claims on every request.
- Maintain pinned trust stores for signature verification; capture verification result in metrics and logs.
- Ensure collectors never log secrets or raw authentication headers; redact tokens before persistence.
- Validate that Policy Engine remains the only identity with permission to write `effective_finding_*` documents.
@@ -173,4 +173,4 @@ Consumers should map these codes to CLI exit codes and structured log events so
---
*Last updated: 2025-10-26 (Sprint 19).*
*Last updated: 2025-10-27 (Sprint 19).*

207
docs/install/docker.md Normal file
View File

@@ -0,0 +1,207 @@
# StellaOps Console — Docker Install Recipes
> **Audience:** Deployment Guild, Console Guild, platform operators.
> **Scope:** Acquire the `stellaops/web-ui` image, run it with Compose or Helm, mirror it for airgapped environments, and keep parity with CLI workflows.
This guide focuses on the new **StellaOps Console** container. Start with the general [Installation Guide](../21_INSTALL_GUIDE.md) for shared prerequisites (Docker, registry access, TLS) and use the steps below to layer in the console.
---
## 1·Release artefacts
| Artefact | Source | Verification |
|----------|--------|--------------|
| Console image | `registry.stella-ops.org/stellaops/web-ui@sha256:<digest>` | Listed in `deploy/releases/<channel>.yaml` (`yq '.services[] | select(.name=="web-ui") | .image'`). Signed with Cosign (`cosign verify --key https://stella-ops.org/keys/cosign.pub …`). |
| Compose bundles | `deploy/compose/docker-compose.{dev,stage,prod,airgap}.yaml` | Each profile already includes a `web-ui` service pinned to the release digest. Run `docker compose --env-file <env> -f docker-compose.<profile>.yaml config` to confirm the digest matches the manifest. |
| Helm values | `deploy/helm/stellaops/values-*.yaml` (`services.web-ui`) | CI lints the chart; use `helm template` to confirm the rendered Deployment/Service carry the expected digest and env vars. |
| Offline artefact (preview) | Generated via `oras copy registry.stella-ops.org/stellaops/web-ui@sha256:<digest> oci-archive:stellaops-web-ui-<channel>.tar` | Record SHA-256 in the downloads manifest (`DOWNLOADS-CONSOLE-23-001`) and sign with Cosign before shipping in the Offline Kit. |
> **Tip:** Keep Compose/Helm digests in sync with the release manifest to preserve determinism. `deploy/tools/validate-profiles.sh` performs a quick cross-check.
---
## 2·Compose quickstart (connected host)
1. **Prepare workspace**
```bash
mkdir stella-console && cd stella-console
cp /path/to/repo/deploy/compose/env/dev.env.example .env
```
2. **Add console configuration** append the following to `.env` (adjust per environment):
```bash
CONSOLE_PUBLIC_BASE_URL=https://console.dev.stella-ops.local
CONSOLE_GATEWAY_BASE_URL=https://api.dev.stella-ops.local
AUTHORITY_ISSUER=https://authority.dev.stella-ops.local
AUTHORITY_CLIENT_ID=console-ui
AUTHORITY_SCOPES="ui.read ui.admin findings:read advisory:read vex:read aoc:verify"
AUTHORITY_DPOP_ENABLED=true
```
Optional extras from [`docs/deploy/console.md`](../deploy/console.md):
```bash
CONSOLE_FEATURE_FLAGS=runs,downloads,policies
CONSOLE_METRICS_ENABLED=true
CONSOLE_LOG_LEVEL=Information
```
3. **Verify bundle provenance**
```bash
cosign verify-blob \
--key https://stella-ops.org/keys/cosign.pub \
--signature /path/to/repo/deploy/compose/docker-compose.dev.yaml.sig \
/path/to/repo/deploy/compose/docker-compose.dev.yaml
```
4. **Launch infrastructure + console**
```bash
docker compose --env-file .env -f /path/to/repo/deploy/compose/docker-compose.dev.yaml up -d mongo minio
docker compose --env-file .env -f /path/to/repo/deploy/compose/docker-compose.dev.yaml up -d web-ui
```
The `web-ui` service exposes the console on port `8443` by default. Change the published port in the Compose file if you need to front it with an existing reverse proxy.
5. **Health check**
```bash
curl -k https://console.dev.stella-ops.local/health/ready
```
Expect `{"status":"Ready"}`. If the response is `401`, confirm Authority credentials and scopes.
---
## 3·Helm deployment (cluster)
1. **Create an overlay** (example `console-values.yaml`):
```yaml
global:
release:
version: "2025.10.0-edge"
services:
web-ui:
image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf
service:
port: 8443
env:
CONSOLE_PUBLIC_BASE_URL: "https://console.dev.stella-ops.local"
CONSOLE_GATEWAY_BASE_URL: "https://api.dev.stella-ops.local"
AUTHORITY_ISSUER: "https://authority.dev.stella-ops.local"
AUTHORITY_CLIENT_ID: "console-ui"
AUTHORITY_SCOPES: "ui.read ui.admin findings:read advisory:read vex:read aoc:verify"
AUTHORITY_DPOP_ENABLED: "true"
CONSOLE_FEATURE_FLAGS: "runs,downloads,policies"
CONSOLE_METRICS_ENABLED: "true"
```
2. **Render and validate**
```bash
helm template stella-console ./deploy/helm/stellaops -f console-values.yaml | \
grep -A2 'name: stellaops-web-ui' -A6 'image:'
```
3. **Deploy**
```bash
helm upgrade --install stella-console ./deploy/helm/stellaops \
-f deploy/helm/stellaops/values-dev.yaml \
-f console-values.yaml
```
4. **Post-deploy checks**
```bash
kubectl get pods -l app.kubernetes.io/name=stellaops-web-ui
kubectl port-forward deploy/stellaops-web-ui 8443:8443
curl -k https://localhost:8443/health/ready
```
---
## 4·Offline packaging
1. **Mirror the image to an OCI archive**
```bash
DIGEST=$(yq '.services[] | select(.name=="web-ui") | .image' deploy/releases/2025.10-edge.yaml | cut -d@ -f2)
oras copy registry.stella-ops.org/stellaops/web-ui@${DIGEST} \
oci-archive:stellaops-web-ui-2025.10.0.tar
shasum -a 256 stellaops-web-ui-2025.10.0.tar
```
2. **Sign the archive**
```bash
cosign sign-blob --key ~/keys/offline-kit.cosign \
--output-signature stellaops-web-ui-2025.10.0.tar.sig \
stellaops-web-ui-2025.10.0.tar
```
3. **Load in the air-gap**
```bash
docker load --input stellaops-web-ui-2025.10.0.tar
docker tag stellaops/web-ui@${DIGEST} registry.airgap.local/stellaops/web-ui:2025.10.0
```
4. **Update the Offline Kit manifest** (once the downloads pipeline lands):
```bash
jq '.artifacts.console.webUi = {
"digest": "sha256:'"${DIGEST#sha256:}"'",
"archive": "stellaops-web-ui-2025.10.0.tar",
"signature": "stellaops-web-ui-2025.10.0.tar.sig"
}' downloads/manifest.json > downloads/manifest.json.tmp
mv downloads/manifest.json.tmp downloads/manifest.json
```
Re-run `stella offline kit import downloads/manifest.json` to validate signatures inside the airgapped environment.
---
## 5·CLI parity
Console operations map directly to scriptable workflows:
| Action | CLI path |
|--------|----------|
| Fetch signed manifest entry | `stella downloads manifest show --artifact console/web-ui` *(CLI task `CONSOLE-DOC-23-502`, pending release)* |
| Mirror digest to OCI archive | `stella downloads mirror --artifact console/web-ui --to oci-archive:stellaops-web-ui.tar` *(planned alongside CLI AOC parity)* |
| Import offline kit | `stella offline kit import stellaops-web-ui-2025.10.0.tar` |
| Validate console health | `stella console status --endpoint https://console.dev.stella-ops.local` *(planned; fallback to `curl` as shown above)* |
Track progress for the CLI commands via `DOCS-CONSOLE-23-014` (CLI vs UI parity matrix).
---
## 6·Compliance checklist
- [ ] Image digest validated against the current release manifest.
- [ ] Compose/Helm deployments verified with `docker compose config` / `helm template`.
- [ ] Authority issuer, scopes, and DPoP settings documented and applied.
- [ ] Offline archive mirrored, signed, and recorded in the downloads manifest.
- [ ] CLI parity notes linked to the upcoming `docs/cli-vs-ui-parity.md` matrix.
- [ ] References cross-checked with `docs/deploy/console.md` and `docs/security/console-security.md`.
- [ ] Health checks documented for connected and air-gapped installs.
---
## 7·References
- `deploy/releases/<channel>.yaml` Release manifest (digests, SBOM metadata).
- `deploy/compose/README.md` Compose profile overview.
- `deploy/helm/stellaops/values-*.yaml` Helm defaults per environment.
- `/docs/deploy/console.md` Detailed environment variables, CSP, health checks.
- `/docs/security/console-security.md` Auth flows, scopes, DPoP, monitoring.
- `/docs/ui/downloads.md` Downloads manifest workflow and offline parity guidance.
---
*Last updated: 2025-10-28 (Sprint23).*

View File

@@ -0,0 +1,191 @@
# Console Observability
> **Audience:** Observability Guild, Console Guild, SRE/operators.
> **Scope:** Metrics, logs, traces, dashboards, alerting, feature flags, and offline workflows for the StellaOps Console (Sprint23).
> **Prerequisites:** Console deployed with metrics enabled (`CONSOLE_METRICS_ENABLED=true`) and OTLP exporters configured (`OTEL_EXPORTER_OTLP_*`).
---
## 1·Instrumentation Overview
- **Telemetry stack:** OpenTelemetry Web SDK (browser) + Console telemetry bridge → OTLP collector (Tempo/Prometheus/Loki). Server-side endpoints expose `/metrics` (Prometheus) and `/health/*`.
- **Sampling:** Front-end spans sample at 5% by default (`OTEL_TRACES_SAMPLER=parentbased_traceidratio`). Metrics are un-sampled; log sampling is handled per category (§3).
- **Correlation IDs:** Every API call carries `x-stellaops-correlation-id`; structured UI events mirror that value so operators can follow a request across gateway, backend, and UI.
- **Scope gating:** Operators need the `ui.telemetry` scope to view live charts in the Admin workspace; the scope also controls access to `/console/telemetry` SSE streams.
---
## 2·Metrics
### 2.1 Experience & Navigation
| Metric | Type | Labels | Notes |
|--------|------|--------|-------|
| `ui_route_render_seconds` | Histogram | `route`, `tenant`, `device` (`desktop`,`tablet`) | Time between route activation and first interactive paint. Target P95 ≤1.5s (cached). |
| `ui_request_duration_seconds` | Histogram | `service`, `method`, `status`, `tenant` | Gateway proxy timing for backend calls performed by the console. Alerts when backend latency degrades. |
| `ui_filter_apply_total` | Counter | `route`, `filter`, `tenant` | Increments when a global filter or context chip is applied. Used to track adoption of saved views. |
| `ui_tenant_switch_total` | Counter | `fromTenant`, `toTenant`, `trigger` (`picker`, `shortcut`, `link`) | Emitted after a successful tenant switch; correlates with Authority `ui.tenant.switch` logs. |
| `ui_offline_banner_seconds` | Histogram | `reason` (`authority`, `manifest`, `gateway`), `tenant` | Duration of offline banner visibility; integrate with air-gap SLAs. |
### 2.2 Security & Session
| Metric | Type | Labels | Notes |
|--------|------|--------|-------|
| `ui_dpop_failure_total` | Counter | `endpoint`, `reason` (`nonce`, `jkt`, `clockSkew`) | Raised when DPoP validation fails; pair with Authority audit trail. |
| `ui_fresh_auth_prompt_total` | Counter | `action` (`token.revoke`, `policy.activate`, `client.create`), `tenant` | Counts fresh-auth modals; backlog above baseline indicates workflow friction. |
| `ui_fresh_auth_failure_total` | Counter | `action`, `reason` (`timeout`,`cancelled`,`auth_error`) | Optional metric (set `CONSOLE_FRESH_AUTH_METRICS=true` when feature flag lands). |
### 2.3 Downloads & Offline Kit
| Metric | Type | Labels | Notes |
|--------|------|--------|-------|
| `ui_download_manifest_refresh_seconds` | Histogram | `tenant`, `channel` (`edge`,`stable`,`airgap`) | Time to fetch and verify downloads manifest. Target <3s. |
| `ui_download_export_queue_depth` | Gauge | `tenant`, `artifactType` (`sbom`,`policy`,`attestation`,`console`) | Mirrors `/console/downloads` queue depth; triggers when offline bundles lag. |
| `ui_download_command_copied_total` | Counter | `tenant`, `artifactType` | Increments when users copy CLI commands from the UI. Useful to observe CLI parity adoption. |
### 2.4 Telemetry Emission & Errors
| Metric | Type | Labels | Notes |
|--------|------|--------|-------|
| `ui_telemetry_batch_failures_total` | Counter | `transport` (`otlp-http`,`otlp-grpc`), `reason` | Emitted by OTLP bridge when batches fail. Enable via `CONSOLE_METRICS_VERBOSE=true`. |
| `ui_telemetry_queue_depth` | Gauge | `priority` (`normal`,`high`), `tenant` | Browser-side buffer depth; monitor for spikes under degraded collectors. |
> **Scraping tips:**
> - Enable `/metrics` via `CONSOLE_METRICS_ENABLED=true`.
> - Set `OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.collector:4318` and relevant headers (`OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer <token>`).
> - For air-gapped sites, point the exporter to the Offline Kit collector (`localhost:4318`) and forward the metrics snapshot using `stella offline bundle metrics`.
---
## 3·Logs
- **Format:** JSON via Console log bridge; emitted to stdout and optional OTLP log exporter. Core fields: `timestamp`, `level`, `action`, `route`, `tenant`, `subject`, `correlationId`, `dpop.jkt`, `device`, `offlineMode`.
- **Categories:**
- `ui.action` general user interactions (route changes, command palette, filter updates). Sampled 50% by default; override with feature flag `telemetry.logVerbose`.
- `ui.tenant.switch` always logged; includes `fromTenant`, `toTenant`, `tokenId`, and Authority audit correlation.
- `ui.download.commandCopied` download commands copied; includes `artifactId`, `digest`, `manifestVersion`.
- `ui.security.anomaly` DPoP mismatches, tenant header errors, CSP violations (level = `Warning`).
- `ui.telemetry.failure` OTLP export errors; include `httpStatus`, `batchSize`, `retryCount`.
- **PII handling:** Full emails are scrubbed; only hashed values (`user:<sha256>`) appear unless `ui.admin` + fresh-auth were granted for the action (still redacted in logs).
- **Retention:** Recommended 14days for connected sites, 30days for sealed/air-gap audits. Ship logs to Loki/Elastic with ingest label `service="stellaops-web-ui"`.
---
## 4·Traces
- **Span names & attributes:**
- `ui.route.transition` wraps route navigation; attributes: `route`, `tenant`, `renderMillis`, `prefetchHit`.
- `ui.api.fetch` HTTP fetch to backend; attributes: `service`, `endpoint`, `status`, `networkTime`.
- `ui.sse.stream` Server-sent event subscriptions (status ticker, runs); attributes: `channel`, `connectedMillis`, `reconnects`.
- `ui.telemetry.batch` Browser OTLP flush; attributes: `batchSize`, `success`, `retryCount`.
- `ui.policy.action` Policy workspace actions (simulate, approve, activate) per `docs/ui/policy-editor.md`.
- **Propagation:** Spans use W3C `traceparent`; gateway echoes header to backend APIs so traces stitch across UI gateway service.
- **Sampling controls:** `OTEL_TRACES_SAMPLER_ARG` (ratio) and feature flag `telemetry.forceSampling` (sets to 100% for incident debugging).
- **Viewing traces:** Grafana Tempo or Jaeger via collector. Filter by `service.name = stellaops-console`. For cross-service debugging, filter on `correlationId` and `tenant`.
---
## 5·Dashboards
### 5.1 Experience Overview
Panels:
- Route render histogram (P50/P90/P99) by route.
- Backend call latency stacked by service (`ui_request_duration_seconds`).
- Offline banner duration trend (`ui_offline_banner_seconds`).
- Tenant switch volume vs failure rate (overlay `ui_dpop_failure_total`).
- Command palette usage (`ui_filter_apply_total` + `ui.action` log counts).
### 5.2 Downloads & Offline Kit
- Manifest refresh time chart (per channel).
- Export queue depth gauge with alert thresholds.
- CLI command adoption (bar chart per artifact type, using `ui_download_command_copied_total`).
- Offline parity banner occurrences (`downloads.offlineParity` flag from API derived metric).
- Last Offline Kit import timestamp (join with Downloads API metadata).
### 5.3 Security & Session
- Fresh-auth prompt counts vs success/fail ratios.
- DPoP failure stacked by reason.
- Tenant mismatch warnings (from `ui.security.anomaly` logs).
- Scope usage heatmap (derived from Authority audit events + UI logs).
- CSP violation counts (browser `securitypolicyviolation` listener forwarded to logs).
> Capture screenshots for Grafana once dashboards stabilise (`docs/assets/ui/observability/*.png`). Replace placeholders before releasing the doc.
---
## 6·Alerting
| Alert | Condition | Suggested Action |
|-------|-----------|------------------|
| **ConsoleLatencyHigh** | `ui_route_render_seconds_bucket{le="1.5"}` drops below 0.95 for 3 intervals | Inspect route splits, check backend latencies, review CDN cache. |
| **BackendLatencyHigh** | `ui_request_duration_seconds_sum / ui_request_duration_seconds_count` > 1s for any service | Correlate with gateway/service dashboards; escalate to owning guild. |
| **TenantSwitchFailures** | Increase in `ui_dpop_failure_total` or `ui.security.anomaly` (tenant mismatch) > 3/min | Validate Authority issuer, check clock skew, confirm tenant config. |
| **FreshAuthLoop** | `ui_fresh_auth_prompt_total` spikes with matching `ui_fresh_auth_failure_total` | Review Authority `/fresh-auth` endpoint, session timeout config, UX regressions. |
| **OfflineBannerLong** | `ui_offline_banner_seconds` P95 > 120s | Investigate Authority/gateway availability; verify Offline Kit freshness. |
| **DownloadsBacklog** | `ui_download_export_queue_depth` > 5 for 10min OR queue age > alert threshold | Ping Downloads service, ensure manifest pipeline (`DOWNLOADS-CONSOLE-23-001`) is healthy. |
| **TelemetryExportErrors** | `ui_telemetry_batch_failures_total` > 0 for ≥5min | Check collector health, credentials, or TLS trust. |
Integrate alerts with Notifier (`ui.alerts`) or existing Ops channels. Tag incidents with `component=console` for correlation.
---
## 7·Feature Flags & Configuration
| Flag / Env Var | Purpose | Default |
|----------------|---------|---------|
| `CONSOLE_FEATURE_FLAGS` | Enables UI modules (`runs`, `downloads`, `policies`, `telemetry`). Telemetry panel requires `telemetry`. | `runs,downloads,policies` |
| `CONSOLE_METRICS_ENABLED` | Exposes `/metrics` for Prometheus scrape. | `true` |
| `CONSOLE_METRICS_VERBOSE` | Emits additional batching metrics (`ui_telemetry_*`). | `false` |
| `CONSOLE_LOG_LEVEL` | Minimum log level (`Information`, `Debug`). Use `Debug` for incident sampling. | `Information` |
| `CONSOLE_METRICS_SAMPLING` *(planned)* | Controls front-end span sampling ratio. Document once released. | `0.05` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector URL; supports HTTPS. | unset |
| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated headers (auth). | unset |
| `OTEL_EXPORTER_OTLP_INSECURE` | Allow HTTP (dev only). | `false` |
| `OTEL_SERVICE_NAME` | Service tag for traces/logs. Set to `stellaops-console`. | auto |
| `CONSOLE_TELEMETRY_SSE_ENABLED` | Enables `/console/telemetry` SSE feed for dashboards. | `true` |
Feature flag changes should be tracked in release notes and mirrored in `/docs/ui/navigation.md` (shortcuts may change when modules toggle).
---
## 8·Offline / Air-Gapped Workflow
- Mirror the console image and telemetry collector as part of the Offline Kit (see `/docs/install/docker.md` §4).
- Scrape metrics locally via `curl -k https://console.local/metrics > metrics.prom`; archive alongside logs for audits.
- Use `stella offline kit import` to keep the downloads manifest in sync; dashboards display staleness using `ui_download_manifest_refresh_seconds`.
- When collectors are unavailable, console queues OTLP batches (up to 5min) and exposes backlog through `ui_telemetry_queue_depth`; export queue metrics to prove no data loss.
- After reconnecting, run `stella console status --telemetry` *(CLI parity pending; see DOCS-CONSOLE-23-014)* or verify `ui_telemetry_batch_failures_total` resets to zero.
- Retain telemetry bundles for 30days per compliance guidelines; include Grafana JSON exports in audit packages.
---
## 9·Compliance Checklist
- [ ] `/metrics` scraped in staging & production; dashboards display `ui_route_render_seconds`, `ui_request_duration_seconds`, and downloads metrics.
- [ ] OTLP traces/logs confirmed end-to-end (collector, Tempo/Loki).
- [ ] Alert rules from §6 implemented in monitoring stack with runbooks linked.
- [ ] Feature flags documented and change-controlled; telemetry disabled only with approval.
- [ ] DPoP/fresh-auth anomalies correlated with Authority audit logs during drill.
- [ ] Offline capture workflow exercised; evidence stored in audit vault.
- [ ] Screenshots of Grafana dashboards committed once they stabilise (update references).
- [ ] Cross-links verified (`docs/deploy/console.md`, `docs/security/console-security.md`, `docs/ui/downloads.md`, `docs/ui/console-overview.md`).
---
## 10·References
- `/docs/deploy/console.md` Metrics endpoint, OTLP config, health checks.
- `/docs/security/console-security.md` Security metrics & alert hints.
- `/docs/ui/console-overview.md` Telemetry primitives and performance budgets.
- `/docs/ui/downloads.md` Downloads metrics and parity workflow.
- `/docs/observability/observability.md` Platform-wide practices.
- `/ops/telemetry-collector.md` & `/ops/telemetry-storage.md` Collector deployment.
- `/docs/install/docker.md` Compose/Helm environment variables.
---
*Last updated: 2025-10-28 (Sprint23).*

View File

@@ -0,0 +1,152 @@
# Policy Exception Effects
> **Audience:** Policy authors, reviewers, operators, and governance owners.
> **Scope:** How exception definitions are authored, resolved, and surfaced by the Policy Engine during evaluation, including precedence rules, metadata flow, and simulation/diff behaviour.
Exception effects let teams codify governed waivers without compromising determinism. This guide explains the artefacts involved, how the evaluator selects a single winning exception, and where downstream consumers observe the applied override.
---
## 1·Exception Building Blocks
| Artefact | Description |
|----------|-------------|
| **Exception Effect** | Declared inside a policy pack (`exceptions.effects`). Defines the override behaviour plus governance metadata. See effect fields in §2. |
| **Routing Template** | Optional mapping (`exceptions.routingTemplates`) used by Authority to route approvals/MFA. Effects reference templates by id. |
| **Exception Instance** | Stored outside the policy pack (Authority/API). Captures who requested the waiver, scope filters, metadata, and creation time. |
Effects are validated at bind time (`PolicyBinder`), while instances are ingested alongside policy evaluation inputs. Both are normalized to case-insensitive identifiers to avoid duplicate conflicts.
---
## 2·Effect Fields
| Field | Required | Purpose | Notes |
|-------|----------|---------|-------|
| `id` | ✅ | Stable identifier (`[A-Za-z0-9-_]+`). | Must be unique per policy pack. |
| `name` | — | Friendly label for consoles/reports. | Forwarded to verdict metadata if present. |
| `effect` | ✅ | Behaviour enum: `suppress`, `defer`, `downgrade`, `requireControl`. | Case-insensitive. |
| `downgradeSeverity` | ⚠️ | Target severity for `downgrade`. | Must map to DSL severities (`high`, `medium`, etc.). Validation enforced in `PolicyBinder` (`policy.exceptions.effect.downgrade.missingSeverity`). |
| `requiredControlId` | ⚠️ | Control catalogue key for `requireControl`. | Required when effect is `requireControl`. |
| `routingTemplate` | — | Connects to an Authority approval flow. | CLI/Console resolve to `authorityRouteId`. |
| `maxDurationDays` | — | Soft limit for temporary waivers. | Must be > 0 when provided. |
| `description` | — | Rich-text rationale. | Displayed in approvals centre (optional). |
Authoring invalid combinations returns structured errors with JSON paths, preventing packs from compiling (see `src/StellaOps.Policy.Tests/PolicyBinderTests.cs:33`). Routing templates additionally declare `authorityRouteId` and `requireMfa` flags for governance routing.
---
## 3·Exception Instances & Scope
Instances are resolved from Authority or API collections and injected into the evaluation context (`PolicyEvaluationExceptions`). Each instance contains:
| Field | Source | Usage |
|-------|--------|-------|
| `id` | Authority storage | Propagated to annotations and `appliedException.exceptionId`. |
| `effectId` | Links to pack-defined effect | Must resolve to a known effect; otherwise ignored. |
| `scope.ruleNames` | Optional list | Limits to specific rule identifiers. |
| `scope.severities` | Optional list (`severity.normalized`) | Normalized against the evaluators severity string. |
| `scope.sources` | Optional advisory sources (`GHSA`, `NVD`, …) | Compared against the advisory context. |
| `scope.tags` | Optional SBOM tags | Matched using `sbom.has_tag(...)`. |
| `createdAt` | RFC3339 UTC timestamp | Used as tie-breaker when specificity scores match. |
| `metadata` | Arbitrary key/value bag | Copied to verdict annotations (`exception.meta.*`). |
Scopes are case-insensitive and trimmed. Empty scopes behave as global waivers but still require routing and metadata supplied by Authority workflows.
---
## 4·Resolution & Specificity
Only one exception effect is applied per finding. Evaluation proceeds as follows:
1. Filter instances whose `effectId` resolves to a known effect.
2. Discard instances whose scope does not match the candidate finding (rule name, severity, advisory source, SBOM tags).
3. Score remaining instances for **specificity**:
- `ruleNames``1000 + (count × 25)`
- `severities``500 + (count × 10)`
- `sources``250 + (count × 10)`
- `tags``100 + (count × 5)`
4. Highest score wins. Ties fall back to the newest `createdAt`, then lexical `id` (stable sorting).
These rules guarantee deterministic selection even when multiple waivers overlap. See `src/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs:209` for tie-break coverage.
---
## 5·Effect Behaviours
| Effect | Status impact | Severity impact | Warnings / metadata |
|--------|---------------|-----------------|---------------------|
| `suppress` | Forces status `suppressed`. | No change. | `exception.status=suppressed`. |
| `defer` | Forces status `deferred`. | No change. | `exception.status=deferred`. |
| `downgrade` | No change. | Sets severity to configured `downgradeSeverity`. | `exception.severity` annotation. |
| `requireControl` | No change. | No change. | Adds warning `Exception '<id>' requires control '<requiredControlId>'`. Annotation `exception.requiredControl`. |
All effects stamp shared annotations: `exception.id`, `exception.effectId`, `exception.effectType`, optional `exception.effectName`, optional `exception.routingTemplate`, plus `exception.maxDurationDays`. Instance metadata is surfaced both in annotations (`exception.meta.<key>`) and the structured `AppliedException.Metadata` payload for downstream APIs. Behaviour is validated by unit tests (`src/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs:130` & `src/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs:169`).
---
## 6·Explain, Simulation & Outputs
- **Explain traces / CLI simulate** Verdict payloads include `appliedException` capturing original vs applied status/severity, enabling diff visualisation in Console and CLI previews.
- **Annotations** Deterministic keys make it trivial for exports or alerting pipelines to flag waived findings.
- **Warnings** `requireControl` adds runtime warnings so operators can enforce completion of compensating controls.
- **Routing** When `routingTemplate` is populated, verdict metadata includes `routingTemplate`, allowing UI surfaces to deep-link into the approvals centre.
Example verdict excerpt (JSON):
```json
{
"status": "suppressed",
"severity": "Critical",
"annotations": {
"exception.id": "exc-001",
"exception.effectId": "suppress-critical",
"exception.effectType": "Suppress",
"exception.status": "suppressed",
"exception.meta.requestedBy": "alice"
},
"appliedException": {
"exceptionId": "exc-001",
"effectId": "suppress-critical",
"effectType": "Suppress",
"originalStatus": "blocked",
"appliedStatus": "suppressed",
"metadata": {
"effectName": "Rule Critical Suppress",
"requestedBy": "alice"
}
}
}
```
---
## 7·Operational Notes
- **Authoring** Policy packs must ship effect definitions before Authority can issue instances. CLI validation (`stella policy lint`) fails if required fields are missing.
- **Approvals & MFA** Effects referencing routing templates inherit `requireMfa` rules from `exceptions.routingTemplates`. Governance guidance in `/docs/11_GOVERNANCE.md` captures Authority approval flows and audit expectations.
- **Presence in exports** Even when an exception suppresses a finding, explain traces and effective findings retain the applied exception metadata for audit parity.
- **Determinism** Specificity scoring plus tie-breakers ensure repeatable outcomes across runs, supporting sealed/offline replay.
---
## 8·Testing References
- `src/StellaOps.Policy.Tests/PolicyBinderTests.cs:33` Validates schema rules for defining effects, routing templates, and downgrade guardrails.
- `src/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs:130` Covers suppression, downgrade, and metadata propagation.
- `src/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs:209` Confirms specificity ordering and metadata forwarding for competing exceptions.
---
## 9·Compliance Checklist
- [ ] **Effect catalogue maintained:** Each policy pack documents available effects and routing templates for auditors.
- [ ] **Authority alignment:** Approval routes in Authority mirror `routingTemplate` definitions and enforce MFA where required.
- [ ] **Explain coverage:** Console/CLI surfaces display `appliedException` details and `exception.*` annotations for every waived verdict.
- [ ] **Simulation parity:** `stella policy simulate` outputs include exception metadata, ensuring PR/CI reviews catch unintended waivers.
- [ ] **Audit retention:** Effective findings history retains `appliedException` payloads so exception lifecycle reviews remain replayable.
- [ ] **Tests locked:** Binder and evaluator tests covering exception paths remain green before publishing documentation updates.
---
*Last updated: 2025-10-27 (Sprint 25).*

View File

@@ -11,28 +11,29 @@ Authority issues short-lived tokens bound to tenants and scopes. Sprint19 int
| Scope | Surface | Purpose | Notes |
|-------|---------|---------|-------|
| `advisory:write` | Concelier ingestion APIs | Allows append-only writes to `advisory_raw`. | Granted to Concelier WebService and trusted connectors. Requires tenant claim. |
| `advisory:verify` | Concelier `/aoc/verify`, CLI, UI dashboard | Permits guard verification and access to violation summaries. | Read-only; used by `stella aoc verify` and console dashboard. |
| `vex:write` | Excititor ingestion APIs | Append-only writes to `vex_raw`. | Mirrors `advisory:write`. |
| `vex:verify` | Excititor `/aoc/verify`, CLI | Read-only verification of VEX ingestion. | Optional for environments without VEX feeds. |
| `graph:write` | Cartographer build pipeline | Enqueue graph build/overlay jobs. | Reserved for the Cartographer service identity; requires tenant claim. |
| `graph:read` | Graph API, Scheduler overlays, UI | Read graph projections/overlays. | Requires tenant claim; granted to Cartographer, Graph API, Scheduler. |
| `advisory:ingest` | Concelier ingestion APIs | Append-only writes to `advisory_raw` collections. | Requires tenant claim; blocked for global clients. |
| `advisory:read` | `/aoc/verify`, Concelier dashboards, CLI | Read-only access to stored advisories and guard results. | Needed alongside `aoc:verify` for CLI/console verification. |
| `vex:ingest` | Excititor ingestion APIs | Append-only writes to `vex_raw`. | Mirrors `advisory:ingest`; tenant required. |
| `vex:read` | `/aoc/verify`, Excititor dashboards, CLI | Read-only access to stored VEX material. | Pair with `aoc:verify` for guard checks. |
| `aoc:verify` | CLI/CI pipelines, Console verification jobs | Execute Aggregation-Only Contract guard runs. | Always issued with tenant; read-only combined with `advisory:read`/`vex:read`. |
| `graph:write` | Cartographer pipeline | Enqueue graph build/overlay jobs. | Reserved for Cartographer service identity; tenant required. |
| `graph:read` | Graph API, Scheduler overlays, UI | Read graph projections/overlays. | Tenant required; granted to Cartographer, Graph API, Scheduler. |
| `graph:export` | Graph export endpoints | Stream GraphML/JSONL artefacts. | UI/gateway automation only; tenant required. |
| `graph:simulate` | Policy simulation overlays | Trigger what-if overlays on graphs. | Restricted to automation; tenant required. |
| `effective:write` | Policy Engine | Allows creation/update of `effective_finding_*` collections. | **Only** the Policy Engine service client may hold this scope. |
| `effective:read` | Console, CLI, exports | Read derived findings. | Shared across tenants with role-based restrictions. |
| `aoc:dashboard` | Console UI | Access AOC dashboard resources. | Bundles `advisory:verify`/`vex:verify` by default; keep for UI RBAC group mapping. |
| `aoc:verify` | Automation service accounts | Execute verification via API without the full dashboard role. | For CI pipelines, offline kit validators. |
| Existing scopes | (e.g., `policy:*`, `sbom:*`) | Unchanged. | Review `/docs/security/policy-governance.md` for policy-specific scopes. |
| `effective:write` | Policy Engine | Create/update `effective_finding_*` collections. | **Only** the Policy Engine service client may hold this scope; tenant required. |
| `findings:read` | Console, CLI, exports | Read derived findings materialised by Policy Engine. | Shared across tenants with RBAC; tenant claim still enforced. |
| `vuln:read` | Vuln Explorer API/UI | Read normalized vulnerability data. | Tenant required. |
| Existing scopes | (e.g., `policy:*`, `concelier.jobs.trigger`) | Unchanged. | Review `/docs/security/policy-governance.md` for policy-specific scopes. |
### 1.1Scope bundles (roles)
- **`role/concelier-ingest`** → `advisory:write`, `advisory:verify`.
- **`role/excititor-ingest`** → `vex:write`, `vex:verify`.
- **`role/aoc-operator`** → `aoc:dashboard`, `aoc:verify`, `advisory:verify`, `vex:verify`.
- **`role/policy-engine`** → `effective:write`, `effective:read`.
- **`role/concelier-ingest`** → `advisory:ingest`, `advisory:read`.
- **`role/excititor-ingest`** → `vex:ingest`, `vex:read`.
- **`role/aoc-operator`** → `aoc:verify`, `advisory:read`, `vex:read`.
- **`role/policy-engine`** → `effective:write`, `findings:read`.
- **`role/cartographer-service`** → `graph:write`, `graph:read`.
- **`role/graph-gateway`** → `graph:read`, `graph:export`, `graph:simulate`.
- **`role/console`** → `advisory:read`, `vex:read`, `aoc:verify`, `findings:read`, `vuln:read`.
Roles are declared per tenant in `authority.yaml`:
@@ -41,11 +42,11 @@ tenants:
- name: default
roles:
concelier-ingest:
scopes: [advisory:write, advisory:verify]
scopes: [advisory:ingest, advisory:read]
aoc-operator:
scopes: [aoc:dashboard, aoc:verify, advisory:verify, vex:verify]
scopes: [aoc:verify, advisory:read, vex:read]
policy-engine:
scopes: [effective:write, effective:read]
scopes: [effective:write, findings:read]
```
---
@@ -62,10 +63,10 @@ Tokens now include:
Authority rejects requests when:
- `tenant` is missing while requesting `advisory:*`, `vex:*`, or `aoc:*` scopes.
- `tenant` is missing while requesting `advisory:ingest`, `advisory:read`, `vex:ingest`, `vex:read`, or `aoc:verify` scopes.
- `service_identity != policy-engine` but `effective:write` is present (`ERR_AOC_006` enforcement).
- `service_identity != cartographer` but `graph:write` is present (graph pipeline enforcement).
- Tokens attempt to combine `advisory:write` with `effective:write` (separation of duties).
- Tokens attempt to combine `advisory:ingest` with `effective:write` (separation of duties).
### 2.2Propagation
@@ -90,22 +91,30 @@ Add new scopes and optional claims transformations:
```yaml
security:
scopes:
- name: advisory:write
description: Concelier raw ingestion
- name: advisory:verify
description: Verify Concelier ingestion
- name: vex:write
- name: advisory:ingest
description: Concelier raw ingestion (append-only)
- name: advisory:read
description: Read Concelier advisories and guard verdicts
- name: vex:ingest
description: Excititor raw ingestion
- name: vex:verify
description: Verify Excititor ingestion
- name: aoc:dashboard
description: Access AOC UI dashboards
- name: vex:read
description: Read Excititor VEX records
- name: aoc:verify
description: Run AOC verification
- name: effective:write
description: Policy Engine materialisation
- name: effective:read
- name: findings:read
description: Read derived findings
- name: graph:write
description: Cartographer build submissions
- name: graph:read
description: Read graph overlays
- name: graph:export
description: Export graph artefacts
- name: graph:simulate
description: Run graph what-if simulations
- name: vuln:read
description: Read Vuln Explorer data
claimTransforms:
- match: { scope: "effective:write" }
require:
@@ -119,13 +128,13 @@ security:
Update service clients:
- `Concelier.WebService` → request `advisory:write`, `advisory:verify`.
- `Excititor.WebService` → request `vex:write`, `vex:verify`.
- `Policy.Engine` → request `effective:write`, `effective:read`; set `properties.serviceIdentity=policy-engine`.
- `Concelier.WebService` → request `advisory:ingest`, `advisory:read`.
- `Excititor.WebService` → request `vex:ingest`, `vex:read`.
- `Policy.Engine` → request `effective:write`, `findings:read`; set `properties.serviceIdentity=policy-engine`.
- `Cartographer.Service` → request `graph:write`, `graph:read`; set `properties.serviceIdentity=cartographer`.
- `Graph API Gateway` → request `graph:read`, `graph:export`, `graph:simulate`; tenant hint required.
- `Console` → request `aoc:dashboard`, `effective:read` plus existing UI scopes.
- `CLI automation` → request `aoc:verify`, `advisory:verify`, `vex:verify` as needed.
- `Console` → request `advisory:read`, `vex:read`, `aoc:verify`, `findings:read`, `vuln:read` plus existing UI scopes.
- `CLI automation` → request `aoc:verify`, `advisory:read`, `vex:read` as needed.
Client definition snippet:
@@ -133,11 +142,11 @@ Client definition snippet:
clients:
- clientId: concelier-web
grantTypes: [client_credentials]
scopes: [advisory:write, advisory:verify]
scopes: [advisory:ingest, advisory:read]
tenants: [default]
- clientId: policy-engine
grantTypes: [client_credentials]
scopes: [effective:write, effective:read]
scopes: [effective:write, findings:read]
properties:
serviceIdentity: policy-engine
- clientId: cartographer-service
@@ -152,7 +161,7 @@ clients:
## 4·Operational safeguards
- **Audit events:** Authority emits `authority.scope.granted` and `authority.scope.revoked` events with `scope` and `tenant`. Monitor for unexpected grants.
- **Rate limiting:** Apply stricter limits on `/token` endpoints for clients requesting `advisory:write` or `vex:write` to mitigate brute-force ingestion attempts.
- **Rate limiting:** Apply stricter limits on `/token` endpoints for clients requesting `advisory:ingest` or `vex:ingest` to mitigate brute-force ingestion attempts.
- **Incident response:** Link AOC alerts to Authority audit logs to confirm whether violations come from expected identities.
- **Rotation:** Rotate ingest client secrets alongside guard deployments; add rotation steps to `ops/authority-key-rotation.md`.
- **Testing:** Integration tests must fail if tokens lacking `tenant` attempt ingestion; add coverage in Concelier/Excititor smoke suites (see `CONCELIER-CORE-AOC-19-013`).
@@ -161,7 +170,7 @@ clients:
## 5·Offline & air-gap notes
- Offline Kit bundles include tenant-scoped service credentials. Ensure ingest bundles ship without `advisory:write` scopes unless strictly required.
- Offline Kit bundles include tenant-scoped service credentials. Ensure ingest bundles ship without `advisory:ingest` scopes unless strictly required.
- CLI verification in offline environments uses pre-issued `aoc:verify` tokens; document expiration and renewal processes.
- Authority replicas in air-gapped environments should restrict scope issuance to known tenants and log all `/token` interactions for later replay.
@@ -191,4 +200,4 @@ clients:
---
*Last updated: 2025-10-26 (Sprint19).*
*Last updated: 2025-10-27 (Sprint19).*

View File

@@ -0,0 +1,162 @@
# StellaOps Console Security Posture
> **Audience:** Security Guild, Console & Authority teams, deployment engineers.
> **Scope:** OIDC/DPoP flows, scope model, session controls, CSP and transport headers, evidence handling, offline posture, and monitoring expectations for the StellaOps Console (Sprint23).
The console is an Angular SPA fronted by the StellaOps Web gateway. It consumes Authority for identity, Concelier/Excititor for aggregation data, Policy Engine for findings, and Attestor for evidence bundles. This guide captures the security guarantees and required hardening so that the console can ship alongside the Aggregation-Only Contract (AOC) without introducing new attack surface.
---
## 1·Identity & Authentication
### 1.1 Authorization sequence
1. Browser→Authority uses **OAuth 2.1 Authorization Code + PKCE** (`S256`).
2. Upon code exchange the console requests a **DPoP-bound access token** (`aud=console`, `tenant=<id>`) with **120s TTL** and optional **rotating refresh token** (`rotate=true`).
3. Authority includes `cnf.jkt` for the ephemeral WebCrypto keypair; console stores the private key in **IndexedDB** (non-exportable) and keeps the public JWK in memory.
4. All API calls attach `Authorization: Bearer <token>` + `DPoP` proof header. Nonces from the gateway are replay-protected (`dpopt-nonce` header).
5. Tenanted API calls flow through the Web gateway which forwards `X-Stella-Tenant` and enforces tenancy headers. Missing or mismatched tenants trigger `403` with `ERR_TENANT_MISMATCH`.
### 1.2 Fresh-auth gating
- Sensitive actions (tenant edits, token revocation, policy promote, signing key rotation) call `Authority /fresh-auth` using `prompt=login` + `max_age=300`.
- Successful fresh-auth yields a **300s** scoped token (`fresh_auth=true`) stored only in memory; the UI disables guarded buttons when the timer expires.
- Audit events: `authority.fresh_auth.start`, `authority.fresh_auth.success`, `authority.fresh_auth.expired` (link to correlation IDs for the gated action).
### 1.3 Offline & sealed mode
- When `console.offlineMode=true` the console presents an offline banner and suppresses fresh-auth prompts, replacing them with CLI guidance (`stella auth fresh-auth --offline`).
- Offline mode requires pre-issued tenant-scoped tokens bundled with the Offline Kit; tokens must include `offline=true` claim and 15m TTL.
- Authority availability health is polled via `/api/console/status`. HTTP failures raise the offline banner and switch to read-only behaviour.
---
## 2·Session & Device Binding
- Access and refresh tokens live in memory; metadata (subject, tenant, expiry) persists in `sessionStorage` for reload continuity. **Never** store raw JWTs in `localStorage`.
- Inactivity timeout defaults to **15minutes**. Idle sessions trigger silent refresh; on failure the UI shows a modal requiring re-auth.
- Tokens are device-bound through DPoP; if a new device logs in, Authority revokes the previous DPoP key and emits `authority.token.binding_changed`.
- CSRF mitigations: bearer tokens plus DPoP remove cookie reliance. If cookies are required (e.g., same-origin analytics) they must be `HttpOnly`, `SameSite=Lax`, `Secure`.
- Browser hardening: enforce `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
---
## 3·Authorization & Scope Model
The console client is registered in Authority as `console-ui` with scopes:
| Feature area | Required scopes | Notes |
|--------------|----------------|-------|
| Base navigation (Dashboard, Findings, SBOM, Runs) | `ui.read`, `findings:read`, `advisory:read`, `vex:read`, `aoc:verify` | `findings:read` enables Policy Engine overlays; `advisory:read`/`vex:read` load ingestion panes; `aoc:verify` allows on-demand guard runs. |
| Admin workspace | `ui.admin`, `authority:tenants.read`, `authority:tenants.write`, `authority:roles.read`, `authority:roles.write`, `authority:tokens.read`, `authority:tokens.revoke`, `authority:clients.read`, `authority:clients.write`, `authority:audit.read` | Scope combinations are tenant constrained. Role changes require fresh-auth. |
| Policy approvals | `policy:read`, `policy:review`, `policy:approve`, `policy:activate`, `policy:runs` | `policy:activate` gated behind fresh-auth. |
| Observability panes (status ticker, telemetry) | `ui.telemetry`, `scheduler:runs.read`, `advisory:read`, `vex:read` | `ui.telemetry` drives OTLP export toggles. |
| Downloads parity (SBOM, attestation) | `downloads:read`, `attestation:verify`, `sbom:export` | Console surfaces digests only; download links require CLI parity for write operations. |
Guidance:
- **Role mapping**: Provision Authority role `role/ui-console-admin` encapsulating the admin scopes above.
- **Tenant enforcement**: Gateway injects `X-Stella-Tenant` from token claims. Requests missing the header must be rejected by downstream services (Concelier, Excititor, Policy Engine) and logged.
- **Separation of duties**: Never grant `ui.admin` and `policy:approve` to the same human role without SOC sign-off; automation accounts should use least-privilege dedicated clients.
---
## 4·Transport, CSP & Browser Hardening
### 4.1 Gateway requirements
- TLS 1.2+ with modern cipher suites; enable HTTP/2 for SSE streams.
- Terminate TLS at the reverse proxy (Traefik, NGINX) and forward `X-Forwarded-*` headers (`ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`).
- Rate-limit `/authorize` and `/token` according to [Authority rate-limit guidance](rate-limits.md).
### 4.2 Content Security Policy
Default CSP served by the console container:
```
default-src 'self';
connect-src 'self' https://*.stella-ops.local;
img-src 'self' data:;
script-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self';
frame-ancestors 'none';
```
Recommendations:
- Extend `connect-src` only for known internal APIs (e.g., telemetry collector). Use `console.config.cspOverrides` instead of editing NGINX directly.
- Enable **COOP/COEP** (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`) to support WASM policy previews.
- Use **Subresource Integrity (SRI)** hashes when adding third-party fonts or scripts.
- For embedded screenshots/GIFs sourced from Offline Kit, use `img-src 'self' data: blob:` and verify assets during build.
- Enforce `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, and `Cache-Control: no-store` on JSON API responses (HTML assets remain cacheable).
### 4.3 SSE & WebSocket hygiene
- SSE endpoints (`/console/status/stream`, `/console/runs/{id}/events`) must set `Cache-Control: no-store` and disable proxy buffering.
- Gate SSE behind the same DPoP tokens; reject without `Authorization`.
- Proxy timeouts ≥60s to avoid disconnect storms; clients use exponential backoff with jitter.
---
## 5·Evidence & Data Handling
- **Evidence bundles**: Download links trigger `attestor.verify` or `downloads.manifest` APIs. The UI never caches bundle contents; it only surfaces SHA-256 digests and cosign signatures. Operators must use CLI to fetch the signed artefact.
- **Secrets**: UI redacts tokens, emails, and attachment paths in logs. Structured logs include only `subject`, `tenant`, `action`, `correlationId`.
- **Aggregation data**: Console honours Aggregation-Only contract—no client-side rewriting of Concelier/Excititor precedence. Provenance badges display source IDs and merge-event hashes.
- **PII minimisation**: User lists show minimal identity (display name, email hash). Full email addresses require `ui.admin` + fresh-auth.
- **Downloads parity**: Every downloadable artefact includes a CLI parity link (e.g., `stella downloads fetch --artifact <id>`). If CLI parity fails, the console displays a warning banner and links to troubleshooting docs.
---
## 6·Logging, Monitoring & Alerts
- Structured logs: `ui.action`, `tenantId`, `subject`, `scope`, `correlationId`, `dpop.jkt`. Log level `Information` for key actions; `Warning` for security anomalies (failed DPoP, tenant mismatch).
- Metrics (Prometheus): `ui_request_duration_seconds`, `ui_dpop_failure_total`, `ui_fresh_auth_prompt_total`, `ui_tenant_switch_total`, `ui_offline_banner_seconds`.
- Alerts:
1. **Fresh-auth failures** >5 per minute per tenant → security review.
2. **DPoP mismatches** sustained >1% of requests → potential replay attempt.
3. **Tenant mismatches** >0 triggers an audit incident (could indicate scope misconfiguration).
- Correlate with Authority audit events (`authority.scope.granted`, `authority.token.revoked`) and Concelier/Excititor ingestion logs to trace user impact.
---
## 7·Offline & Air-Gapped Posture
- Offline deployments require mirrored container images and Offline Kit manifest verification (see `/docs/deploy/console.md` §7).
- Console reads `offlineManifest.json` at boot to validate asset digests; mismatches block startup until the manifest is refreshed.
- Tenant and role edits queue change manifests for export; UI instructs operators to run `stella auth apply --bundle <file>` on the offline Authority host.
Evidence viewing remains read-only; download buttons provide scripts to export from local Attestor snapshots.
- Fresh-auth prompts display instructions for hardware-token usage on bastion hosts; system logs mark actions executed under offline fallback.
---
## 8·Threat Model Alignment
| Threat (Authority TM §5) | Console control |
|--------------------------|-----------------|
| Spoofed revocation bundle | Console verifies manifest signatures before showing revocation status; links to `stella auth revoke verify`. |
| Parameter tampering on `/token` | PKCE + DPoP enforced; console propagates correlation IDs so Authority logs can link anomalies. |
| Bootstrap invite replay | Admin UI surfaces invite status with expiry; fresh-auth required before issuing new invites. |
| Token replay by stolen agent | DPoP binding prevents reuse; console surfaces revocation latency warnings sourced from Zastava metrics. |
| Offline bundle tampering | Console refuses unsigned Offline Kit assets; prompts operators to re-import verified bundles. |
| Privilege escalation via plug-in overrides | Plug-in manifest viewer warns when a plug-in downgrades password policy; UI restricts plug-in activation to fresh-auth + `ui.admin` scoped users. |
Document gaps and remediation hooks in `SEC5.*` backlog as they are addressed.
---
## 9·Compliance checklist
- [ ] Authority client `console-ui` registered with PKCE, DPoP, tenant claim requirement, and scopes from §3.
- [ ] CSP enforced per §4 with overrides documented in deployment manifests.
- [ ] Fresh-auth timer (300s) validated for admin and policy actions; audit events captured.
- [ ] DPoP binding tested (replay attempt blocked; logs show `ui_dpop_failure_total` increment).
- [ ] Offline mode exercises performed (banner, CLI guidance, manifest verification).
- [ ] Evidence download parity verified with CLI scripts; console never caches sensitive artefacts.
- [ ] Monitoring dashboards show metrics and alerts outlined in §6; alert runbooks reviewed with Security Guild.
- [ ] Security review sign-off recorded in sprint log with links to Authority threat model references.
---
*Last updated: 2025-10-28 (Sprint23).*

View File

@@ -12,9 +12,10 @@ The Console AOC dashboard gives operators a live view of ingestion guardrails ac
- **Route:** `/console/sources` (dashboard) with contextual drawer routes `/console/sources/:sourceKey` and `/console/sources/:sourceKey/violations/:documentId`.
- **Feature flag:** `aocDashboard.enabled` (default `true` once Concelier WebService exposes `/aoc/verify`). Toggle is tenant-scoped to support phased rollout.
- **Scopes:**
- `ui.read` (base navigation) and `advisory:verify` to view ingestion stats/violations.
- `vex:verify` to see Excititor entries and run VEX verifications.
- `advisory:write` / `vex:write` **not** required; dashboard uses read-only APIs.
- `ui.read` (base navigation) plus `advisory:read` to view Concelier ingestion metrics/violations.
- `vex:read` to see Excititor entries and run VEX verifications.
- `aoc:verify` to trigger guard runs from the dashboard action bar.
- `advisory:ingest` / `vex:ingest` **not** required; the dashboard uses read-only APIs.
- **Tenancy:** All data is filtered by the active tenant selector. Switching tenants re-fetches tiles and drill-down tables with tenant-scoped tokens.
- **Back-end contracts:** Requires Concelier/Excititor 19.x (AOC guards enabled) and Authority scopes updated per [Authority service docs](../ARCHITECTURE_AUTHORITY.md#new-aoc-scopes).

View File

@@ -190,7 +190,7 @@ Telemetry entries include correlation IDs that match backend manifest refresh lo
- `/docs/ui/sbom-explorer.md` - export flows feeding the downloads queue.
- `/docs/ui/runs.md` - evidence bundle integration.
- `/docs/24_OFFLINE_KIT.md` - offline kit packaging and verification.
- `/docs/security/console-security.md` - scopes, CSP, and download token handling (pending).
- `/docs/security/console-security.md` - scopes, CSP, and download token handling.
- `/docs/cli-vs-ui-parity.md` - CLI equivalence checks (pending).
- `deploy/releases/*.yaml` - source of container digests mirrored into the manifest.

View File

@@ -0,0 +1,26 @@
# Docs Guild Update — 2025-10-28
## Console security posture draft
- Published `docs/security/console-security.md` covering console OIDC/DPoP flow, scope map, fresh-auth sequence, CSP defaults, evidence handling, and monitoring checklist.
- Authority owners (`AUTH-CONSOLE-23-003`) to verify `/fresh-auth` token semantics (120s OpTok, 300s fresh-auth window) and confirm scope bundles before closing the sprint task.
- Security Guild requested to execute the compliance checklist in §9 and record sign-off in SPRINT 23 log once alerts/dashboards are wired (metrics references: `ui_request_duration_seconds`, `ui_dpop_failure_total`, Grafana board `console-security.json`).
## Console CLI parity matrix
- Added `/docs/cli-vs-ui-parity.md` with feature-level status tracking (✅/🟡/🟩). Pending commands reference CLI backlog (`CLI-EXPORT-35-001`, `CLI-POLICY-23-005`, `CONSOLE-DOC-23-502`).
- DevEx/CLI Guild to wire parity CI workflow when CLI downloads commands ship; Downloads workspace already links to the forthcoming parity report slot.
## Accessibility refresh
- Published `/docs/accessibility.md` describing keyboard flows, screen-reader behaviour, colour tokens, testing rig (Storybook axe, Playwright a11y), and offline guidance.
- Accessibility Guild (CONSOLE-QA-23-402) to log the next Playwright a11y sweep results against the new checklist; design tokens follow-up tracked via CONSOLE-FEAT-23-102.
Artifacts:
- Doc: `/docs/security/console-security.md`
- Doc: `/docs/cli-vs-ui-parity.md`
- Doc: `/docs/accessibility.md`
- Sprint tracker: `SPRINTS.md` (DOCS-CONSOLE-23-012 now DONE)
cc: `@authority-core`, `@security-guild`, `@docs-guild`

229
docs/vex/aggregation.md Normal file
View File

@@ -0,0 +1,229 @@
# VEX Observations & Linksets
> Imposed rule: Work of this type or tasks of this type on this component must
> also be applied everywhere else it should be applied.
Link-Not-Merge brings the same immutable observation model to Excititor that
Concelier now uses for advisories. VEX statements are stored as append-only
observations; linksets correlate them, capture conflicts, and keep provenance so
Policy Engine and UI surfaces can explain decisions without collapsing sources.
---
## 1. Model overview
### 1.1 Observation lifecycle
1. **Ingest** Connectors fetch OpenVEX, CSAF VEX, CycloneDX VEX, or VEX
attestations, validate signatures, and strip any derived consensus data
forbidden by the Aggregation-Only Contract (AOC).
2. **Persist** Excititor writes immutable `vex_observations` keyed by tenant,
provider, upstream identifier, and `contentHash`. Supersedes chains record
revisions; the original payload is never mutated.
3. **Expose** WebService will surface paginated observation APIs and Offline
Kit snapshots mirror the same data for air-gapped sites.
Observation schema sketch (final shape lands with `EXCITITOR-LNM-21-001`):
```text
observationId = {tenant}:{providerId}:{upstreamId}:{revision}
tenant, providerId, streamId
upstream{ upstreamId, documentVersion, fetchedAt, receivedAt,
contentHash, signature{present, format?, keyId?, signature?} }
content{ format, specVersion, raw }
statements[
{ vulnerabilityId, productKey, status, justification?,
introducedVersion?, fixedVersion?, locator }
]
linkset{ purls[], cpes[], aliases[], references[],
reconciledFrom[], conflicts[]? }
attributes{ batchId?, replayCursor? }
createdAt
```
- **Raw payload** (`content.raw`) remains lossless (Relaxed Extended JSON).
- **Statements** provide normalized tuples for each claim contained in the
document, including justification and version hints.
- **Linkset** mirrors identifiers extracted during ingestion, retaining JSON
pointer metadata so audits can trace back to the source fragment.
### 1.2 Linkset lifecycle
Linksets correlate claims referring to the same `(vulnerabilityId, productKey)`
pair across providers.
1. **Seed** Observations push normalized identifiers (CVE, GHSA, vendor IDs)
plus canonical product keys (purl preferred, cpe fallback). Platform-scoped
statements remain marked `non_joinable`.
2. **Correlate** The linkset builder groups statements by tenant and identity,
combines alias graphs from Concelier, and uses justification/product overlap
to assign correlation confidence.
3. **Annotate** Conflicts (status disagreement, justification mismatch, range
inconsistencies) are recorded as structured entries.
4. **Persist** Results land in `vex_linksets` with deterministic IDs (hash of
sorted `(vulnerabilityId, productKey, observationIds)`) and append-only
history for replay/debugging.
Linksets never override statements or invent consensus; they simply align
evidence for Policy Engine and consumers.
---
## 2. Observation vs. linkset
- **Purpose**
- Observation: Immutable record of a single upstream VEX document.
- Linkset: Correlated evidence spanning observations that describe the same
product-vulnerability pair.
- **Mutation**
- Observation: Append-only via supersedes.
- Linkset: Regenerated deterministically by correlation jobs.
- **Allowed fields**
- Observation: Raw payload, provenance, normalized statement tuples, join
hints.
- Linkset: Observation references, statement IDs, confidence metrics, conflict
annotations.
- **Forbidden fields**
- Observation: Derived consensus, suppression flags, risk scores.
- Linkset: Derived severity or policy decisions (only evidence + conflicts).
- **Consumers**
- Observation: Evidence exports, Offline Kit mirrors, CLI raw dumps.
- Linkset: Policy Engine VEX overlay, Console evidence panes, Vuln Explorer.
### 2.1 Example sequence
1. Canonical vendor issues an attested OpenVEX declaring `CVE-2025-2222` as
`not_affected` for `pkg:rpm/redhat/openssl@1.1.1w-12`. Excititor inserts a
new observation referencing that statement.
2. Upstream CycloneDX VEX from a distro reports the same product as `affected`
with `under_investigation` justification.
3. Linkset builder groups both statements by alias overlap and product key,
setting confidence `high` because CVE and purl match.
4. Conflict annotation records `status-mismatch` and retains both justifications;
Policy Engine uses this to explain why suppression cannot proceed without
policy override.
---
## 3. Conflict handling
Structured conflicts capture disagreements without mutating source statements.
```json
{
"type": "status-mismatch",
"vulnerabilityId": "CVE-2025-2222",
"productKey": "pkg:rpm/redhat/openssl@1.1.1w-12",
"statements": [
{
"observationId": "tenant:redhat:openvex:3",
"providerId": "redhat",
"status": "not_affected",
"justification": "component_not_present"
},
{
"observationId": "tenant:ubuntu:cyclonedx:12",
"providerId": "ubuntu",
"status": "affected",
"justification": "under_investigation"
}
],
"confidence": "medium",
"detectedAt": "2025-10-27T14:30:00Z"
}
```
Conflict classes (tracked via `EXCITITOR-LNM-21-003`):
- `status-mismatch` Different statuses for the same pair (affected vs
not_affected vs fixed vs under_investigation).
- `justification-divergence` Same status but incompatible justifications or
missing justification where policy requires it.
- `version-range-clash` Introduced/fixed ranges contradict each other.
- `non-joinable-overlap` Platform-scoped statements collide with package
statements; flagged as warning but retained.
- `metadata-gap` Missing provenance/signature field on specific statements.
Conflicts surface through:
- `/vex/linksets/{id}` APIs (`conflicts[]` payload).
- Console evidence panels (badges + drawer detail).
- CLI exports (`stella vex linkset …` planned in `CLI-LNM-22-002`).
- Metrics dashboards (`vex_linkset_conflicts_total{type}`).
---
## 4. AOC alignment
- **Raw-first** `content.raw` and `statements[]` mirror upstream input; no
derived consensus or suppression values are written by ingestion.
- **No merges** Each upstream statement persists independently; linksets refer
back via `observationId`.
- **Provenance mandatory** Missing signature or source metadata yields
`ERR_AOC_004`; ingestion blocks until connectors fix the feed.
- **Idempotent writes** Duplicate `(providerId, upstreamId, contentHash)`
results in a no-op; revisions append with a `supersedes` pointer.
- **Deterministic output** Correlator sorts identifiers, normalizes timestamps
(UTC ISO-8601), and hashes canonical JSON to generate stable linkset IDs.
- **Scope-aware** Tenant claims enforced on write/read; Authority scopes
`vex:ingest` / `vex:read` are required (see `AUTH-AOC-22-001`).
Violations raise `ERR_AOC_00x`, emit `aoc_violation_total`, and prevent the data
from landing downstream.
---
## 5. Downstream consumption
- **Policy Engine** Evaluates VEX evidence alongside advisory linksets to gate
suppression, severity downgrades, or explainability.
- **Console UI** Evidence panel renders VEX statements grouped by provider and
highlights conflicts or missing signatures.
- **CLI** Planned commands export observations/linksets for offline analysis
(`CLI-LNM-22-002`).
- **Offline Kit** Bundled snapshots keep VEX data aligned with advisory
observations for air-gapped parity.
- **Observability** Dashboards track ingestion latency, conflict counts, and
supersedes depth per provider.
New consumers must treat both collections as read-only and preserve deterministic
ordering when caching.
---
## 6. Validation & testing
- **Unit tests** (`StellaOps.Excititor.Core.Tests`) to cover schema guards,
deterministic linkset hashing, conflict classification, and supersedes
behaviour.
- **Mongo integration tests** (`StellaOps.Excititor.Storage.Mongo.Tests`) to
verify indexes, shard keys, and idempotent writes across tenants.
- **CLI smoke suites** (`stella vex observations`, `stella vex linksets`) for
JSON determinism and exit code coverage.
- **Replay determinism** Feed identical upstream payloads twice and ensure
observation/linkset hashes match across runs.
- **Offline kit verification** Validate VEX exports packaged in Offline Kit
snapshots against live service outputs.
- **Fixture refresh** Samples (`SAMPLES-LNM-22-002`) must include multi-source
conflicts and justification variants used by docs and UI tests.
---
## 7. Reviewer checklist
- Observation schema aligns with `EXCITITOR-LNM-21-001` once the schema lands;
update references as soon as the final contract is published.
- Linkset lifecycle covers correlation signals (alias graphs, product keys,
justification rules) and deterministic ID strategy.
- Conflict classes include status, justification, version range, platform overlap
scenarios.
- AOC guardrails called out with relevant error codes and Authority scopes.
- Downstream consumer list matches active APIs/CLI features (update when
`CLI-LNM-22-002` and WebService endpoints ship).
- Validation section references Core, Storage, CLI, and Offline test suites plus
fixture requirements.
- Imposed rule reminder retained at top.
Dependencies outstanding (2025-10-27): `EXCITITOR-LNM-21-001..005` and
`EXCITITOR-LNM-21-101..102` are still TODO; revisit this document once schemas,
APIs, and fixtures are implemented.

View File

@@ -129,7 +129,7 @@ clients:
displayName: "AOC Verification Agent"
grantTypes: [ "client_credentials" ]
audiences: [ "api://concelier", "api://excitor" ]
scopes: [ "aoc:verify" ]
scopes: [ "aoc:verify", "advisory:read", "vex:read" ]
tenant: "tenant-default"
senderConstraint: "dpop"
auth:

View File

@@ -61,7 +61,8 @@
|----|--------|----------|------------|-------------|---------------|
| DEVOPS-POLICY-20-001 | DONE (2025-10-26) | DevOps Guild, Policy Guild | POLICY-ENGINE-20-001 | Integrate DSL linting in CI (parser/compile) to block invalid policies; add pipeline step compiling sample policies. | CI fails on syntax errors; lint logs surfaced; docs updated with pipeline instructions. |
| DEVOPS-POLICY-20-003 | DONE (2025-10-26) | DevOps Guild, QA Guild | DEVOPS-POLICY-20-001, POLICY-ENGINE-20-005 | Determinism CI: run Policy Engine twice with identical inputs and diff outputs to guard non-determinism. | CI job compares outputs, fails on differences, logs stored; documentation updated. |
| DEVOPS-POLICY-20-004 | DOING (2025-10-26) | DevOps Guild, Scheduler Guild, CLI Guild | SCHED-MODELS-20-001, CLI-POLICY-20-002 | Automate policy schema exports: generate JSON Schema from `PolicyRun*` DTOs during CI, publish artefacts, and emit change alerts for CLI consumers (Slack + changelog). | CI stage outputs versioned schema files, uploads artefacts, notifies #policy-engine channel on change; docs/CLI references updated. |
| DEVOPS-POLICY-20-004 | DONE (2025-10-27) | DevOps Guild, Scheduler Guild, CLI Guild | SCHED-MODELS-20-001, CLI-POLICY-20-002 | Automate policy schema exports: generate JSON Schema from `PolicyRun*` DTOs during CI, publish artefacts, and emit change alerts for CLI consumers (Slack + changelog). | CI stage outputs versioned schema files, uploads artefacts, notifies #policy-engine channel on change; docs/CLI references updated. |
> 2025-10-27: `.gitea/workflows/build-test-deploy.yml` publishes the `policy-schema-exports` artefact under `artifacts/policy-schemas/<commit>/` and posts Slack diffs via `POLICY_ENGINE_SCHEMA_WEBHOOK`; diff stored as `policy-schema-diff.patch`.
## Graph Explorer v1
@@ -80,8 +81,8 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| DEVOPS-LNM-22-001 | TODO | DevOps Guild, Concelier Guild | CONCELIER-LNM-21-102 | Run migration/backfill pipelines for advisory observations/linksets in staging, validate counts/conflicts, and automate deployment steps. | Migration job scripted; staging validation report produced; rollback documented. |
| DEVOPS-LNM-22-002 | TODO | DevOps Guild, Excititor Guild | EXCITITOR-LNM-21-102 | Execute VEX observation/linkset backfill with monitoring; ensure NATS/Redis events integrated; document ops runbook. | Backfill completed in staging; monitoring dashboards updated; runbook published. |
| DEVOPS-LNM-22-001 | BLOCKED (2025-10-27) | DevOps Guild, Concelier Guild | CONCELIER-LNM-21-102 | Run migration/backfill pipelines for advisory observations/linksets in staging, validate counts/conflicts, and automate deployment steps. Awaiting storage backfill tooling. |
| DEVOPS-LNM-22-002 | BLOCKED (2025-10-27) | DevOps Guild, Excititor Guild | EXCITITOR-LNM-21-102 | Execute VEX observation/linkset backfill with monitoring; ensure NATS/Redis events integrated; document ops runbook. Blocked until Excititor storage migration lands. |
| DEVOPS-LNM-22-003 | TODO | DevOps Guild, Observability Guild | CONCELIER-LNM-21-005, EXCITITOR-LNM-21-005 | Add CI/monitoring coverage for new metrics (`advisory_observations_total`, `linksets_total`, etc.) and alerts on ingest-to-API SLA breaches. | Metrics scraped into Grafana; alert thresholds set; CI job verifies metric emission. |
## Graph & Vuln Explorer v1

View File

@@ -21,8 +21,8 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SAMPLES-LNM-22-001 | TODO | Samples Guild, Concelier Guild | CONCELIER-LNM-21-001..003 | Create advisory observation/linkset fixtures (NVD, GHSA, OSV disagreements) for API/CLI/UI tests with documented conflicts. | Fixtures deposited under `samples/advisories/`; metadata README added; tests reference fixtures. |
| SAMPLES-LNM-22-002 | TODO | Samples Guild, Excititor Guild | EXCITITOR-LNM-21-001..003 | Produce VEX observation/linkset fixtures demonstrating status conflicts and path relevance; include raw blobs. | Fixtures stored under `samples/vex/`; CLI/UI tests consume; docs linked. |
| SAMPLES-LNM-22-001 | BLOCKED (2025-10-27) | Samples Guild, Concelier Guild | CONCELIER-LNM-21-001..003 | Create advisory observation/linkset fixtures (NVD, GHSA, OSV disagreements) for API/CLI/UI tests with documented conflicts. Waiting on finalized schema/linkset outputs. | Fixtures deposited under `samples/advisories/`; metadata README added; tests reference fixtures. |
| SAMPLES-LNM-22-002 | BLOCKED (2025-10-27) | Samples Guild, Excititor Guild | EXCITITOR-LNM-21-001..003 | Produce VEX observation/linkset fixtures demonstrating status conflicts and path relevance; include raw blobs. Pending Excititor observation/linkset implementation. | Fixtures stored under `samples/vex/`; CLI/UI tests consume; docs linked. |
## Graph & Vuln Explorer v1 (extended)

View File

@@ -14,4 +14,9 @@ public static class StellaOpsServiceIdentities
/// 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";
}

View File

@@ -6,6 +6,7 @@ using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Configuration;
@@ -389,6 +390,106 @@ public class ClientCredentialsHandlersTests
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_RejectsAdvisoryScopes_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "concelier-ingestor",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:ingest advisory:read");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Advisory scopes require a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsVexScopes_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "excitor-ingestor",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "vex:ingest vex:read");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vex:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant()
{
var clientDocument = CreateClient(
clientId: "concelier-ingestor",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:ingest advisory:read",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "advisory:read" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity()
{
@@ -992,6 +1093,206 @@ public class TokenValidationHandlersTests
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
}
[Fact]
public async Task ValidateAccessTokenHandler_AddsTenantClaim_FromTokenDocument()
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId,
Tenant = "tenant-alpha"
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin);
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
}
[Fact]
public async Task ValidateAccessTokenHandler_Rejects_WhenTenantDiffersFromToken()
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId,
Tenant = "tenant-alpha"
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin);
principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-beta"));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
Assert.Equal("The token tenant does not match the issued tenant.", context.ErrorDescription);
}
[Fact]
public async Task ValidateAccessTokenHandler_AssignsTenant_FromClientWhenTokenMissing()
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin);
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
}
[Fact]
public async Task ValidateAccessTokenHandler_Rejects_WhenClientTenantDiffers()
{
var clientDocument = CreateClient(tenant: "tenant-beta");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin);
principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha"));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription);
}
[Fact]
public async Task ValidateAccessTokenHandler_EnrichesClaims_WhenProviderAvailable()
{

View File

@@ -283,6 +283,11 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var hasGraphExport = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphExport) >= 0;
var hasGraphSimulate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphSimulate) >= 0;
var graphScopesRequested = hasGraphRead || hasGraphWrite || hasGraphExport || hasGraphSimulate;
var hasAdvisoryIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryIngest) >= 0;
var hasAdvisoryRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryRead) >= 0;
var hasVexIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexIngest) >= 0;
var hasVexRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexRead) >= 0;
var hasVulnRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnRead) >= 0;
var tenantScopeForAudit = hasGraphWrite
? StellaOpsScopes.GraphWrite
@@ -302,6 +307,38 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
if ((hasAdvisoryIngest || hasAdvisoryRead) && !EnsureTenantAssigned())
{
var advisoryScope = hasAdvisoryIngest ? StellaOpsScopes.AdvisoryIngest : StellaOpsScopes.AdvisoryRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = advisoryScope;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Advisory scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: advisory scopes require tenant assignment.",
document.ClientId);
return;
}
if ((hasVexIngest || hasVexRead) && !EnsureTenantAssigned())
{
var vexScope = hasVexIngest ? StellaOpsScopes.VexIngest : StellaOpsScopes.VexRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = vexScope;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "VEX scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: vex scopes require tenant assignment.",
document.ClientId);
return;
}
if (hasVulnRead && !EnsureTenantAssigned())
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.VulnRead;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Vuln Explorer scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: vuln scopes require tenant assignment.",
document.ClientId);
return;
}
if (grantedScopes.Length > 0 &&
Array.IndexOf(grantedScopes, StellaOpsScopes.EffectiveWrite) >= 0)
{

View File

@@ -69,6 +69,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
return;
}
static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
var identity = context.Principal.Identity as ClaimsIdentity;
var principalTenant = NormalizeTenant(context.Principal.GetClaim(StellaOpsClaimTypes.Tenant));
using var activity = activitySource.StartActivity("authority.token.validate_access", ActivityKind.Internal);
activity?.SetTag("authority.endpoint", context.EndpointType switch
{
@@ -111,21 +117,43 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
if (tokenDocument is not null)
{
EnsureSenderConstraintClaims(context.Principal, tokenDocument);
var documentTenant = NormalizeTenant(tokenDocument.Tenant);
if (documentTenant is not null)
{
if (principalTenant is null)
{
if (identity is not null)
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, documentTenant);
principalTenant = documentTenant;
}
}
else if (!string.Equals(principalTenant, documentTenant, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the issued tenant.");
logger.LogWarning(
"Access token validation failed: tenant mismatch for token {TokenId}. PrincipalTenant={PrincipalTenant}; DocumentTenant={DocumentTenant}.",
tokenDocument.TokenId,
principalTenant,
documentTenant);
return;
}
metadataAccessor.SetTenant(documentTenant);
}
}
if (!context.IsRejected && tokenDocument is not null)
{
await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(tokenDocument.Tenant))
{
metadataAccessor.SetTenant(tokenDocument.Tenant);
}
}
var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId);
AuthorityClientDocument? clientDocument = null;
if (!string.IsNullOrWhiteSpace(clientId))
{
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false);
clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false);
if (clientDocument is null || clientDocument.Disabled)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted.");
@@ -134,15 +162,43 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
}
}
if (context.Principal.Identity is not ClaimsIdentity identity)
if (clientDocument is not null &&
clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var clientTenantRaw))
{
var clientTenant = NormalizeTenant(clientTenantRaw);
if (clientTenant is not null)
{
if (principalTenant is null)
{
if (identity is not null)
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, clientTenant);
principalTenant = clientTenant;
}
}
else if (!string.Equals(principalTenant, clientTenant, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the registered client tenant.");
logger.LogWarning(
"Access token validation failed: tenant mismatch for client {ClientId}. PrincipalTenant={PrincipalTenant}; ClientTenant={ClientTenant}.",
clientId,
principalTenant,
clientTenant);
return;
}
metadataAccessor.SetTenant(clientTenant);
}
}
if (identity is null)
{
return;
}
var tenantClaim = context.Principal.GetClaim(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenantClaim))
if (principalTenant is not null)
{
metadataAccessor.SetTenant(tenantClaim);
metadataAccessor.SetTenant(principalTenant);
}
var providerName = context.Principal.GetClaim(StellaOpsClaimTypes.IdentityProvider);

View File

@@ -2,10 +2,13 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-AOC-19-001 | DONE (2025-10-26) | Authority Core & Security Guild | — | Introduce scopes `advisory:read`, `advisory:ingest`, `vex:read`, `vex:ingest`, `aoc:verify` with configuration binding, migrations, and offline kit defaults. | Scopes published in metadata/OpenAPI, configuration validates scope lists, tests cover token issuance + enforcement. |
| AUTH-AOC-19-002 | DOING (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-001 | Propagate tenant claim + scope enforcement for ingestion identities; ensure cross-tenant writes/read blocked and audit logs capture tenant context. | Tenant claim injected into downstream services; forbidden cross-tenant access rejected; audit/log fixtures updated. |
| AUTH-AOC-19-002 | DONE (2025-10-27) | Authority Core & Security Guild | AUTH-AOC-19-001 | Propagate tenant claim + scope enforcement for ingestion identities; ensure cross-tenant writes/read blocked and audit logs capture tenant context. | Tenant claim injected into downstream services; forbidden cross-tenant access rejected; audit/log fixtures updated. |
> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003.
| AUTH-AOC-19-003 | TODO | Authority Core & Docs Guild | AUTH-AOC-19-001 | Update Authority docs and sample configs to describe new scopes, tenancy enforcement, and verify endpoints. | Docs and examples refreshed; release notes prepared; smoke tests confirm new scopes required. |
> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests.
> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet<string?>`), preventing Authority test suite run; waiting on Concelier fix before rerun.
| AUTH-AOC-19-003 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-001 | Update Authority docs and sample configs to describe new scopes, tenancy enforcement, and verify endpoints. | Docs and examples refreshed; release notes prepared; smoke tests confirm new scopes required. |
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
## Policy Engine v2
@@ -38,6 +41,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-VULN-24-001 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
## Orchestrator Dashboard
@@ -54,6 +58,8 @@
| AUTH-CONSOLE-23-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001 | Register StellaOps Console confidential client with OIDC PKCE support, short-lived ID/access tokens, `console:*` audience claims, and SPA-friendly refresh (token exchange endpoint). Publish discovery metadata + offline kit defaults. | Client registration committed, configuration templates updated, integration tests validate PKCE + scope issuance, security review recorded. |
| AUTH-CONSOLE-23-002 | TODO | Authority Core & Security Guild | AUTH-CONSOLE-23-001, AUTH-AOC-19-002 | Expose tenant catalog, user profile, and token introspection endpoints required by Console (fresh-auth prompts, scope checks); enforce tenant header requirements and audit logging with correlation IDs. | Endpoints ship with RBAC enforcement, audit logs include tenant+scope, integration tests cover unauthorized/tenant-mismatch scenarios. |
| AUTH-CONSOLE-23-003 | TODO | Authority Core & Docs Guild | AUTH-CONSOLE-23-001, AUTH-CONSOLE-23-002 | Update security docs/config samples for Console flows (PKCE, tenant badge, fresh-auth for admin actions, session inactivity timeouts) with compliance checklist. | Docs merged, config samples validated, release notes updated, ops runbook references new flows. |
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120s OpTok, 300s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
| AUTH-CONSOLE-23-004 | TODO | Authority Core & Security Guild | AUTH-CONSOLE-23-003, DOCS-CONSOLE-23-012 | Validate console security guide assumptions (120s OpTok TTL, 300s fresh-auth window, scope bundles) against Authority implementation and update configs/audit fixtures if needed. | Confirmation recorded in sprint log; Authority config samples/tests updated when adjustments required; `/fresh-auth` behaviour documented in release notes. |
## Policy Studio (Sprint 27)
@@ -61,6 +67,7 @@
|----|--------|----------|------------|-------------|---------------|
| AUTH-POLICY-27-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-CONSOLE-23-001 | Define Policy Studio roles (`policy:author`, `policy:review`, `policy:approve`, `policy:operate`, `policy:audit`) with tenant-scoped claims, update issuer metadata, and seed offline kit defaults. | Scopes/roles exposed via discovery docs; tokens issued with correct claims; integration tests cover role combinations; docs updated. |
| AUTH-POLICY-27-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
| AUTH-POLICY-27-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
## Exceptions v1

View File

@@ -20,8 +20,10 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| BENCH-GRAPH-21-001 | DOING (2025-10-27) | Bench Guild, Graph Platform Guild | GRAPH-API-28-003, GRAPH-INDEX-28-006 | Build graph viewport/path benchmark harness (50k/100k nodes) measuring Graph API/Indexer latency, memory, and tile cache hit rates. *(Executed within Sprint 28 Graph program).* | Harness committed; baseline metrics logged; integrates with perf dashboards. |
| BENCH-GRAPH-21-002 | TODO | Bench Guild, UI Guild | BENCH-GRAPH-21-001, UI-GRAPH-24-001 | Add headless UI load benchmark (Playwright) for graph canvas interactions to track render times and FPS budgets. *(Executed within Sprint 28 Graph program).* | Benchmark runs in CI; results exported; alert thresholds defined. |
| BENCH-GRAPH-21-001 | BLOCKED (2025-10-27) | Bench Guild, Graph Platform Guild | GRAPH-API-28-003, GRAPH-INDEX-28-006 | Build graph viewport/path benchmark harness (50k/100k nodes) measuring Graph API/Indexer latency, memory, and tile cache hit rates. *(Executed within Sprint 28 Graph program).* | Harness committed; baseline metrics logged; integrates with perf dashboards. |
> 2025-10-27: Graph API (`GRAPH-API-28-003`) and indexer (`GRAPH-INDEX-28-006`) contracts are not yet available, so workload scenarios and baselines cannot be recorded. Revisit once upstream services expose stable perf endpoints.
| BENCH-GRAPH-21-002 | BLOCKED (2025-10-27) | Bench Guild, UI Guild | BENCH-GRAPH-21-001, UI-GRAPH-24-001 | Add headless UI load benchmark (Playwright) for graph canvas interactions to track render times and FPS budgets. *(Executed within Sprint 28 Graph program).* | Benchmark runs in CI; results exported; alert thresholds defined. |
> 2025-10-27: Waiting on BENCH-GRAPH-21-001 harness and UI Graph Explorer (`UI-GRAPH-24-001`) to stabilize. Playwright flows and perf targets are not defined yet.
## Link-Not-Merge v1

View File

@@ -0,0 +1,51 @@
using StellaOps.Auth.Abstractions;
using StellaOps.Cartographer.Options;
using Xunit;
namespace StellaOps.Cartographer.Tests.Options;
public class CartographerAuthorityOptionsConfiguratorTests
{
[Fact]
public void ApplyDefaults_AddsGraphScopes()
{
var options = new CartographerAuthorityOptions();
CartographerAuthorityOptionsConfigurator.ApplyDefaults(options);
Assert.Contains(StellaOpsScopes.GraphRead, options.RequiredScopes);
Assert.Contains(StellaOpsScopes.GraphWrite, options.RequiredScopes);
}
[Fact]
public void ApplyDefaults_DoesNotDuplicateScopes()
{
var options = new CartographerAuthorityOptions();
options.RequiredScopes.Add("GRAPH:READ");
options.RequiredScopes.Add(StellaOpsScopes.GraphWrite);
CartographerAuthorityOptionsConfigurator.ApplyDefaults(options);
Assert.Equal(2, options.RequiredScopes.Count);
}
[Fact]
public void Validate_AllowsDisabledConfiguration()
{
var options = new CartographerAuthorityOptions();
options.Validate(); // should not throw when disabled
}
[Fact]
public void Validate_ThrowsForInvalidIssuer()
{
var options = new CartographerAuthorityOptions
{
Enabled = true,
Issuer = "invalid"
};
Assert.Throws<InvalidOperationException>(() => options.Validate());
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cartographer\StellaOps.Cartographer.csproj" />
</ItemGroup>
</Project>

View File

@@ -15,3 +15,4 @@ Build and operate the Cartographer service that materializes immutable SBOM prop
- Tenancy and scope enforcement must match Authority policies (`graph:*`, `sbom:read`, `findings:read`).
- Update `TASKS.md`, `SPRINTS.md` when status changes.
- Provide fixtures and documentation so UI/CLI teams can simulate graphs offline.
- Authority integration derives scope names from `StellaOps.Auth.Abstractions.StellaOpsScopes`; avoid hard-coded `graph:*` literals.

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cartographer.Options;
/// <summary>
/// Configuration controlling Authority-backed authentication for the Cartographer service.
/// </summary>
public sealed class CartographerAuthorityOptions
{
/// <summary>
/// Enables Authority-backed authentication for Cartographer endpoints.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Allows anonymous access when Authority integration is enabled (development only).
/// </summary>
public bool AllowAnonymousFallback { get; set; }
/// <summary>
/// Authority issuer URL exposed via OpenID discovery.
/// </summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// Whether HTTPS metadata is required when fetching Authority discovery documents.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Optional explicit metadata endpoint for Authority discovery.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Timeout (seconds) applied to Authority back-channel HTTP calls.
/// </summary>
public int BackchannelTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Allowed token clock skew (seconds) when validating Authority-issued tokens.
/// </summary>
public int TokenClockSkewSeconds { get; set; } = 60;
/// <summary>
/// Accepted audiences for Cartographer access tokens.
/// </summary>
public IList<string> Audiences { get; } = new List<string>();
/// <summary>
/// Scopes required for Cartographer operations.
/// </summary>
public IList<string> RequiredScopes { get; } = new List<string>();
/// <summary>
/// Tenants permitted to access Cartographer resources.
/// </summary>
public IList<string> RequiredTenants { get; } = new List<string>();
/// <summary>
/// Networks allowed to bypass authentication enforcement.
/// </summary>
public IList<string> BypassNetworks { get; } = new List<string>();
/// <summary>
/// Validates configured values and throws <see cref="InvalidOperationException"/> on failure.
/// </summary>
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Cartographer Authority issuer must be configured when Authority integration is enabled.");
}
if (!Uri.TryCreate(Issuer.Trim(), UriKind.Absolute, out var issuerUri))
{
throw new InvalidOperationException("Cartographer Authority issuer must be an absolute URI.");
}
if (RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Cartographer Authority issuer must use HTTPS unless running on loopback.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Cartographer Authority back-channel timeout must be greater than zero seconds.");
}
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Cartographer Authority token clock skew must be between 0 and 300 seconds.");
}
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Cartographer.Options;
/// <summary>
/// Applies Cartographer-specific defaults to <see cref="CartographerAuthorityOptions"/>.
/// </summary>
internal static class CartographerAuthorityOptionsConfigurator
{
/// <summary>
/// Ensures required scopes are present and duplicates are removed case-insensitively.
/// </summary>
/// <param name="options">Target options.</param>
public static void ApplyDefaults(CartographerAuthorityOptions options)
{
ArgumentNullException.ThrowIfNull(options);
EnsureScope(options.RequiredScopes, StellaOpsScopes.GraphRead);
EnsureScope(options.RequiredScopes, StellaOpsScopes.GraphWrite);
}
private static void EnsureScope(ICollection<string> scopes, string scope)
{
ArgumentNullException.ThrowIfNull(scopes);
ArgumentException.ThrowIfNullOrEmpty(scope);
if (scopes.Any(existing => string.Equals(existing, scope, StringComparison.OrdinalIgnoreCase)))
{
return;
}
scopes.Add(scope);
}
}

View File

@@ -1,3 +1,5 @@
using StellaOps.Cartographer.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
@@ -7,10 +9,30 @@ builder.Configuration
builder.Services.AddOptions();
builder.Services.AddLogging();
var authoritySection = builder.Configuration.GetSection("Cartographer:Authority");
var authorityOptions = new CartographerAuthorityOptions();
authoritySection.Bind(authorityOptions);
CartographerAuthorityOptionsConfigurator.ApplyDefaults(authorityOptions);
authorityOptions.Validate();
builder.Services.AddSingleton(authorityOptions);
builder.Services.AddOptions<CartographerAuthorityOptions>()
.Bind(authoritySection)
.PostConfigure(CartographerAuthorityOptionsConfigurator.ApplyDefaults);
// TODO: register Cartographer graph builders, overlay workers, and Authority client once implementations land.
var app = builder.Build();
if (!authorityOptions.Enabled)
{
app.Logger.LogWarning("Cartographer Authority authentication is disabled; enable it before production deployments.");
}
else if (authorityOptions.AllowAnonymousFallback)
{
app.Logger.LogWarning("Cartographer Authority allows anonymous fallback; disable fallback before production rollout.");
}
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));

View File

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

View File

@@ -12,5 +12,6 @@
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="..\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
# Cartographer Task Board — Epic 3: Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CARTO-GRAPH-21-010 | TODO | Cartographer Guild | AUTH-GRAPH-21-001 | Replace hard-coded `graph:*` scope strings in Cartographer services/clients with `StellaOpsScopes` constants; document new dependency. | All scope checks reference `StellaOpsScopes`; documentation updated; unit tests adjusted if needed. |
| CARTO-GRAPH-21-010 | DONE (2025-10-27) | Cartographer Guild | AUTH-GRAPH-21-001 | Replace hard-coded `graph:*` scope strings in Cartographer services/clients with `StellaOpsScopes` constants; document new dependency. | All scope checks reference `StellaOpsScopes`; documentation updated; unit tests adjusted if needed. |
> 2025-10-26 — Note: awaiting Cartographer service bootstrap. Keep this task open until Cartographer routes exist so we can swap to `StellaOpsScopes` immediately.

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
@@ -379,6 +380,169 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandleVulnObservationsAsync_WritesTableOutput()
{
var originalExit = Environment.ExitCode;
var response = new AdvisoryObservationsResponse
{
Observations = new[]
{
new AdvisoryObservationDocument
{
ObservationId = "tenant-a:ghsa:alpha:1",
Tenant = "tenant-a",
Source = new AdvisoryObservationSource
{
Vendor = "ghsa",
Stream = "advisories",
Api = "https://example.test/api"
},
Upstream = new AdvisoryObservationUpstream
{
UpstreamId = "GHSA-abcd-efgh"
},
Linkset = new AdvisoryObservationLinkset
{
Aliases = new[] { "cve-2025-0001" },
Purls = new[] { "pkg:npm/package-a@1.0.0" },
Cpes = new[] { "cpe:/a:vendor:product:1.0" }
},
CreatedAt = new DateTimeOffset(2025, 10, 27, 6, 0, 0, TimeSpan.Zero)
}
},
Linkset = new AdvisoryObservationLinksetAggregate
{
Aliases = new[] { "cve-2025-0001" },
Purls = new[] { "pkg:npm/package-a@1.0.0" },
Cpes = new[] { "cpe:/a:vendor:product:1.0" },
References = Array.Empty<AdvisoryObservationReference>()
}
};
var stubClient = new StubConcelierObservationsClient(response);
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
var provider = BuildServiceProvider(backend, concelierClient: stubClient);
var console = new TestConsole();
var originalConsole = AnsiConsole.Console;
AnsiConsole.Console = console;
try
{
await CommandHandlers.HandleVulnObservationsAsync(
provider,
tenant: "Tenant-A ",
observationIds: new[] { "tenant-a:ghsa:alpha:1 " },
aliases: new[] { " CVE-2025-0001 " },
purls: new[] { " pkg:npm/package-a@1.0.0 " },
cpes: Array.Empty<string>(),
emitJson: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
AnsiConsole.Console = originalConsole;
}
Assert.NotNull(stubClient.LastQuery);
var query = stubClient.LastQuery!;
Assert.Equal("tenant-a", query.Tenant);
Assert.Contains("cve-2025-0001", query.Aliases);
Assert.Contains("pkg:npm/package-a@1.0.0", query.Purls);
var output = console.Output;
Assert.False(string.IsNullOrWhiteSpace(output));
}
[Fact]
public async Task HandleVulnObservationsAsync_WritesJsonOutput()
{
var originalExit = Environment.ExitCode;
var response = new AdvisoryObservationsResponse
{
Observations = new[]
{
new AdvisoryObservationDocument
{
ObservationId = "tenant-a:osv:beta:2",
Tenant = "tenant-a",
Source = new AdvisoryObservationSource
{
Vendor = "osv",
Stream = "osv",
Api = "https://example.test/osv"
},
Upstream = new AdvisoryObservationUpstream
{
UpstreamId = "OSV-2025-XYZ"
},
Linkset = new AdvisoryObservationLinkset
{
Aliases = new[] { "cve-2025-0101" },
Purls = new[] { "pkg:pypi/package-b@2.0.0" },
Cpes = Array.Empty<string>(),
References = new[]
{
new AdvisoryObservationReference { Type = "advisory", Url = "https://example.test/advisory" }
}
},
CreatedAt = new DateTimeOffset(2025, 10, 27, 7, 30, 0, TimeSpan.Zero)
}
},
Linkset = new AdvisoryObservationLinksetAggregate
{
Aliases = new[] { "cve-2025-0101" },
Purls = new[] { "pkg:pypi/package-b@2.0.0" },
Cpes = Array.Empty<string>(),
References = new[]
{
new AdvisoryObservationReference { Type = "advisory", Url = "https://example.test/advisory" }
}
}
};
var stubClient = new StubConcelierObservationsClient(response);
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
var provider = BuildServiceProvider(backend, concelierClient: stubClient);
var writer = new StringWriter();
var originalOut = Console.Out;
Console.SetOut(writer);
try
{
await CommandHandlers.HandleVulnObservationsAsync(
provider,
tenant: "tenant-a",
observationIds: Array.Empty<string>(),
aliases: Array.Empty<string>(),
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
emitJson: true,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
Console.SetOut(originalOut);
}
var json = writer.ToString();
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.True(root.TryGetProperty("observations", out var observations));
Assert.Equal("tenant-a:osv:beta:2", observations[0].GetProperty("observationId").GetString());
Assert.Equal("pkg:pypi/package-b@2.0.0", observations[0].GetProperty("linkset").GetProperty("purls")[0].GetString());
}
[Theory]
[InlineData(null)]
[InlineData("default")]
@@ -771,6 +935,218 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandlePolicySimulateAsync_WritesInteractiveSummary()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var console = new TestConsole();
console.Width(120);
console.Interactive();
console.EmitAnsiSequences();
AnsiConsole.Console = console;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
var severity = new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(StringComparer.Ordinal)
{
["critical"] = new PolicySimulationSeverityDelta(1, null),
["high"] = new PolicySimulationSeverityDelta(null, 2)
});
var ruleHits = new ReadOnlyCollection<PolicySimulationRuleDelta>(new List<PolicySimulationRuleDelta>
{
new("rule-block-critical", "Block Critical", 1, 0),
new("rule-quiet-low", "Quiet Low", null, 2)
});
backend.SimulationResult = new PolicySimulationResult(
new PolicySimulationDiff(
"scheduler.policy-diff-summary@1",
2,
1,
10,
severity,
ruleHits),
"blob://policy/P-7/simulation.json");
var provider = BuildServiceProvider(backend);
try
{
await CommandHandlers.HandlePolicySimulateAsync(
provider,
policyId: "P-7",
baseVersion: 3,
candidateVersion: 4,
sbomArguments: new[] { "sbom:A", "sbom:B" },
environmentArguments: new[] { "sealed=false", "exposure=internet" },
format: "table",
outputPath: null,
explain: true,
failOnDiff: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
Assert.NotNull(backend.LastPolicySimulation);
var simulation = backend.LastPolicySimulation!.Value;
Assert.Equal("P-7", simulation.PolicyId);
Assert.Equal(3, simulation.Input.BaseVersion);
Assert.Equal(4, simulation.Input.CandidateVersion);
Assert.True(simulation.Input.Explain);
Assert.Equal(new[] { "sbom:A", "sbom:B" }, simulation.Input.SbomSet);
Assert.True(simulation.Input.Environment.TryGetValue("sealed", out var sealedValue) && sealedValue is bool sealedFlag && sealedFlag == false);
Assert.True(simulation.Input.Environment.TryGetValue("exposure", out var exposureValue) && string.Equals(exposureValue as string, "internet", StringComparison.Ordinal));
var output = console.Output;
Assert.Contains("Severity", output, StringComparison.Ordinal);
Assert.Contains("critical", output, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Rule", output, StringComparison.Ordinal);
Assert.Contains("Block Critical", output, StringComparison.Ordinal);
}
finally
{
Environment.ExitCode = originalExit;
AnsiConsole.Console = originalConsole;
}
}
[Fact]
public async Task HandlePolicySimulateAsync_WritesJsonOutput()
{
var originalExit = Environment.ExitCode;
var originalOut = Console.Out;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
backend.SimulationResult = new PolicySimulationResult(
new PolicySimulationDiff(
"scheduler.policy-diff-summary@1",
0,
0,
5,
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
null);
var provider = BuildServiceProvider(backend);
using var writer = new StringWriter();
Console.SetOut(writer);
try
{
await CommandHandlers.HandlePolicySimulateAsync(
provider,
policyId: "P-9",
baseVersion: null,
candidateVersion: 5,
sbomArguments: Array.Empty<string>(),
environmentArguments: new[] { "sealed=true", "threshold=0.8" },
format: "json",
outputPath: null,
explain: false,
failOnDiff: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
using var document = JsonDocument.Parse(writer.ToString());
var root = document.RootElement;
Assert.Equal("P-9", root.GetProperty("policyId").GetString());
Assert.Equal(5, root.GetProperty("candidateVersion").GetInt32());
Assert.True(root.TryGetProperty("environment", out var envElement) && envElement.TryGetProperty("sealed", out var sealedElement) && sealedElement.GetBoolean());
Assert.True(envElement.TryGetProperty("threshold", out var thresholdElement) && Math.Abs(thresholdElement.GetDouble() - 0.8) < 0.0001);
}
finally
{
Console.SetOut(originalOut);
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandlePolicySimulateAsync_FailOnDiffSetsExitCode20()
{
var originalExit = Environment.ExitCode;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
backend.SimulationResult = new PolicySimulationResult(
new PolicySimulationDiff(
null,
1,
0,
0,
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
null);
var provider = BuildServiceProvider(backend);
try
{
await CommandHandlers.HandlePolicySimulateAsync(
provider,
policyId: "P-11",
baseVersion: null,
candidateVersion: null,
sbomArguments: Array.Empty<string>(),
environmentArguments: Array.Empty<string>(),
format: "json",
outputPath: null,
explain: false,
failOnDiff: true,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(20, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandlePolicySimulateAsync_MapsErrorCodes()
{
var originalExit = Environment.ExitCode;
var originalOut = Console.Out;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
SimulationException = new PolicyApiException("Missing inputs", HttpStatusCode.BadRequest, "ERR_POL_003")
};
var provider = BuildServiceProvider(backend);
using var writer = new StringWriter();
Console.SetOut(writer);
try
{
await CommandHandlers.HandlePolicySimulateAsync(
provider,
policyId: "P-12",
baseVersion: null,
candidateVersion: null,
sbomArguments: Array.Empty<string>(),
environmentArguments: Array.Empty<string>(),
format: "json",
outputPath: null,
explain: false,
failOnDiff: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(21, Environment.ExitCode);
}
finally
{
Console.SetOut(originalOut);
Environment.ExitCode = originalExit;
}
}
private static async Task<RevocationArtifactPaths> WriteRevocationArtifactsAsync(TempDirectory temp, string? providerHint)
{
var (bundleBytes, signature, keyPem) = await BuildRevocationArtifactsAsync(providerHint);
@@ -849,7 +1225,8 @@ public sealed class CommandHandlersTests
IScannerExecutor? executor = null,
IScannerInstaller? installer = null,
StellaOpsCliOptions? options = null,
IStellaOpsTokenClient? tokenClient = null)
IStellaOpsTokenClient? tokenClient = null,
IConcelierObservationsClient? concelierClient = null)
{
var services = new ServiceCollection();
services.AddSingleton(backend);
@@ -870,6 +1247,9 @@ public sealed class CommandHandlersTests
services.AddSingleton(tokenClient);
}
services.AddSingleton<IConcelierObservationsClient>(
concelierClient ?? new StubConcelierObservationsClient());
return services.BuildServiceProvider();
}
@@ -907,6 +1287,45 @@ public sealed class CommandHandlersTests
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
public RuntimePolicyEvaluationResult RuntimePolicyResult { get; set; } = DefaultRuntimePolicyResult;
public PolicySimulationResult SimulationResult { get; set; } = new PolicySimulationResult(
new PolicySimulationDiff(
null,
0,
0,
0,
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
null);
public PolicyApiException? SimulationException { get; set; }
public (string PolicyId, PolicySimulationInput Input)? LastPolicySimulation { get; private set; }
public (string PolicyId, PolicyFindingsQuery Query)? LastFindingsQuery { get; private set; }
public (string PolicyId, string FindingId)? LastFindingRequest { get; private set; }
public (string PolicyId, string FindingId, bool Verbose)? LastExplainRequest { get; private set; }
public PolicyFindingsPage FindingsPage { get; set; } = new PolicyFindingsPage(
new ReadOnlyCollection<PolicyFinding>(Array.Empty<PolicyFinding>()),
null);
public PolicyFinding Finding { get; set; } = new PolicyFinding(
"finding-1",
"affected",
"High",
7.5,
"sbom:S-42",
4,
DateTimeOffset.Parse("2025-10-26T14:06:01Z", CultureInfo.InvariantCulture),
false,
null,
"internet",
null,
Array.Empty<string>(),
Array.Empty<string>(),
"{}");
public PolicyFindingExplain FindingExplain { get; set; } = new PolicyFindingExplain(
"finding-1",
4,
new ReadOnlyCollection<PolicyFindingExplainStep>(Array.Empty<PolicyFindingExplainStep>()),
new ReadOnlyCollection<string>(Array.Empty<string>()),
"{}");
public PolicyApiException? FindingsException { get; set; }
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
=> throw new NotImplementedException();
@@ -952,6 +1371,50 @@ public sealed class CommandHandlersTests
public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
=> Task.FromResult(RuntimePolicyResult);
public Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken)
{
LastPolicySimulation = (policyId, input);
if (SimulationException is not null)
{
throw SimulationException;
}
return Task.FromResult(SimulationResult);
}
public Task<PolicyFindingsPage> GetPolicyFindingsAsync(string policyId, PolicyFindingsQuery query, CancellationToken cancellationToken)
{
LastFindingsQuery = (policyId, query);
if (FindingsException is not null)
{
throw FindingsException;
}
return Task.FromResult(FindingsPage);
}
public Task<PolicyFinding> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken)
{
LastFindingRequest = (policyId, findingId);
if (FindingsException is not null)
{
throw FindingsException;
}
return Task.FromResult(Finding);
}
public Task<PolicyFindingExplain> GetPolicyFindingExplainAsync(string policyId, string findingId, bool verbose, CancellationToken cancellationToken)
{
LastExplainRequest = (policyId, findingId, verbose);
if (FindingsException is not null)
{
throw FindingsException;
}
return Task.FromResult(FindingExplain);
}
public Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
=> throw new NotSupportedException();
@@ -1066,4 +1529,26 @@ public sealed class CommandHandlersTests
.Replace('+', '-')
.Replace('/', '_');
}
private sealed class StubConcelierObservationsClient : IConcelierObservationsClient
{
private readonly AdvisoryObservationsResponse _response;
public StubConcelierObservationsClient(AdvisoryObservationsResponse? response = null)
{
_response = response ?? new AdvisoryObservationsResponse();
}
public AdvisoryObservationsQuery? LastQuery { get; private set; }
public Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken)
{
LastQuery = query;
return Task.FromResult(_response);
}
}
}
public Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken)
=> Task.FromResult(new AocIngestDryRunResponse(true, Array.Empty<AocForbiddenField>(), Array.Empty<string>(), "{}"));

View File

@@ -22,6 +22,7 @@ public sealed class CliCommandModuleLoaderTests
options.Plugins.BaseDirectory = repoRoot;
options.Plugins.Directory = "plugins/cli";
options.Plugins.ManifestSearchPattern = "manifest.json";
var services = new ServiceCollection()
.AddSingleton(options)

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Net;
@@ -865,5 +866,135 @@ public sealed class BackendOperationsClientTests
Requests++;
return Task.FromResult(_tokenResult);
}
}
}
}
[Fact]
public async Task SimulatePolicyAsync_SendsPayloadAndParsesResponse()
{
string? capturedBody = null;
var handler = new StubHttpMessageHandler((request, _) =>
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("https://policy.example/api/policy/policies/P-7/simulate", request.RequestUri!.ToString());
capturedBody = request.Content!.ReadAsStringAsync().Result;
var responseDocument = new PolicySimulationResponseDocument
{
Diff = new PolicySimulationDiffDocument
{
SchemaVersion = "scheduler.policy-diff-summary@1",
Added = 2,
Removed = 1,
Unchanged = 10,
BySeverity = new Dictionary<string, PolicySimulationSeverityDeltaDocument>
{
["critical"] = new PolicySimulationSeverityDeltaDocument { Up = 1 },
["high"] = new PolicySimulationSeverityDeltaDocument { Down = 1 }
},
RuleHits = new List<PolicySimulationRuleDeltaDocument>
{
new() { RuleId = "rule-block", RuleName = "Block Critical", Up = 1, Down = 0 }
}
},
ExplainUri = "blob://policy/P-7/simulation.json"
};
var json = JsonSerializer.Serialize(responseDocument, new JsonSerializerOptions(JsonSerializerDefaults.Web));
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
RequestMessage = request
};
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://policy.example")
};
var options = new StellaOpsCliOptions { BackendUrl = "https://policy.example" };
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
var sbomSet = new ReadOnlyCollection<string>(new List<string> { "sbom:A", "sbom:B" });
var environment = new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
{
["sealed"] = false,
["threshold"] = 0.85
});
var input = new PolicySimulationInput(3, 4, sbomSet, environment, true);
var result = await client.SimulatePolicyAsync("P-7", input, CancellationToken.None);
Assert.NotNull(capturedBody);
using (var document = JsonDocument.Parse(capturedBody!))
{
var root = document.RootElement;
Assert.Equal(3, root.GetProperty("baseVersion").GetInt32());
Assert.Equal(4, root.GetProperty("candidateVersion").GetInt32());
Assert.True(root.TryGetProperty("env", out var envElement) && envElement.GetProperty("sealed").GetBoolean() == false);
Assert.Equal(0.85, envElement.GetProperty("threshold").GetDouble(), 3);
Assert.True(root.GetProperty("explain").GetBoolean());
var sboms = root.GetProperty("sbomSet");
Assert.Equal(2, sboms.GetArrayLength());
Assert.Equal("sbom:A", sboms[0].GetString());
}
Assert.Equal("scheduler.policy-diff-summary@1", result.Diff.SchemaVersion);
Assert.Equal(2, result.Diff.Added);
Assert.Equal(1, result.Diff.Removed);
Assert.Equal(10, result.Diff.Unchanged);
Assert.Equal("blob://policy/P-7/simulation.json", result.ExplainUri);
Assert.True(result.Diff.BySeverity.ContainsKey("critical"));
Assert.Single(result.Diff.RuleHits);
Assert.Equal("rule-block", result.Diff.RuleHits[0].RuleId);
}
[Fact]
public async Task SimulatePolicyAsync_ThrowsPolicyApiExceptionOnError()
{
var handler = new StubHttpMessageHandler((request, _) =>
{
var problem = new ProblemDocument
{
Title = "Bad request",
Detail = "Missing SBOM set",
Status = (int)HttpStatusCode.BadRequest,
Extensions = new Dictionary<string, object?>
{
["code"] = "ERR_POL_003"
}
};
var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web));
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
RequestMessage = request
};
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://policy.example")
};
var options = new StellaOpsCliOptions { BackendUrl = "https://policy.example" };
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
var input = new PolicySimulationInput(
null,
null,
new ReadOnlyCollection<string>(Array.Empty<string>()),
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>()),
false);
var exception = await Assert.ThrowsAsync<PolicyApiException>(() => client.SimulatePolicyAsync("P-7", input, CancellationToken.None));
Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode);
Assert.Equal("ERR_POL_003", exception.ErrorCode);
Assert.Contains("Bad request", exception.Message);
}
}

View File

@@ -32,8 +32,11 @@ internal static class CommandFactory
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
@@ -230,12 +233,91 @@ internal static class CommandFactory
return CommandHandlers.HandleExportJobAsync(services, format, delta, publishFull, publishDelta, includeFull, includeDelta, verbose, cancellationToken);
});
db.Add(fetch);
db.Add(merge);
db.Add(fetch);
db.Add(merge);
db.Add(export);
return db;
}
private static Command BuildSourcesCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var sources = new Command("sources", "Interact with source ingestion workflows.");
var ingest = new Command("ingest", "Validate source documents before ingestion.");
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Evaluate guard rules without writing to persistent storage."
};
var sourceOption = new Option<string>("--source")
{
Description = "Logical source identifier (e.g. redhat, ubuntu, osv).",
Required = true
};
var inputOption = new Option<string>("--input")
{
Description = "Path to a local document or HTTPS URI.",
Required = true
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant identifier override."
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: table or json."
};
var noColorOption = new Option<bool>("--no-color")
{
Description = "Disable ANSI colouring in console output."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write the JSON report to the specified file path."
};
ingest.Add(dryRunOption);
ingest.Add(sourceOption);
ingest.Add(inputOption);
ingest.Add(tenantOption);
ingest.Add(formatOption);
ingest.Add(noColorOption);
ingest.Add(outputOption);
ingest.SetAction((parseResult, _) =>
{
var dryRun = parseResult.GetValue(dryRunOption);
var source = parseResult.GetValue(sourceOption) ?? string.Empty;
var input = parseResult.GetValue(inputOption) ?? string.Empty;
var tenant = parseResult.GetValue(tenantOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var noColor = parseResult.GetValue(noColorOption);
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleSourcesIngestAsync(
services,
dryRun,
source,
input,
tenant,
format,
noColor,
output,
verbose,
cancellationToken);
});
sources.Add(ingest);
return sources;
}
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
@@ -322,6 +404,167 @@ internal static class CommandFactory
return auth;
}
private static Command BuildPolicyCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
_ = options;
var policy = new Command("policy", "Interact with Policy Engine operations.");
var simulate = new Command("simulate", "Simulate a policy revision against selected SBOMs and environment.");
var policyIdArgument = new Argument<string>("policy-id")
{
Description = "Policy identifier (e.g. P-7)."
};
simulate.Add(policyIdArgument);
var baseOption = new Option<int?>("--base")
{
Description = "Base policy version for diff calculations."
};
var candidateOption = new Option<int?>("--candidate")
{
Description = "Candidate policy version. Defaults to latest approved."
};
var sbomOption = new Option<string[]>("--sbom")
{
Description = "SBOM identifier to include (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
sbomOption.AllowMultipleArgumentsPerToken = true;
var envOption = new Option<string[]>("--env")
{
Description = "Environment override (key=value, repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
envOption.AllowMultipleArgumentsPerToken = true;
var formatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write JSON output to the specified file."
};
var explainOption = new Option<bool>("--explain")
{
Description = "Request explain traces for diffed findings."
};
var failOnDiffOption = new Option<bool>("--fail-on-diff")
{
Description = "Exit with code 20 when findings are added or removed."
};
simulate.Add(baseOption);
simulate.Add(candidateOption);
simulate.Add(sbomOption);
simulate.Add(envOption);
simulate.Add(formatOption);
simulate.Add(outputOption);
simulate.Add(explainOption);
simulate.Add(failOnDiffOption);
simulate.SetAction((parseResult, _) =>
{
var policyId = parseResult.GetValue(policyIdArgument) ?? string.Empty;
var baseVersion = parseResult.GetValue(baseOption);
var candidateVersion = parseResult.GetValue(candidateOption);
var sbomSet = parseResult.GetValue(sbomOption) ?? Array.Empty<string>();
var environment = parseResult.GetValue(envOption) ?? Array.Empty<string>();
var format = parseResult.GetValue(formatOption);
var output = parseResult.GetValue(outputOption);
var explain = parseResult.GetValue(explainOption);
var failOnDiff = parseResult.GetValue(failOnDiffOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicySimulateAsync(
services,
policyId,
baseVersion,
candidateVersion,
sbomSet,
environment,
format,
output,
explain,
failOnDiff,
verbose,
cancellationToken);
});
policy.Add(simulate);
return policy;
}
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var vuln = new Command("vuln", "Explore vulnerability observations and overlays.");
var observations = new Command("observations", "List raw advisory observations for overlay consumers.");
var tenantOption = new Option<string>("--tenant")
{
Description = "Tenant identifier.",
Required = true
};
var observationIdOption = new Option<string[]>("--observation-id")
{
Description = "Filter by observation identifier (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var aliasOption = new Option<string[]>("--alias")
{
Description = "Filter by vulnerability alias (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var purlOption = new Option<string[]>("--purl")
{
Description = "Filter by Package URL (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var cpeOption = new Option<string[]>("--cpe")
{
Description = "Filter by CPE value (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var jsonOption = new Option<bool>("--json")
{
Description = "Emit raw JSON payload instead of a table."
};
observations.Add(tenantOption);
observations.Add(observationIdOption);
observations.Add(aliasOption);
observations.Add(purlOption);
observations.Add(cpeOption);
observations.Add(jsonOption);
observations.SetAction((parseResult, _) =>
{
var tenant = parseResult.GetValue(tenantOption) ?? string.Empty;
var observationIds = parseResult.GetValue(observationIdOption) ?? Array.Empty<string>();
var aliases = parseResult.GetValue(aliasOption) ?? Array.Empty<string>();
var purls = parseResult.GetValue(purlOption) ?? Array.Empty<string>();
var cpes = parseResult.GetValue(cpeOption) ?? Array.Empty<string>();
var emitJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleVulnObservationsAsync(
services,
tenant,
observationIds,
aliases,
purls,
cpes,
emitJson,
verbose,
cancellationToken);
});
vuln.Add(observations);
return vuln;
}
private static Command BuildConfigCommand(StellaOpsCliOptions options)
{
var config = new Command("config", "Inspect CLI configuration state.");
@@ -333,6 +576,7 @@ internal static class CommandFactory
var lines = new[]
{
$"Backend URL: {MaskIfEmpty(options.BackendUrl)}",
$"Concelier URL: {MaskIfEmpty(options.ConcelierUrl)}",
$"API Key: {DescribeSecret(options.ApiKey)}",
$"Scanner Cache: {options.ScannerCacheDirectory}",
$"Results Directory: {options.ResultsDirectory}",

File diff suppressed because it is too large Load Diff

View File

@@ -25,12 +25,14 @@ public static class CliBootstrapper
};
options.PostBind = (cliOptions, configuration) =>
{
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
var attemptsRaw = ResolveWithFallback(

View File

@@ -11,6 +11,8 @@ public sealed class StellaOpsCliOptions
public string BackendUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string ScannerCacheDirectory { get; set; } = "scanners";
public string ResultsDirectory { get; set; } = "results";

View File

@@ -96,14 +96,24 @@ internal static class Program
{
client.Timeout = TimeSpan.FromMinutes(5);
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
{
client.BaseAddress = backendUri;
}
});
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
{
client.BaseAddress = backendUri;
}
});
services.AddHttpClient<IConcelierObservationsClient, ConcelierObservationsClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) &&
Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var concelierUri))
{
client.BaseAddress = concelierUri;
}
});
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
await using var serviceProvider = services.BuildServiceProvider();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();

View File

@@ -467,14 +467,231 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
}
var decisionsView = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions);
return new RuntimePolicyEvaluationResult(
document.TtlSeconds ?? 0,
document.ExpiresAtUtc?.ToUniversalTime(),
string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision,
decisionsView);
}
var decisionsView = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions);
return new RuntimePolicyEvaluationResult(
document.TtlSeconds ?? 0,
document.ExpiresAtUtc?.ToUniversalTime(),
string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision,
decisionsView);
}
public async Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (input is null)
{
throw new ArgumentNullException(nameof(input));
}
var requestDocument = new PolicySimulationRequestDocument
{
BaseVersion = input.BaseVersion,
CandidateVersion = input.CandidateVersion,
Explain = input.Explain ? true : null
};
if (input.SbomSet.Count > 0)
{
requestDocument.SbomSet = input.SbomSet;
}
if (input.Environment.Count > 0)
{
var environment = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var pair in input.Environment)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
environment[pair.Key] = SerializeEnvironmentValue(pair.Value);
}
if (environment.Count > 0)
{
requestDocument.Env = environment;
}
}
var encodedPolicyId = Uri.EscapeDataString(policyId);
using var request = CreateRequest(HttpMethod.Post, $"api/policy/policies/{encodedPolicyId}/simulate");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
request.Content = JsonContent.Create(requestDocument, options: SerializerOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
if (response.Content is null || response.Content.Headers.ContentLength is 0)
{
throw new InvalidOperationException("Policy simulation response was empty.");
}
PolicySimulationResponseDocument? document;
try
{
document = await response.Content.ReadFromJsonAsync<PolicySimulationResponseDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse policy simulation response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (document is null)
{
throw new InvalidOperationException("Policy simulation response was empty.");
}
if (document.Diff is null)
{
throw new InvalidOperationException("Policy simulation response missing diff summary.");
}
return MapPolicySimulation(document);
}
public async Task<PolicyFindingsPage> GetPolicyFindingsAsync(string policyId, PolicyFindingsQuery query, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (query is null)
{
throw new ArgumentNullException(nameof(query));
}
var encodedPolicyId = Uri.EscapeDataString(policyId.Trim());
var requestPath = new StringBuilder($"api/policy/findings/{encodedPolicyId}");
var queryString = BuildFindingsQueryString(query);
if (!string.IsNullOrEmpty(queryString))
{
requestPath.Append('?').Append(queryString);
}
using var request = CreateRequest(HttpMethod.Get, requestPath.ToString());
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var items = new List<PolicyFinding>();
var root = document.RootElement;
if (root.TryGetProperty("items", out var itemsElement) && itemsElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in itemsElement.EnumerateArray())
{
items.Add(ParsePolicyFinding(item));
}
}
string? nextCursor = null;
if (root.TryGetProperty("nextCursor", out var cursorElement) && cursorElement.ValueKind == JsonValueKind.String)
{
var value = cursorElement.GetString();
nextCursor = string.IsNullOrWhiteSpace(value) ? null : value;
}
return new PolicyFindingsPage(items.AsReadOnly(), nextCursor);
}
public async Task<PolicyFinding> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (string.IsNullOrWhiteSpace(findingId))
{
throw new ArgumentException("Finding identifier must be provided.", nameof(findingId));
}
var encodedPolicyId = Uri.EscapeDataString(policyId.Trim());
var encodedFindingId = Uri.EscapeDataString(findingId.Trim());
var path = $"api/policy/findings/{encodedPolicyId}/{encodedFindingId}";
using var request = CreateRequest(HttpMethod.Get, path);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return ParsePolicyFinding(document.RootElement);
}
public async Task<PolicyFindingExplain> GetPolicyFindingExplainAsync(string policyId, string findingId, bool verbose, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (string.IsNullOrWhiteSpace(findingId))
{
throw new ArgumentException("Finding identifier must be provided.", nameof(findingId));
}
var encodedPolicyId = Uri.EscapeDataString(policyId.Trim());
var encodedFindingId = Uri.EscapeDataString(findingId.Trim());
var mode = verbose ? "verbose" : "summary";
var path = $"api/policy/findings/{encodedPolicyId}/{encodedFindingId}/explain?mode={mode}";
using var request = CreateRequest(HttpMethod.Get, path);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return ParsePolicyFindingExplain(document.RootElement);
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
@@ -800,6 +1017,37 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
components);
}
public async Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest requestBody, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
ArgumentNullException.ThrowIfNull(requestBody);
using var request = CreateRequest(HttpMethod.Post, "api/aoc/ingest/dry-run");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
request.Content = JsonContent.Create(requestBody, options: SerializerOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
var result = await response.Content.ReadFromJsonAsync<AocIngestDryRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
return result ?? new AocIngestDryRunResponse();
}
catch (JsonException ex)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse ingest dry-run response. {ex.Message}", ex)
{
Data = { ["payload"] = payload }
};
}
}
private string ResolveOfflineDirectory(string destinationDirectory)
{
if (!string.IsNullOrWhiteSpace(destinationDirectory))
@@ -1501,12 +1749,418 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return null;
}
private void EnsureBackendConfigured()
{
if (_httpClient.BaseAddress is null)
{
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
}
private static string BuildFindingsQueryString(PolicyFindingsQuery query)
{
var parameters = new List<string>();
AppendJoinedParameter(parameters, "sbomId", query.SbomIds);
AppendJoinedParameter(parameters, "status", query.Statuses);
AppendJoinedParameter(parameters, "severity", query.Severities);
AppendSingleParameter(parameters, "cursor", query.Cursor);
if (query.Page.HasValue)
{
AppendSingleParameter(parameters, "page", query.Page.Value.ToString(CultureInfo.InvariantCulture));
}
if (query.PageSize.HasValue)
{
AppendSingleParameter(parameters, "pageSize", query.PageSize.Value.ToString(CultureInfo.InvariantCulture));
}
if (query.Since.HasValue)
{
AppendSingleParameter(parameters, "since", query.Since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
}
return string.Join("&", parameters);
}
private static void AppendJoinedParameter(List<string> parameters, string name, IReadOnlyList<string> values)
{
if (values is null || values.Count == 0)
{
return;
}
var normalized = new List<string>();
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
normalized.Add(Uri.EscapeDataString(value.Trim()));
}
if (normalized.Count == 0)
{
return;
}
parameters.Add($"{name}={string.Join(",", normalized)}");
}
private static void AppendSingleParameter(List<string> parameters, string name, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
parameters.Add($"{name}={Uri.EscapeDataString(value)}");
}
private static PolicyFinding ParsePolicyFinding(JsonElement element)
{
var findingId = TryGetString(element, "findingId") ?? string.Empty;
var status = TryGetString(element, "status") ?? "unknown";
string? severityNormalized = null;
double? severityScore = null;
if (element.TryGetProperty("severity", out var severityElement) && severityElement.ValueKind == JsonValueKind.Object)
{
severityNormalized = TryGetString(severityElement, "normalized");
severityScore = TryGetDouble(severityElement, "score");
}
var sbomId = TryGetString(element, "sbomId");
var policyVersion = TryGetInt(element, "policyVersion");
var updatedAt = TryGetTimestamp(element, "updatedAt");
var quieted = TryGetNullableBoolean(element, "quieted");
var quietedBy = TryGetString(element, "quietedBy");
var environment = TryGetString(element, "environment");
string? vexStatementId = null;
if (element.TryGetProperty("vex", out var vexElement) && vexElement.ValueKind == JsonValueKind.Object)
{
vexStatementId = TryGetString(vexElement, "winningStatementId");
}
var advisoryIds = ExtractStringArray(element, "advisoryIds");
var tags = ExtractStringArray(element, "tags");
return new PolicyFinding(
findingId,
status,
severityNormalized,
severityScore,
sbomId,
policyVersion,
updatedAt,
quieted,
quietedBy,
environment,
vexStatementId,
advisoryIds,
tags,
element.GetRawText());
}
private static PolicyFindingExplain ParsePolicyFindingExplain(JsonElement element)
{
var findingId = TryGetString(element, "findingId") ?? string.Empty;
var policyVersion = TryGetInt(element, "policyVersion");
var steps = new List<PolicyFindingExplainStep>();
if (element.TryGetProperty("steps", out var stepsElement) && stepsElement.ValueKind == JsonValueKind.Array)
{
foreach (var stepElement in stepsElement.EnumerateArray())
{
steps.Add(ParseExplainStep(stepElement));
}
}
var hints = new List<string>();
if (element.TryGetProperty("sealedHints", out var hintsElement) && hintsElement.ValueKind == JsonValueKind.Array)
{
foreach (var hint in hintsElement.EnumerateArray())
{
if (hint.ValueKind == JsonValueKind.String)
{
var value = hint.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
hints.Add(value);
}
}
else if (hint.ValueKind == JsonValueKind.Object && hint.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String)
{
var value = messageElement.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
hints.Add(value);
}
}
}
}
return new PolicyFindingExplain(
findingId,
policyVersion,
steps.AsReadOnly(),
hints.AsReadOnly(),
element.GetRawText());
}
private static PolicyFindingExplainStep ParseExplainStep(JsonElement element)
{
var rule = TryGetString(element, "rule");
var status = TryGetString(element, "status");
var inputs = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, "rule", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "status", StringComparison.OrdinalIgnoreCase))
{
continue;
}
inputs[property.Name] = ConvertJsonElement(property.Value);
}
return new PolicyFindingExplainStep(
rule,
status,
new ReadOnlyDictionary<string, object?>(inputs),
element.GetRawText());
}
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String)
{
var value = property.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
return null;
}
private static double? TryGetDouble(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property))
{
return TryGetDouble(property);
}
return null;
}
private static double? TryGetDouble(JsonElement property)
{
return property.ValueKind switch
{
JsonValueKind.Number => property.TryGetDouble(out var number) ? number : null,
JsonValueKind.String => double.TryParse(property.GetString(), NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null,
_ => null
};
}
private static int? TryGetInt(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property))
{
return TryGetInt(property);
}
return null;
}
private static int? TryGetInt(JsonElement property)
{
return property.ValueKind switch
{
JsonValueKind.Number => property.TryGetInt32(out var number) ? number : null,
JsonValueKind.String => int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null,
_ => null
};
}
private static bool? TryGetNullableBoolean(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(property.GetString(), out var parsed) ? parsed : null,
_ => null
};
}
return null;
}
private static DateTimeOffset? TryGetTimestamp(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String)
{
return TryParseTimestamp(property.GetString());
}
return null;
}
private static DateTimeOffset? TryParseTimestamp(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp))
{
return timestamp.ToUniversalTime();
}
return null;
}
private static IReadOnlyList<string> ExtractStringArray(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array)
{
return Array.Empty<string>();
}
var list = new List<string>();
foreach (var item in property.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
list.Add(value);
}
}
}
return list.Count == 0 ? Array.Empty<string>() : new ReadOnlyCollection<string>(list);
}
private static object? ConvertJsonElement(JsonElement element)
=> element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number when element.TryGetInt64(out var l) => l,
JsonValueKind.Number when element.TryGetDouble(out var d) => d,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Object => ConvertObject(element),
JsonValueKind.Array => ConvertArray(element),
_ => element.GetRawText()
};
private static IReadOnlyDictionary<string, object?> ConvertObject(JsonElement element)
{
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
result[property.Name] = ConvertJsonElement(property.Value);
}
return new ReadOnlyDictionary<string, object?>(result);
}
private static IReadOnlyList<object?> ConvertArray(JsonElement element)
{
var list = new List<object?>();
foreach (var item in element.EnumerateArray())
{
list.Add(ConvertJsonElement(item));
}
return new ReadOnlyCollection<object?>(list);
}
private static JsonElement SerializeEnvironmentValue(object? value)
{
if (value is JsonElement element)
{
return element;
}
return JsonSerializer.SerializeToElement<object?>(value, SerializerOptions);
}
private static string? ExtractProblemErrorCode(ProblemDocument? problem)
{
if (problem?.Extensions is null || problem.Extensions.Count == 0)
{
return null;
}
if (problem.Extensions.TryGetValue("code", out var value))
{
switch (value)
{
case string code when !string.IsNullOrWhiteSpace(code):
return code;
case JsonElement element when element.ValueKind == JsonValueKind.String:
var text = element.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
return null;
}
private static PolicySimulationResult MapPolicySimulation(PolicySimulationResponseDocument document)
{
var diffDocument = document.Diff ?? throw new InvalidOperationException("Policy simulation response missing diff summary.");
var severity = diffDocument.BySeverity is null
? new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)
: diffDocument.BySeverity
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value is not null)
.ToDictionary(
kvp => kvp.Key,
kvp => new PolicySimulationSeverityDelta(kvp.Value!.Up, kvp.Value.Down),
StringComparer.Ordinal);
var severityView = new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(severity);
var ruleHits = diffDocument.RuleHits is null
? new List<PolicySimulationRuleDelta>()
: diffDocument.RuleHits
.Where(hit => hit is not null)
.Select(hit => new PolicySimulationRuleDelta(
hit!.RuleId ?? string.Empty,
hit.RuleName ?? string.Empty,
hit.Up,
hit.Down))
.ToList();
var ruleHitsView = ruleHits.AsReadOnly();
var diff = new PolicySimulationDiff(
string.IsNullOrWhiteSpace(diffDocument.SchemaVersion) ? null : diffDocument.SchemaVersion,
diffDocument.Added ?? 0,
diffDocument.Removed ?? 0,
diffDocument.Unchanged ?? 0,
severityView,
ruleHitsView);
return new PolicySimulationResult(
diff,
string.IsNullOrWhiteSpace(document.ExplainUri) ? null : document.ExplainUri);
}
private void EnsureBackendConfigured()
{
if (_httpClient.BaseAddress is null)
{
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
}
}
private string ResolveArtifactPath(string outputPath, string channel)
@@ -1525,45 +2179,59 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return Path.Combine(directory, fileName);
}
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var statusCode = (int)response.StatusCode;
var builder = new StringBuilder();
builder.Append("Backend request failed with status ");
builder.Append(statusCode);
builder.Append(' ');
builder.Append(response.ReasonPhrase ?? "Unknown");
if (response.Content.Headers.ContentLength is > 0)
{
try
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (problem is not null)
{
if (!string.IsNullOrWhiteSpace(problem.Title))
{
builder.AppendLine().Append(problem.Title);
}
if (!string.IsNullOrWhiteSpace(problem.Detail))
{
builder.AppendLine().Append(problem.Detail);
}
}
}
catch (JsonException)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(raw))
{
builder.AppendLine().Append(raw);
}
}
}
return builder.ToString();
}
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
return message;
}
private async Task<(string Message, ProblemDocument? Problem)> CreateFailureDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var statusCode = (int)response.StatusCode;
var builder = new StringBuilder();
builder.Append("Backend request failed with status ");
builder.Append(statusCode);
builder.Append(' ');
builder.Append(response.ReasonPhrase ?? "Unknown");
ProblemDocument? problem = null;
if (response.Content is not null && response.Content.Headers.ContentLength is > 0)
{
string? raw = null;
try
{
raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(raw))
{
problem = JsonSerializer.Deserialize<ProblemDocument>(raw, SerializerOptions);
}
}
catch (JsonException)
{
problem = null;
}
if (problem is not null)
{
if (!string.IsNullOrWhiteSpace(problem.Title))
{
builder.AppendLine().Append(problem.Title);
}
if (!string.IsNullOrWhiteSpace(problem.Detail))
{
builder.AppendLine().Append(problem.Detail);
}
}
else if (!string.IsNullOrWhiteSpace(raw))
{
builder.AppendLine().Append(raw);
}
}
return (builder.ToString(), problem);
}
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
{

View File

@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
private readonly HttpClient httpClient;
private readonly StellaOpsCliOptions options;
private readonly ILogger<ConcelierObservationsClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public ConcelierObservationsClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<ConcelierObservationsClient> logger,
IStellaOpsTokenClient? tokenClient = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.tokenClient = tokenClient;
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
EnsureConfigured();
var requestUri = BuildRequestUri(query);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to query observations (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
response.EnsureSuccessStatusCode();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<AdvisoryObservationsResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new AdvisoryObservationsResponse();
}
private static string BuildRequestUri(AdvisoryObservationsQuery query)
{
var builder = new StringBuilder("/concelier/observations?tenant=");
builder.Append(Uri.EscapeDataString(query.Tenant));
AppendValues(builder, "observationId", query.ObservationIds);
AppendValues(builder, "alias", query.Aliases);
AppendValues(builder, "purl", query.Purls);
AppendValues(builder, "cpe", query.Cpes);
return builder.ToString();
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
{
if (values is null || values.Count == 0)
{
return;
}
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
builder.Append('&');
builder.Append(name);
builder.Append('=');
builder.Append(Uri.EscapeDataString(value));
}
}
}
private void EnsureConfigured()
{
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
{
return;
}
throw new InvalidOperationException(
"ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_URL.");
}
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(options.ApiKey))
{
return options.ApiKey;
}
if (tokenClient is null || string.IsNullOrWhiteSpace(options.Authority.Url))
{
return null;
}
var now = DateTimeOffset.UtcNow;
lock (tokenSync)
{
if (!string.IsNullOrEmpty(cachedAccessToken) && now < cachedAccessTokenExpiresAt - TokenRefreshSkew)
{
return cachedAccessToken;
}
}
var (scope, cacheKey) = BuildScopeAndCacheKey(options);
var cachedEntry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
{
lock (tokenSync)
{
cachedAccessToken = cachedEntry.AccessToken;
cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
return cachedAccessToken;
}
}
StellaOpsTokenResult token;
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
{
if (string.IsNullOrWhiteSpace(options.Authority.Password))
{
throw new InvalidOperationException("Authority password must be configured when username is provided.");
}
token = await tokenClient.RequestPasswordTokenAsync(
options.Authority.Username,
options.Authority.Password!,
scope,
cancellationToken).ConfigureAwait(false);
}
else
{
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false);
}
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = token.AccessToken;
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
return cachedAccessToken;
}
}
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
{
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
? $"user:{options.Authority.Username}"
: $"client:{options.Authority.ClientId}";
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
return (finalScope, cacheKey);
}
private static string EnsureScope(string scopes, string required)
{
if (string.IsNullOrWhiteSpace(scopes))
{
return required;
}
var parts = scopes
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static scope => scope.ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.ToList();
if (!parts.Contains(required, StringComparer.Ordinal))
{
parts.Add(required);
}
return string.Join(' ', parts);
}
}

View File

@@ -23,9 +23,19 @@ internal interface IBackendOperationsClient
Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken);
Task<PolicyFindingsPage> GetPolicyFindingsAsync(string policyId, PolicyFindingsQuery query, CancellationToken cancellationToken);
Task<PolicyFinding> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken);
Task<PolicyFindingExplain> GetPolicyFindingExplainAsync(string policyId, string findingId, bool verbose, CancellationToken cancellationToken);
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);
Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken);
Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken);
Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal interface IConcelierObservationsClient
{
Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
internal sealed record AdvisoryObservationsQuery(
string Tenant,
IReadOnlyList<string> ObservationIds,
IReadOnlyList<string> Aliases,
IReadOnlyList<string> Purls,
IReadOnlyList<string> Cpes);
internal sealed class AdvisoryObservationsResponse
{
[JsonPropertyName("observations")]
public IReadOnlyList<AdvisoryObservationDocument> Observations { get; init; } =
Array.Empty<AdvisoryObservationDocument>();
[JsonPropertyName("linkset")]
public AdvisoryObservationLinksetAggregate Linkset { get; init; } =
new();
}
internal sealed class AdvisoryObservationDocument
{
[JsonPropertyName("observationId")]
public string ObservationId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("source")]
public AdvisoryObservationSource Source { get; init; } = new();
[JsonPropertyName("upstream")]
public AdvisoryObservationUpstream Upstream { get; init; } = new();
[JsonPropertyName("linkset")]
public AdvisoryObservationLinkset Linkset { get; init; } = new();
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
}
internal sealed class AdvisoryObservationSource
{
[JsonPropertyName("vendor")]
public string Vendor { get; init; } = string.Empty;
[JsonPropertyName("stream")]
public string Stream { get; init; } = string.Empty;
[JsonPropertyName("api")]
public string Api { get; init; } = string.Empty;
[JsonPropertyName("collectorVersion")]
public string? CollectorVersion { get; init; }
}
internal sealed class AdvisoryObservationUpstream
{
[JsonPropertyName("upstreamId")]
public string UpstreamId { get; init; } = string.Empty;
[JsonPropertyName("documentVersion")]
public string? DocumentVersion { get; init; }
}
internal sealed class AdvisoryObservationLinkset
{
[JsonPropertyName("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
[JsonPropertyName("purls")]
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
[JsonPropertyName("cpes")]
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
[JsonPropertyName("references")]
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
Array.Empty<AdvisoryObservationReference>();
}
internal sealed class AdvisoryObservationReference
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
}
internal sealed class AdvisoryObservationLinksetAggregate
{
[JsonPropertyName("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
[JsonPropertyName("purls")]
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
[JsonPropertyName("cpes")]
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
[JsonPropertyName("references")]
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
Array.Empty<AdvisoryObservationReference>();
}

View File

@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
internal sealed class AocIngestDryRunRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("source")]
public string Source { get; init; } = string.Empty;
[JsonPropertyName("document")]
public AocIngestDryRunDocument Document { get; init; } = new();
}
internal sealed class AocIngestDryRunDocument
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("content")]
public string Content { get; init; } = string.Empty;
[JsonPropertyName("contentType")]
public string ContentType { get; init; } = "application/json";
[JsonPropertyName("contentEncoding")]
public string? ContentEncoding { get; init; }
}
internal sealed class AocIngestDryRunResponse
{
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("guardVersion")]
public string? GuardVersion { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("document")]
public AocIngestDryRunDocumentResult Document { get; init; } = new();
[JsonPropertyName("violations")]
public IReadOnlyList<AocIngestDryRunViolation> Violations { get; init; } =
Array.Empty<AocIngestDryRunViolation>();
}
internal sealed class AocIngestDryRunDocumentResult
{
[JsonPropertyName("contentHash")]
public string? ContentHash { get; init; }
[JsonPropertyName("supersedes")]
public string? Supersedes { get; init; }
[JsonPropertyName("provenance")]
public AocIngestDryRunProvenance Provenance { get; init; } = new();
}
internal sealed class AocIngestDryRunProvenance
{
[JsonPropertyName("signature")]
public AocIngestDryRunSignature Signature { get; init; } = new();
}
internal sealed class AocIngestDryRunSignature
{
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("present")]
public bool Present { get; init; }
}
internal sealed class AocIngestDryRunViolation
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string? Path { get; init; }
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicyFindingsQuery(
IReadOnlyList<string> SbomIds,
IReadOnlyList<string> Statuses,
IReadOnlyList<string> Severities,
string? Cursor,
int? Page,
int? PageSize,
DateTimeOffset? Since);
internal sealed record PolicyFindingsPage(
IReadOnlyList<PolicyFinding> Items,
string? NextCursor);
internal sealed record PolicyFinding(
string FindingId,
string Status,
string? SeverityNormalized,
double? SeverityScore,
string? SbomId,
int? PolicyVersion,
DateTimeOffset? UpdatedAt,
bool? Quieted,
string? QuietedBy,
string? Environment,
string? VexStatementId,
IReadOnlyList<string> AdvisoryIds,
IReadOnlyList<string> Tags,
string RawJson);
internal sealed record PolicyFindingExplain(
string FindingId,
int? PolicyVersion,
IReadOnlyList<PolicyFindingExplainStep> Steps,
IReadOnlyList<string> SealedHints,
string RawJson);
internal sealed record PolicyFindingExplainStep(
string? Rule,
string? Status,
IReadOnlyDictionary<string, object?> Inputs,
string RawJson);

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicySimulationInput(
int? BaseVersion,
int? CandidateVersion,
IReadOnlyList<string> SbomSet,
IReadOnlyDictionary<string, object?> Environment,
bool Explain);
internal sealed record PolicySimulationResult(
PolicySimulationDiff Diff,
string? ExplainUri);
internal sealed record PolicySimulationDiff(
string? SchemaVersion,
int Added,
int Removed,
int Unchanged,
IReadOnlyDictionary<string, PolicySimulationSeverityDelta> BySeverity,
IReadOnlyList<PolicySimulationRuleDelta> RuleHits);
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class PolicySimulationRequestDocument
{
public int? BaseVersion { get; set; }
public int? CandidateVersion { get; set; }
public IReadOnlyList<string>? SbomSet { get; set; }
public Dictionary<string, JsonElement>? Env { get; set; }
public bool? Explain { get; set; }
}
internal sealed class PolicySimulationResponseDocument
{
public PolicySimulationDiffDocument? Diff { get; set; }
public string? ExplainUri { get; set; }
}
internal sealed class PolicySimulationDiffDocument
{
public string? SchemaVersion { get; set; }
public int? Added { get; set; }
public int? Removed { get; set; }
public int? Unchanged { get; set; }
public Dictionary<string, PolicySimulationSeverityDeltaDocument>? BySeverity { get; set; }
public List<PolicySimulationRuleDeltaDocument>? RuleHits { get; set; }
}
internal sealed class PolicySimulationSeverityDeltaDocument
{
public int? Up { get; set; }
public int? Down { get; set; }
}
internal sealed class PolicySimulationRuleDeltaDocument
{
public string? RuleId { get; set; }
public string? RuleName { get; set; }
public int? Up { get; set; }
public int? Down { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Net;
namespace StellaOps.Cli.Services;
internal sealed class PolicyApiException : Exception
{
public PolicyApiException(string message, HttpStatusCode statusCode, string? errorCode, Exception? innerException = null)
: base(message, innerException)
{
StatusCode = statusCode;
ErrorCode = errorCode;
}
public HttpStatusCode StatusCode { get; }
public string? ErrorCode { get; }
}

View File

@@ -1,8 +1,10 @@
# CLI Task Board — Epic 1: Aggregation-Only Contract
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-AOC-19-001 | TODO | DevEx/CLI Guild | CONCELIER-WEB-AOC-19-001, EXCITITOR-WEB-AOC-19-001 | Implement `stella sources ingest --dry-run` printing would-write payloads with forbidden field scan results and guard status. | Command displays diff-safe JSON, highlights forbidden fields, exits non-zero on guard violation, and has unit tests. |
| CLI-AOC-19-001 | DOING (2025-10-27) | DevEx/CLI Guild | CONCELIER-WEB-AOC-19-001, EXCITITOR-WEB-AOC-19-001 | Implement `stella sources ingest --dry-run` printing would-write payloads with forbidden field scan results and guard status. | Command displays diff-safe JSON, highlights forbidden fields, exits non-zero on guard violation, and has unit tests. |
> Docs ready (2025-10-26): Reference behaviour/spec in `docs/cli/cli-reference.md` §2 and AOC reference §5.
> 2025-10-27: CLI command scaffolded with backend client call, JSON/table output, gzip/base64 normalisation, and exit-code mapping. Awaiting Concelier dry-run endpoint + integration tests once backend lands.
> 2025-10-27: Progress paused before adding CLI unit tests; blocked on extending `StubBackendClient` + fixtures for `ExecuteAocIngestDryRunAsync` coverage.
| CLI-AOC-19-002 | TODO | DevEx/CLI Guild | CLI-AOC-19-001 | Add `stella aoc verify` command supporting `--since`/`--limit`, mapping `ERR_AOC_00x` to exit codes, with JSON/table output. | Command integrates with both services, exit codes documented, regression tests green. |
> Docs ready (2025-10-26): CLI guide §3 covers options/exit codes; deployment doc `docs/deploy/containers.md` describes required verifier user.
| CLI-AOC-19-003 | TODO | Docs/CLI Guild | CLI-AOC-19-001, CLI-AOC-19-002 | Update CLI reference and quickstart docs to cover new commands, exit codes, and offline verification workflows. | Docs updated; examples recorded; release notes mention new commands. |
@@ -13,9 +15,12 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-POLICY-20-001 | TODO | DevEx/CLI Guild | WEB-POLICY-20-001 | Add `stella policy new|edit|submit|approve` commands with local editor integration, version pinning, and approval workflow wiring. | Commands round-trip policy drafts with temp files; approval requires correct scopes; unit tests cover happy/error paths. |
| CLI-POLICY-20-002 | TODO | DevEx/CLI Guild | CLI-POLICY-20-001, WEB-POLICY-20-001, WEB-POLICY-20-002 | Implement `stella policy simulate` with SBOM/env arguments and diff output (table/JSON), handling exit codes for `ERR_POL_*`. | Simulation outputs deterministic diffs; JSON schema documented; tests validate exit codes + piping of env variables. |
| CLI-POLICY-20-002 | DONE (2025-10-27) | DevEx/CLI Guild | CLI-POLICY-20-001, WEB-POLICY-20-001, WEB-POLICY-20-002 | Implement `stella policy simulate` with SBOM/env arguments and diff output (table/JSON), handling exit codes for `ERR_POL_*`. | Simulation outputs deterministic diffs; JSON schema documented; tests validate exit codes + piping of env variables. |
> 2025-10-26: Scheduler Models expose canonical run/diff schemas (`src/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`). Schema exporter lives at `scripts/export-policy-schemas.sh`; wire schema validation once DevOps publishes artifacts (see DEVOPS-POLICY-20-004).
> 2025-10-27: DevOps pipeline now publishes `policy-schema-exports` artefacts per commit (see `.gitea/workflows/build-test-deploy.yml`); Slack `#policy-engine` alerts trigger on schema diffs. Pull the JSON from the CI artifact instead of committing local copies.
> 2025-10-27: CLI command supports table/JSON output, environment parsing, `--fail-on-diff`, and maps `ERR_POL_*` to exit codes; tested in `StellaOps.Cli.Tests` against stubbed backend.
| CLI-POLICY-20-003 | TODO | DevEx/CLI Guild, Docs Guild | CLI-POLICY-20-002, WEB-POLICY-20-003, DOCS-POLICY-20-006 | Extend `stella findings ls|get` commands for policy-filtered retrieval with pagination, severity filters, and explain output. | Commands stream paginated results; explain view renders rationale entries; docs/help updated; end-to-end tests cover filters. |
> 2025-10-27: Work paused after stubbing backend parsing helpers; command wiring/tests still pending. Resume by finishing backend query serialization + CLI output paths.
## Graph Explorer v1
@@ -61,9 +66,13 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-POLICY-27-001 | TODO | DevEx/CLI Guild | REGISTRY-API-27-001, WEB-POLICY-27-001 | Implement policy workspace commands (`stella policy init`, `edit`, `lint`, `compile`, `test`) with template selection, local cache, JSON output, and deterministic temp directories. | Commands operate offline with cached templates; diagnostics mirror API responses; unit tests cover happy/error paths; help text updated. |
> Docs dependency: `DOCS-POLICY-27-007` blocked until CLI commands + help output land.
| CLI-POLICY-27-002 | TODO | DevEx/CLI Guild | REGISTRY-API-27-006, WEB-POLICY-27-002 | Add submission/review workflow commands (`stella policy version bump`, `submit`, `review comment`, `approve`, `reject`) supporting reviewer assignment, changelog capture, and exit codes. | Workflow commands enforce required approvers; comments upload correctly; integration tests cover approval failure; docs updated. |
> Docs dependency: `DOCS-POLICY-27-007` and `DOCS-POLICY-27-006` require review/promotion CLI flows.
| CLI-POLICY-27-003 | TODO | DevEx/CLI Guild | REGISTRY-API-27-005, SCHED-CONSOLE-27-001 | Implement `stella policy simulate` enhancements (quick vs batch, SBOM selectors, heatmap summary, manifest download) with `--json` and Markdown report output for CI. | CLI can trigger batch sim, poll progress, download artifacts; outputs deterministic schemas; CI sample workflow documented; tests cover cancellation/timeouts. |
> Docs dependency: `DOCS-POLICY-27-004` needs simulate CLI examples.
| CLI-POLICY-27-004 | TODO | DevEx/CLI Guild | REGISTRY-API-27-007, REGISTRY-API-27-008, AUTH-POLICY-27-002 | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. | Commands enforce signing requirement, support dry-run, produce audit logs; integration tests cover promotion + rollback; documentation updated. |
> Docs dependency: `DOCS-POLICY-27-006` requires publish/promote/rollback CLI examples.
| CLI-POLICY-27-005 | TODO | DevEx/CLI Guild, Docs Guild | DOCS-CONSOLE-27-007, DOCS-POLICY-27-007 | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. | CLI docs merged with screenshots/transcripts; parity matrix updated; acceptance tests ensure `--help` examples compile. |
## Vulnerability Explorer (Sprint 29)

View File

@@ -12,6 +12,8 @@ internal static class CliMetrics
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
private static readonly Counter<long> OfflineKitDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.download.count");
private static readonly Counter<long> OfflineKitImportCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.import.count");
private static readonly Counter<long> PolicySimulationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.simulate.count");
private static readonly Counter<long> SourcesDryRunCounter = Meter.CreateCounter<long>("stellaops.cli.sources.dryrun.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
@@ -44,6 +46,18 @@ internal static class CliMetrics
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)
});
public static void RecordPolicySimulation(string outcome)
=> PolicySimulationCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordSourcesDryRun(string status)
=> SourcesDryRunCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("status", string.IsNullOrWhiteSpace(status) ? "unknown" : status)
});
public static IDisposable MeasureCommandDuration(string command)
{
var start = DateTime.UtcNow;

View File

@@ -1,8 +1,9 @@
{
"StellaOps": {
"ApiKey": "",
"BackendUrl": "",
"ScannerCacheDirectory": "scanners",
"ApiKey": "",
"BackendUrl": "",
"ConcelierUrl": "",
"ScannerCacheDirectory": "scanners",
"ResultsDirectory": "results",
"DefaultRunner": "dotnet",
"ScannerSignaturePublicKeyPath": "",

View File

@@ -0,0 +1,231 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Observations;
public sealed class AdvisoryObservationQueryServiceTests
{
private static readonly AdvisoryObservationSource DefaultSource = new("ghsa", "stream", "https://example.test/api");
private static readonly AdvisoryObservationSignature DefaultSignature = new(false, null, null, null);
[Fact]
public async Task QueryAsync_WhenNoFilters_ReturnsTenantObservationsSortedAndAggregated()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "Tenant-A",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:npm/package-a@1.0.0" },
cpes: new[] { "cpe:/a:vendor:product:1.0" },
references: new[]
{
new AdvisoryObservationReference("advisory", "https://example.test/advisory-1")
},
createdAt: DateTimeOffset.UtcNow.AddMinutes(-5)),
CreateObservation(
observationId: "tenant-a:osv:beta:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0002", "GHSA-xyzz" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: Array.Empty<string>(),
references: new[]
{
new AdvisoryObservationReference("advisory", "https://example.test/advisory-2"),
new AdvisoryObservationReference("patch", "https://example.test/patch-1")
},
createdAt: DateTimeOffset.UtcNow)
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var result = await service.QueryAsync(new AdvisoryObservationQueryOptions("tenant-a"), CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
Assert.Equal("tenant-a:osv:beta:1", result.Observations[0].ObservationId);
Assert.Equal("tenant-a:ghsa:alpha:1", result.Observations[1].ObservationId);
Assert.Equal(
new[] { "cve-2025-0001", "cve-2025-0002", "ghsa-xyzz" },
result.Linkset.Aliases);
Assert.Equal(
new[] { "pkg:npm/package-a@1.0.0", "pkg:pypi/package-b@2.0.0" },
result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:1.0" }, result.Linkset.Cpes);
Assert.Equal(3, result.Linkset.References.Length);
Assert.Equal("advisory", result.Linkset.References[0].Type);
Assert.Equal("https://example.test/advisory-1", result.Linkset.References[0].Url);
Assert.Equal("https://example.test/advisory-2", result.Linkset.References[1].Url);
Assert.Equal("patch", result.Linkset.References[2].Type);
}
[Fact]
public async Task QueryAsync_WithAliasFilter_UsesAliasLookupAndFilters()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow),
CreateObservation(
observationId: "tenant-a:nvd:gamma:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-9999" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-10))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var result = await service.QueryAsync(
new AdvisoryObservationQueryOptions("TEnant-A", aliases: new[] { " CVE-2025-0001 ", "CVE-2025-9999" }),
CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
Assert.All(result.Observations, observation =>
Assert.Contains(observation.Linkset.Aliases, alias => alias is "cve-2025-0001" or "cve-2025-9999"));
}
[Fact]
public async Task QueryAsync_WithObservationIdAndLinksetFilters_ReturnsIntersection()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:npm/package-a@1.0.0" },
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow),
CreateObservation(
observationId: "tenant-a:ghsa:beta:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" },
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-1))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var options = new AdvisoryObservationQueryOptions(
tenant: "tenant-a",
observationIds: new[] { "tenant-a:ghsa:beta:1" },
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" });
var result = await service.QueryAsync(options, CancellationToken.None);
Assert.Single(result.Observations);
Assert.Equal("tenant-a:ghsa:beta:1", result.Observations[0].ObservationId);
Assert.Equal(new[] { "pkg:pypi/package-b@2.0.0" }, result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:2.0" }, result.Linkset.Cpes);
}
private static AdvisoryObservation CreateObservation(
string observationId,
string tenant,
IEnumerable<string> aliases,
IEnumerable<string> purls,
IEnumerable<string> cpes,
IEnumerable<AdvisoryObservationReference> references,
DateTimeOffset createdAt)
{
var raw = JsonNode.Parse("""{"message":"payload"}""") ?? throw new InvalidOperationException("Raw payload must not be null.");
var upstream = new AdvisoryObservationUpstream(
upstreamId: observationId,
documentVersion: null,
fetchedAt: createdAt,
receivedAt: createdAt,
contentHash: $"sha256:{observationId}",
signature: DefaultSignature);
var content = new AdvisoryObservationContent("CSAF", "2.0", raw);
var linkset = new AdvisoryObservationLinkset(aliases, purls, cpes, references);
return new AdvisoryObservation(
observationId,
tenant,
DefaultSource,
upstream,
content,
linkset,
createdAt);
}
private sealed class InMemoryLookup : IAdvisoryObservationLookup
{
private readonly ImmutableDictionary<string, ImmutableArray<AdvisoryObservation>> _observationsByTenant;
public InMemoryLookup(IEnumerable<AdvisoryObservation> observations)
{
ArgumentNullException.ThrowIfNull(observations);
_observationsByTenant = observations
.GroupBy(static observation => observation.Tenant, StringComparer.Ordinal)
.ToImmutableDictionary(
static group => group.Key,
static group => group.ToImmutableArray(),
StringComparer.Ordinal);
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
cancellationToken.ThrowIfCancellationRequested();
if (_observationsByTenant.TryGetValue(tenant, out var observations))
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(observations);
}
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByAliasesAsync(
string tenant,
IReadOnlyCollection<string> aliases,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(aliases);
cancellationToken.ThrowIfCancellationRequested();
if (!_observationsByTenant.TryGetValue(tenant, out var observations) || aliases.Count == 0)
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
var aliasSet = aliases.ToImmutableHashSet(StringComparer.Ordinal);
var matches = observations
.Where(observation => observation.Linkset.Aliases.Any(aliasSet.Contains))
.ToImmutableArray();
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(matches);
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Query options for retrieving advisory observations scoped to a tenant.
/// </summary>
public sealed record AdvisoryObservationQueryOptions
{
public AdvisoryObservationQueryOptions(
string tenant,
IReadOnlyCollection<string>? observationIds = null,
IReadOnlyCollection<string>? aliases = null,
IReadOnlyCollection<string>? purls = null,
IReadOnlyCollection<string>? cpes = null)
{
Tenant = Validation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
ObservationIds = observationIds ?? Array.Empty<string>();
Aliases = aliases ?? Array.Empty<string>();
Purls = purls ?? Array.Empty<string>();
Cpes = cpes ?? Array.Empty<string>();
}
/// <summary>
/// Tenant identifier used for scoping queries (case-insensitive).
/// </summary>
public string Tenant { get; }
/// <summary>
/// Optional set of observation identifiers to include.
/// </summary>
public IReadOnlyCollection<string> ObservationIds { get; }
/// <summary>
/// Optional set of alias identifiers (e.g., CVE/GHSA) to filter by.
/// </summary>
public IReadOnlyCollection<string> Aliases { get; }
/// <summary>
/// Optional set of Package URLs to filter by.
/// </summary>
public IReadOnlyCollection<string> Purls { get; }
/// <summary>
/// Optional set of CPE values to filter by.
/// </summary>
public IReadOnlyCollection<string> Cpes { get; }
}
/// <summary>
/// Query result containing observations and their aggregated linkset hints.
/// </summary>
public sealed record AdvisoryObservationQueryResult(
ImmutableArray<AdvisoryObservation> Observations,
AdvisoryObservationLinksetAggregate Linkset);
/// <summary>
/// Aggregated linkset built from the observations returned by a query.
/// </summary>
public sealed record AdvisoryObservationLinksetAggregate(
ImmutableArray<string> Aliases,
ImmutableArray<string> Purls,
ImmutableArray<string> Cpes,
ImmutableArray<AdvisoryObservationReference> References);

View File

@@ -0,0 +1,164 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Default implementation of <see cref="IAdvisoryObservationQueryService"/> that projects raw observations for overlay consumers.
/// </summary>
public sealed class AdvisoryObservationQueryService : IAdvisoryObservationQueryService
{
private readonly IAdvisoryObservationLookup _lookup;
public AdvisoryObservationQueryService(IAdvisoryObservationLookup lookup)
{
_lookup = lookup ?? throw new ArgumentNullException(nameof(lookup));
}
public async ValueTask<AdvisoryObservationQueryResult> QueryAsync(
AdvisoryObservationQueryOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var normalizedTenant = NormalizeTenant(options.Tenant);
var normalizedObservationIds = NormalizeSet(options.ObservationIds, static value => value, StringComparer.Ordinal);
var normalizedAliases = NormalizeSet(options.Aliases, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var normalizedPurls = NormalizeSet(options.Purls, static value => value, StringComparer.Ordinal);
var normalizedCpes = NormalizeSet(options.Cpes, static value => value, StringComparer.Ordinal);
IReadOnlyList<AdvisoryObservation> observations;
if (normalizedAliases.Count > 0)
{
observations = await _lookup
.FindByAliasesAsync(normalizedTenant, normalizedAliases, cancellationToken)
.ConfigureAwait(false);
}
else
{
observations = await _lookup
.ListByTenantAsync(normalizedTenant, cancellationToken)
.ConfigureAwait(false);
}
var matched = observations
.Where(observation => Matches(observation, normalizedObservationIds, normalizedAliases, normalizedPurls, normalizedCpes))
.OrderByDescending(static observation => observation.CreatedAt)
.ThenBy(static observation => observation.ObservationId, StringComparer.Ordinal)
.ToImmutableArray();
var linkset = BuildAggregateLinkset(matched);
return new AdvisoryObservationQueryResult(matched, linkset);
}
private static bool Matches(
AdvisoryObservation observation,
ImmutableHashSet<string> observationIds,
ImmutableHashSet<string> aliases,
ImmutableHashSet<string> purls,
ImmutableHashSet<string> cpes)
{
ArgumentNullException.ThrowIfNull(observation);
if (observationIds.Count > 0 && !observationIds.Contains(observation.ObservationId))
{
return false;
}
if (aliases.Count > 0 && !observation.Linkset.Aliases.Any(aliases.Contains))
{
return false;
}
if (purls.Count > 0 && !observation.Linkset.Purls.Any(purls.Contains))
{
return false;
}
if (cpes.Count > 0 && !observation.Linkset.Cpes.Any(cpes.Contains))
{
return false;
}
return true;
}
private static string NormalizeTenant(string tenant)
=> Validation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant)).ToLowerInvariant();
private static ImmutableHashSet<string> NormalizeSet(
IEnumerable<string>? values,
Func<string, string> projector,
StringComparer comparer)
{
if (values is null)
{
return ImmutableHashSet<string>.Empty;
}
var builder = ImmutableHashSet.CreateBuilder<string>(comparer);
foreach (var value in values)
{
var normalized = Validation.TrimToNull(value);
if (normalized is null)
{
continue;
}
builder.Add(projector(normalized));
}
return builder.ToImmutable();
}
private static AdvisoryObservationLinksetAggregate BuildAggregateLinkset(ImmutableArray<AdvisoryObservation> observations)
{
if (observations.IsDefaultOrEmpty)
{
return new AdvisoryObservationLinksetAggregate(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<AdvisoryObservationReference>.Empty);
}
var aliasSet = new HashSet<string>(StringComparer.Ordinal);
var purlSet = new HashSet<string>(StringComparer.Ordinal);
var cpeSet = new HashSet<string>(StringComparer.Ordinal);
var referenceSet = new HashSet<AdvisoryObservationReference>();
foreach (var observation in observations)
{
foreach (var alias in observation.Linkset.Aliases)
{
aliasSet.Add(alias);
}
foreach (var purl in observation.Linkset.Purls)
{
purlSet.Add(purl);
}
foreach (var cpe in observation.Linkset.Cpes)
{
cpeSet.Add(cpe);
}
foreach (var reference in observation.Linkset.References)
{
referenceSet.Add(reference);
}
}
return new AdvisoryObservationLinksetAggregate(
aliasSet.OrderBy(static alias => alias, StringComparer.Ordinal).ToImmutableArray(),
purlSet.OrderBy(static purl => purl, StringComparer.Ordinal).ToImmutableArray(),
cpeSet.OrderBy(static cpe => cpe, StringComparer.Ordinal).ToImmutableArray(),
referenceSet
.OrderBy(static reference => reference.Type, StringComparer.Ordinal)
.ThenBy(static reference => reference.Url, StringComparer.Ordinal)
.ToImmutableArray());
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Abstraction over the advisory observation persistence layer used for overlay queries.
/// </summary>
public interface IAdvisoryObservationLookup
{
/// <summary>
/// Lists all advisory observations for the provided tenant.
/// </summary>
/// <param name="tenant">Tenant identifier (case-insensitive).</param>
/// <param name="cancellationToken">A cancellation token.</param>
ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken);
/// <summary>
/// Finds advisory observations for a tenant that match at least one of the supplied aliases.
/// </summary>
/// <param name="tenant">Tenant identifier (case-insensitive).</param>
/// <param name="aliases">Normalized alias values to match against.</param>
/// <param name="cancellationToken">A cancellation token.</param>
ValueTask<IReadOnlyList<AdvisoryObservation>> FindByAliasesAsync(
string tenant,
IReadOnlyCollection<string> aliases,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Provides read-only access to advisory observations for overlay services.
/// </summary>
public interface IAdvisoryObservationQueryService
{
/// <summary>
/// Queries advisory observations scoped by tenant and optional linkset filters.
/// </summary>
/// <param name="options">Query options defining tenant and filter criteria.</param>
/// <param name="cancellationToken">A cancellation token.</param>
ValueTask<AdvisoryObservationQueryResult> QueryAsync(
AdvisoryObservationQueryOptions options,
CancellationToken cancellationToken);
}

View File

@@ -4,6 +4,7 @@
|---|---|---|---|---|
| CONCELIER-CORE-AOC-19-001 `AOC write guard` | TODO | Concelier Core Guild | WEB-AOC-19-001 | Implement repository interceptor that inspects write payloads for forbidden AOC keys, validates provenance/signature presence, and maps violations to `ERR_AOC_00x`. |
> Docs alignment (2025-10-26): Behaviour/spec captured in `docs/ingestion/aggregation-only-contract.md` and architecture overview §2.
> Coordination (2025-10-27): Authority `dotnet test` run is currently blocked because `AdvisoryObservationQueryService.BuildAliasLookup` returns `ImmutableHashSet<string?>`; please normalise these lookups to `ImmutableHashSet<string>` (trim nulls) so downstream builds succeed.
| CONCELIER-CORE-AOC-19-002 `Deterministic linkset extraction` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Build canonical linkset mappers for CVE/GHSA/PURL/CPE/reference extraction from upstream raw payloads, ensuring reconciled-from metadata is tracked and deterministic. |
> Docs alignment (2025-10-26): Linkset expectations detailed in AOC reference §4 and policy-engine architecture §2.1.
| CONCELIER-CORE-AOC-19-003 `Idempotent append-only upsert` | TODO | Concelier Core Guild | CONCELIER-STORE-AOC-19-002 | Implement idempotent upsert path using `(vendor, upstreamId, contentHash, tenant)` key, emitting supersedes pointers for new revisions and preventing duplicate inserts. |
@@ -22,16 +23,18 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| CONCELIER-GRAPH-21-001 `SBOM projection enrichment` | TODO | Concelier Core Guild, Cartographer Guild | CONCELIER-POLICY-20-002, CARTO-GRAPH-21-002 | Extend SBOM normalization to emit full relationship graph (depends_on/contains/provides), scope tags, entrypoint annotations, and component metadata required by Cartographer. |
| CONCELIER-GRAPH-21-002 `Change events` | TODO | Concelier Core Guild, Scheduler Guild | CONCELIER-GRAPH-21-001 | Publish change events (new SBOM version, relationship delta) for Cartographer build queue; ensure events include tenant/context metadata. |
| CONCELIER-GRAPH-21-001 `SBOM projection enrichment` | BLOCKED (2025-10-27) | Concelier Core Guild, Cartographer Guild | CONCELIER-POLICY-20-002, CARTO-GRAPH-21-002 | Extend SBOM normalization to emit full relationship graph (depends_on/contains/provides), scope tags, entrypoint annotations, and component metadata required by Cartographer. |
> 2025-10-27: Waiting on policy-driven linkset enrichment (`CONCELIER-POLICY-20-002`) and Cartographer API contract (`CARTO-GRAPH-21-002`) to define required relationship payloads. Without those schemas the projection changes cannot be implemented deterministically.
| CONCELIER-GRAPH-21-002 `Change events` | BLOCKED (2025-10-27) | Concelier Core Guild, Scheduler Guild | CONCELIER-GRAPH-21-001 | Publish change events (new SBOM version, relationship delta) for Cartographer build queue; ensure events include tenant/context metadata. |
> 2025-10-27: Depends on `CONCELIER-GRAPH-21-001`; event schema hinges on finalized projection output and Cartographer webhook contract, both pending.
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| CONCELIER-LNM-21-001 `Advisory observation schema` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Introduce immutable `advisory_observations` model with AOC metadata, raw payload pointers, normalized fields, and tenancy guardrails; publish schema definition. |
| CONCELIER-LNM-21-002 `Linkset builder` | TODO | Concelier Core Guild, Data Science Guild | CONCELIER-LNM-21-001 | Implement correlation pipeline (alias graph, PURL overlap, CVSS vector equality, fuzzy title match) that produces `advisory_linksets` with confidence + conflict annotations. |
| CONCELIER-LNM-21-003 `Conflict annotator` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Detect field disagreements (severity, CVSS, ranges, references) and record structured conflicts on linksets; surface to API/UI. |
| CONCELIER-LNM-21-001 `Advisory observation schema` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Introduce immutable `advisory_observations` model with AOC metadata, raw payload pointers, normalized fields, and tenancy guardrails; publish schema definition. `DOCS-LNM-22-001` blocked pending this deliverable. |
| CONCELIER-LNM-21-002 `Linkset builder` | TODO | Concelier Core Guild, Data Science Guild | CONCELIER-LNM-21-001 | Implement correlation pipeline (alias graph, PURL overlap, CVSS vector equality, fuzzy title match) that produces `advisory_linksets` with confidence + conflict annotations. Docs note: unblock `DOCS-LNM-22-001` once builder lands. |
| CONCELIER-LNM-21-003 `Conflict annotator` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Detect field disagreements (severity, CVSS, ranges, references) and record structured conflicts on linksets; surface to API/UI. Docs awaiting structured conflict payloads. |
| CONCELIER-LNM-21-004 `Merge code removal` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Excise existing merge/dedup logic, enforce immutability on observations, and add guards/tests to prevent future merges. |
| CONCELIER-LNM-21-005 `Event emission` | TODO | Concelier Core Guild, Platform Events Guild | CONCELIER-LNM-21-002 | Emit `advisory.linkset.updated` events with delta payloads for downstream Policy Engine/Cartographer consumers; ensure idempotent delivery. |
@@ -46,7 +49,8 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| CONCELIER-GRAPH-24-001 `Advisory overlay inputs` | DOING (2025-10-27) | Concelier Core Guild | CONCELIER-POLICY-23-001 | Expose raw advisory observations/linksets with tenant filters for overlay services; no derived counts/severity in ingestion. |
| CONCELIER-GRAPH-24-001 `Advisory overlay inputs` | TODO | Concelier Core Guild | CONCELIER-POLICY-23-001 | Expose raw advisory observations/linksets with tenant filters for overlay services; no derived counts/severity in ingestion. |
> 2025-10-27: Initial prototype (query service + CLI consumer) drafted but reverted pending scope/tenant alignment; no changes merged.
## Reachability v1

View File

@@ -0,0 +1,38 @@
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Storage.Mongo.Observations;
internal sealed class AdvisoryObservationLookup : IAdvisoryObservationLookup
{
private readonly IAdvisoryObservationStore _store;
public AdvisoryObservationLookup(IAdvisoryObservationStore store)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
cancellationToken.ThrowIfCancellationRequested();
return new ValueTask<IReadOnlyList<AdvisoryObservation>>(
_store.ListByTenantAsync(tenant, cancellationToken));
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByAliasesAsync(
string tenant,
IReadOnlyCollection<string> aliases,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(aliases);
cancellationToken.ThrowIfCancellationRequested();
return new ValueTask<IReadOnlyList<AdvisoryObservation>>(
_store.FindByAliasesAsync(tenant, aliases, cancellationToken));
}
}

View File

@@ -12,12 +12,13 @@ using StellaOps.Concelier.Storage.Mongo.Exporting;
using StellaOps.Concelier.Storage.Mongo.JpFlags;
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
using StellaOps.Concelier.Storage.Mongo.Conflicts;
using StellaOps.Concelier.Storage.Mongo.PsirtFlags;
using StellaOps.Concelier.Storage.Mongo.Statements;
using StellaOps.Concelier.Storage.Mongo.Events;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Storage.Mongo.PsirtFlags;
using StellaOps.Concelier.Storage.Mongo.Statements;
using StellaOps.Concelier.Storage.Mongo.Events;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Storage.Mongo.Migrations;
using StellaOps.Concelier.Storage.Mongo.Observations;
using StellaOps.Concelier.Core.Observations;
namespace StellaOps.Concelier.Storage.Mongo;
@@ -73,6 +74,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IAdvisoryStatementStore, AdvisoryStatementStore>();
services.AddSingleton<IAdvisoryConflictStore, AdvisoryConflictStore>();
services.AddSingleton<IAdvisoryObservationStore, AdvisoryObservationStore>();
services.AddSingleton<IAdvisoryObservationLookup, AdvisoryObservationLookup>();
services.AddSingleton<IAdvisoryEventRepository, MongoAdvisoryEventRepository>();
services.AddSingleton<IAdvisoryEventLog, AdvisoryEventLog>();
services.AddSingleton<IExportStateStore, ExportStateStore>();

View File

@@ -0,0 +1,14 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.WebService.Contracts;
public sealed record AdvisoryObservationQueryResponse(
ImmutableArray<AdvisoryObservation> Observations,
AdvisoryObservationLinksetAggregateResponse Linkset);
public sealed record AdvisoryObservationLinksetAggregateResponse(
ImmutableArray<string> Aliases,
ImmutableArray<string> Purls,
ImmutableArray<string> Cpes,
ImmutableArray<AdvisoryObservationReference> References);

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -12,13 +13,14 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.WebService.Diagnostics;
using Serilog;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.WebService.Diagnostics;
using Serilog;
using StellaOps.Concelier.Merge;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.WebService.Extensions;
@@ -34,10 +36,12 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Aoc;
using StellaOps.Concelier.WebService.Contracts;
var builder = WebApplication.CreateBuilder(args);
const string JobsPolicyName = "Concelier.Jobs.Trigger";
const string ObservationsPolicyName = "Concelier.Observations.Read";
builder.Configuration.AddStellaOpsDefaults(options =>
{
@@ -75,12 +79,13 @@ builder.Services.AddSingleton<MirrorFileLocator>();
builder.Services.AddMongoStorage(storageOptions =>
{
storageOptions.ConnectionString = concelierOptions.Storage.Dsn;
storageOptions.DatabaseName = concelierOptions.Storage.Database;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds);
});
builder.Services.AddMergeModule(builder.Configuration);
builder.Services.AddJobScheduler();
storageOptions.DatabaseName = concelierOptions.Storage.Database;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds);
});
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
builder.Services.AddMergeModule(builder.Configuration);
builder.Services.AddJobScheduler();
builder.Services.AddBuiltInConcelierJobs();
builder.Services.AddSingleton<ServiceStatus>(sp => new ServiceStatus(sp.GetRequiredService<TimeProvider>()));
@@ -163,6 +168,7 @@ if (authorityConfigured)
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(JobsPolicyName, concelierOptions.Authority.RequiredScopes.ToArray());
options.AddStellaOpsScopePolicy(ObservationsPolicyName, StellaOpsScopes.VulnRead);
});
}
@@ -189,6 +195,71 @@ app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
jsonOptions.Converters.Add(new JsonStringEnumConverter());
var observationsEndpoint = app.MapGet("/concelier/observations", async (
string tenant,
[FromQuery(Name = "observationId")] string[]? observationIds,
[FromQuery(Name = "alias")] string[]? aliases,
[FromQuery(Name = "purl")] string[]? purls,
[FromQuery(Name = "cpe")] string[]? cpes,
IAdvisoryObservationQueryService queryService,
HttpContext httpContext,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest("tenant must be provided.");
}
var normalizedTenant = tenant.Trim().ToLowerInvariant();
if (authorityConfigured)
{
var principal = httpContext.User;
if (enforceAuthority && (principal?.Identity?.IsAuthenticated != true))
{
return Results.Unauthorized();
}
if (principal?.Identity?.IsAuthenticated == true)
{
var tenantClaim = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(tenantClaim))
{
return Results.Forbid();
}
var normalizedClaim = tenantClaim.Trim().ToLowerInvariant();
if (!string.Equals(normalizedClaim, normalizedTenant, StringComparison.Ordinal))
{
return Results.Forbid();
}
}
}
var options = new AdvisoryObservationQueryOptions(
normalizedTenant,
observationIds,
aliases,
purls,
cpes);
var result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
var response = new AdvisoryObservationQueryResponse(
result.Observations,
new AdvisoryObservationLinksetAggregateResponse(
result.Linkset.Aliases,
result.Linkset.Purls,
result.Linkset.Cpes,
result.Linkset.References));
return Results.Ok(response);
}).WithName("GetConcelierObservations");
if (authorityConfigured)
{
observationsEndpoint.RequireAuthorization(ObservationsPolicyName);
}
app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
string vulnerabilityKey,
DateTimeOffset? asOf,

View File

@@ -18,16 +18,18 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-GRAPH-21-001 `Inspector linkouts` | TODO | Excititor Core Guild, Cartographer Guild | EXCITITOR-POLICY-20-002, CARTO-GRAPH-21-005 | Provide batched VEX/advisory reference fetches keyed by graph node PURLs so UI inspector can display raw documents and justification metadata. |
| EXCITITOR-GRAPH-21-002 `Overlay enrichment` | TODO | Excititor Core Guild | EXCITITOR-GRAPH-21-001, POLICY-ENGINE-30-001 | Ensure overlay metadata includes VEX justification summaries and document versions for Cartographer overlays; update fixtures/tests. |
| EXCITITOR-GRAPH-21-001 `Inspector linkouts` | BLOCKED (2025-10-27) | Excititor Core Guild, Cartographer Guild | EXCITITOR-POLICY-20-002, CARTO-GRAPH-21-005 | Provide batched VEX/advisory reference fetches keyed by graph node PURLs so UI inspector can display raw documents and justification metadata. |
> 2025-10-27: Pending policy-driven linkset enrichment (`EXCITITOR-POLICY-20-002`) and Cartographer inspector contract (`CARTO-GRAPH-21-005`). No stable payload to target.
| EXCITITOR-GRAPH-21-002 `Overlay enrichment` | BLOCKED (2025-10-27) | Excititor Core Guild | EXCITITOR-GRAPH-21-001, POLICY-ENGINE-30-001 | Ensure overlay metadata includes VEX justification summaries and document versions for Cartographer overlays; update fixtures/tests. |
> 2025-10-27: Requires inspector linkouts (`EXCITITOR-GRAPH-21-001`) and Policy Engine overlay schema (`POLICY-ENGINE-30-001`) before enrichment can be implemented.
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-LNM-21-001 `VEX observation model` | TODO | Excititor Core Guild | EXCITITOR-CORE-AOC-19-001 | Define immutable `vex_observations` schema capturing raw statements, product PURLs, justification, and AOC metadata. |
| EXCITITOR-LNM-21-002 `Linkset correlator` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-001 | Build correlation pipeline combining alias + product PURL signals to form `vex_linksets` with confidence metrics. |
| EXCITITOR-LNM-21-003 `Conflict annotator` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-002 | Record status/justification disagreements within linksets and expose structured conflicts. |
| EXCITITOR-LNM-21-001 `VEX observation model` | TODO | Excititor Core Guild | EXCITITOR-CORE-AOC-19-001 | Define immutable `vex_observations` schema capturing raw statements, product PURLs, justification, and AOC metadata. `DOCS-LNM-22-002` blocked pending this schema. |
| EXCITITOR-LNM-21-002 `Linkset correlator` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-001 | Build correlation pipeline combining alias + product PURL signals to form `vex_linksets` with confidence metrics. Docs waiting to finalize VEX aggregation guide. |
| EXCITITOR-LNM-21-003 `Conflict annotator` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-002 | Record status/justification disagreements within linksets and expose structured conflicts. Provide structured payloads for `DOCS-LNM-22-002`. |
| EXCITITOR-LNM-21-004 `Merge removal` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-002 | Remove legacy VEX merge logic, enforce immutability, and add guards/tests to prevent future merges. |
| EXCITITOR-LNM-21-005 `Event emission` | TODO | Excititor Core Guild, Platform Events Guild | EXCITITOR-LNM-21-002 | Emit `vex.linkset.updated` events for downstream consumers with delta descriptions and tenant context. |

View File

@@ -17,7 +17,8 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-GRAPH-21-005 `Inspector indexes` | TODO | Excititor Storage Guild | EXCITITOR-GRAPH-21-001 | Add indexes/materialized views for VEX lookups by PURL/policy to support Cartographer inspector performance; document migrations. |
| EXCITITOR-GRAPH-21-005 `Inspector indexes` | BLOCKED (2025-10-27) | Excititor Storage Guild | EXCITITOR-GRAPH-21-001 | Add indexes/materialized views for VEX lookups by PURL/policy to support Cartographer inspector performance; document migrations. |
> 2025-10-27: Indexed workload requirements depend on Inspector linkouts (`EXCITITOR-GRAPH-21-001`) which are themselves blocked on Cartographer contract. Revisit once access patterns are defined.
## Link-Not-Merge v1

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Services;
@@ -126,6 +127,144 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
Assert.Contains(result.Warnings, message => message.Contains("EOL", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Evaluate_ExceptionSuppressesCriticalFinding()
{
var document = CompileBaseline();
var effect = new PolicyExceptionEffect(
Id: "suppress-critical",
Name: "Critical Break Glass",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "secops",
MaxDurationDays: 7,
Description: null);
var scope = PolicyEvaluationExceptionScope.Create(ruleNames: new[] { "block_critical" });
var instance = new PolicyEvaluationExceptionInstance(
Id: "exc-001",
EffectId: effect.Id,
Scope: scope,
CreatedAt: new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var exceptions = new PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
ImmutableArray.Create(instance));
var context = CreateContext("Critical", "internal", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("block_critical", result.RuleName);
Assert.Equal("suppressed", result.Status);
Assert.NotNull(result.AppliedException);
Assert.Equal("exc-001", result.AppliedException!.ExceptionId);
Assert.Equal("suppress-critical", result.AppliedException!.EffectId);
Assert.Equal("blocked", result.AppliedException!.OriginalStatus);
Assert.Equal("suppressed", result.AppliedException!.AppliedStatus);
Assert.Equal("suppressed", result.Annotations["exception.status"]);
}
[Fact]
public void Evaluate_ExceptionDowngradesSeverity()
{
var document = CompileBaseline();
var effect = new PolicyExceptionEffect(
Id: "downgrade-internet",
Name: "Downgrade High Internet",
Effect: PolicyExceptionEffectType.Downgrade,
DowngradeSeverity: PolicySeverity.Medium,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var scope = PolicyEvaluationExceptionScope.Create(
ruleNames: new[] { "escalate_high_internet" },
severities: new[] { "High" },
sources: new[] { "GHSA" });
var instance = new PolicyEvaluationExceptionInstance(
Id: "exc-200",
EffectId: effect.Id,
Scope: scope,
CreatedAt: new DateTimeOffset(2025, 10, 2, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var exceptions = new PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
ImmutableArray.Create(instance));
var context = CreateContext("High", "internet", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("escalate_high_internet", result.RuleName);
Assert.Equal("affected", result.Status);
Assert.Equal("Medium", result.Severity);
Assert.NotNull(result.AppliedException);
Assert.Equal("Critical", result.AppliedException!.OriginalSeverity);
Assert.Equal("Medium", result.AppliedException!.AppliedSeverity);
Assert.Equal("Medium", result.Annotations["exception.severity"]);
}
[Fact]
public void Evaluate_MoreSpecificExceptionWins()
{
var document = CompileBaseline();
var suppressGlobal = new PolicyExceptionEffect(
Id: "suppress-critical-global",
Name: "Global Critical Suppress",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var suppressRule = new PolicyExceptionEffect(
Id: "suppress-critical-rule",
Name: "Rule Critical Suppress",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var globalInstance = new PolicyEvaluationExceptionInstance(
Id: "exc-global",
EffectId: suppressGlobal.Id,
Scope: PolicyEvaluationExceptionScope.Create(severities: new[] { "Critical" }),
CreatedAt: new DateTimeOffset(2025, 9, 1, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var ruleInstance = new PolicyEvaluationExceptionInstance(
Id: "exc-rule",
EffectId: suppressRule.Id,
Scope: PolicyEvaluationExceptionScope.Create(
ruleNames: new[] { "block_critical" },
severities: new[] { "Critical" }),
CreatedAt: new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty.Add("requestedBy", "alice"));
var effects = ImmutableDictionary<string, PolicyExceptionEffect>.Empty
.Add(suppressGlobal.Id, suppressGlobal)
.Add(suppressRule.Id, suppressRule);
var exceptions = new PolicyEvaluationExceptions(
effects,
ImmutableArray.Create(globalInstance, ruleInstance));
var context = CreateContext("Critical", "internal", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("suppressed", result.Status);
Assert.NotNull(result.AppliedException);
Assert.Equal("exc-rule", result.AppliedException!.ExceptionId);
Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]);
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
}
private PolicyIrDocument CompileBaseline()
{
var compilation = compiler.Compile(BaselinePolicy);
@@ -133,7 +272,7 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
return Assert.IsType<PolicyIrDocument>(compilation.Document);
}
private static PolicyEvaluationContext CreateContext(string severity, string exposure)
private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null)
{
return new PolicyEvaluationContext(
new PolicyEvaluationSeverity(severity),
@@ -143,7 +282,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
PolicyEvaluationVexEvidence.Empty,
new PolicyEvaluationSbom(ImmutableHashSet<string>.Empty));
new PolicyEvaluationSbom(ImmutableHashSet<string>.Empty),
exceptions ?? PolicyEvaluationExceptions.Empty);
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>

View File

@@ -49,6 +49,7 @@ internal sealed class PolicyParser
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, PolicyLiteralValue>(StringComparer.Ordinal);
var settingsBuilder = ImmutableDictionary.CreateBuilder<string, PolicyLiteralValue>(StringComparer.Ordinal);
var profiles = ImmutableArray.CreateBuilder<PolicyProfileNode>();
var rules = ImmutableArray.CreateBuilder<PolicyRuleNode>();
while (!Check(TokenKind.RightBrace) && !IsAtEnd)
@@ -75,9 +76,12 @@ internal sealed class PolicyParser
if (Match(TokenKind.KeywordProfile))
{
Consume(TokenKind.Identifier, "Profile requires a name.", "policy.profile");
Consume(TokenKind.LeftBrace, "Expected '{' after profile declaration.", "policy.profile");
SkipBlock();
var profile = ParseProfile();
if (profile is not null)
{
profiles.Add(profile);
}
continue;
}
@@ -108,12 +112,43 @@ internal sealed class PolicyParser
name,
syntax,
metadataBuilder.ToImmutable(),
ImmutableArray<PolicyProfileNode>.Empty,
profiles.ToImmutable(),
settingsBuilder.ToImmutable(),
rules.ToImmutable(),
span);
}
private PolicyProfileNode? ParseProfile()
{
var nameToken = Consume(TokenKind.Identifier, "Profile requires a name.", "policy.profile");
var name = nameToken.Text;
Consume(TokenKind.LeftBrace, "Expected '{' after profile declaration.", $"policy.profile.{name}");
var start = nameToken.Span.Start;
var depth = 1;
while (depth > 0 && !IsAtEnd)
{
if (Match(TokenKind.LeftBrace))
{
depth++;
}
else if (Match(TokenKind.RightBrace))
{
depth--;
}
else
{
Advance();
}
}
var close = Previous;
return new PolicyProfileNode(
name,
ImmutableArray<PolicyProfileItemNode>.Empty,
new SourceSpan(start, close.Span.End));
}
private PolicyRuleNode? ParseRule()
{
var nameToken = Consume(TokenKind.Identifier, "Rule requires a name.", "policy.rule");
@@ -153,7 +188,7 @@ internal sealed class PolicyParser
if (because is null)
{
diagnostics.Add(PolicyIssue.Warning(PolicyDslDiagnosticCodes.MissingBecauseClause, $"Rule '{name}' missing 'because' clause.", $"policy.rule.{name}"));
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.MissingBecauseClause, $"Rule '{name}' missing 'because' clause.", $"policy.rule.{name}"));
}
return new PolicyRuleNode(name, priority, when, thenActions, elseActions, because, new SourceSpan(nameToken.Span.Start, close.Span.End));

View File

@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
namespace StellaOps.Policy.Engine.Evaluation;
@@ -13,7 +16,8 @@ internal sealed record PolicyEvaluationContext(
PolicyEvaluationEnvironment Environment,
PolicyEvaluationAdvisory Advisory,
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom);
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions);
internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null);
@@ -51,7 +55,8 @@ internal sealed record PolicyEvaluationResult(
string? RuleName,
int? Priority,
ImmutableDictionary<string, string> Annotations,
ImmutableArray<string> Warnings)
ImmutableArray<string> Warnings,
PolicyExceptionApplication? AppliedException)
{
public static PolicyEvaluationResult CreateDefault(string? severity) => new(
Matched: false,
@@ -60,5 +65,78 @@ internal sealed record PolicyEvaluationResult(
RuleName: null,
Priority: null,
Annotations: ImmutableDictionary<string, string>.Empty,
Warnings: ImmutableArray<string>.Empty);
Warnings: ImmutableArray<string>.Empty,
AppliedException: null);
}
internal sealed record PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect> Effects,
ImmutableArray<PolicyEvaluationExceptionInstance> Instances)
{
public static readonly PolicyEvaluationExceptions Empty = new(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty,
ImmutableArray<PolicyEvaluationExceptionInstance>.Empty);
public bool IsEmpty => Instances.IsDefaultOrEmpty || Instances.Length == 0;
}
internal sealed record PolicyEvaluationExceptionInstance(
string Id,
string EffectId,
PolicyEvaluationExceptionScope Scope,
DateTimeOffset CreatedAt,
ImmutableDictionary<string, string> Metadata);
internal sealed record PolicyEvaluationExceptionScope(
ImmutableHashSet<string> RuleNames,
ImmutableHashSet<string> Severities,
ImmutableHashSet<string> Sources,
ImmutableHashSet<string> Tags)
{
public static PolicyEvaluationExceptionScope Empty { get; } = new(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase));
public bool IsEmpty => RuleNames.Count == 0
&& Severities.Count == 0
&& Sources.Count == 0
&& Tags.Count == 0;
public static PolicyEvaluationExceptionScope Create(
IEnumerable<string>? ruleNames = null,
IEnumerable<string>? severities = null,
IEnumerable<string>? sources = null,
IEnumerable<string>? tags = null)
{
return new PolicyEvaluationExceptionScope(
Normalize(ruleNames),
Normalize(severities),
Normalize(sources),
Normalize(tags));
}
private static ImmutableHashSet<string> Normalize(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
}
}
internal sealed record PolicyExceptionApplication(
string ExceptionId,
string EffectId,
PolicyExceptionEffectType EffectType,
string OriginalStatus,
string? OriginalSeverity,
string AppliedStatus,
string? AppliedSeverity,
ImmutableDictionary<string, string> Metadata);

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
namespace StellaOps.Policy.Engine.Evaluation;
@@ -49,17 +51,21 @@ internal sealed class PolicyEvaluator
runtime.Status = "affected";
}
return new PolicyEvaluationResult(
var baseResult = new PolicyEvaluationResult(
Matched: true,
Status: runtime.Status,
Severity: runtime.Severity,
RuleName: rule.Name,
Priority: rule.Priority,
Annotations: runtime.Annotations.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
Warnings: runtime.Warnings.ToImmutableArray());
Warnings: runtime.Warnings.ToImmutableArray(),
AppliedException: null);
return ApplyExceptions(request, baseResult);
}
return PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
var defaultResult = PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
return ApplyExceptions(request, defaultResult);
}
private static void ApplyAction(
@@ -181,4 +187,234 @@ internal sealed class PolicyEvaluator
public List<string> Warnings { get; } = new();
}
private static PolicyEvaluationResult ApplyExceptions(PolicyEvaluationRequest request, PolicyEvaluationResult baseResult)
{
var exceptions = request.Context.Exceptions;
if (exceptions.IsEmpty)
{
return baseResult;
}
PolicyEvaluationExceptionInstance? winningInstance = null;
PolicyExceptionEffect? winningEffect = null;
var winningScore = -1;
foreach (var instance in exceptions.Instances)
{
if (!exceptions.Effects.TryGetValue(instance.EffectId, out var effect))
{
continue;
}
if (!MatchesScope(instance.Scope, request, baseResult))
{
continue;
}
var specificity = ComputeSpecificity(instance.Scope);
if (specificity < 0)
{
continue;
}
if (winningInstance is null
|| specificity > winningScore
|| (specificity == winningScore && instance.CreatedAt > winningInstance.CreatedAt)
|| (specificity == winningScore && instance.CreatedAt == winningInstance!.CreatedAt
&& string.CompareOrdinal(instance.Id, winningInstance.Id) < 0))
{
winningInstance = instance;
winningEffect = effect;
winningScore = specificity;
}
}
if (winningInstance is null || winningEffect is null)
{
return baseResult;
}
return ApplyExceptionEffect(baseResult, winningInstance, winningEffect);
}
private static bool MatchesScope(
PolicyEvaluationExceptionScope scope,
PolicyEvaluationRequest request,
PolicyEvaluationResult baseResult)
{
if (scope.RuleNames.Count > 0)
{
if (string.IsNullOrEmpty(baseResult.RuleName)
|| !scope.RuleNames.Contains(baseResult.RuleName))
{
return false;
}
}
if (scope.Severities.Count > 0)
{
var severity = request.Context.Severity.Normalized;
if (string.IsNullOrEmpty(severity)
|| !scope.Severities.Contains(severity))
{
return false;
}
}
if (scope.Sources.Count > 0)
{
var source = request.Context.Advisory.Source;
if (string.IsNullOrEmpty(source)
|| !scope.Sources.Contains(source))
{
return false;
}
}
if (scope.Tags.Count > 0)
{
var sbom = request.Context.Sbom;
var hasMatch = scope.Tags.Any(sbom.HasTag);
if (!hasMatch)
{
return false;
}
}
return true;
}
private static int ComputeSpecificity(PolicyEvaluationExceptionScope scope)
{
var score = 0;
if (scope.RuleNames.Count > 0)
{
score += 1_000 + scope.RuleNames.Count * 25;
}
if (scope.Severities.Count > 0)
{
score += 500 + scope.Severities.Count * 10;
}
if (scope.Sources.Count > 0)
{
score += 250 + scope.Sources.Count * 10;
}
if (scope.Tags.Count > 0)
{
score += 100 + scope.Tags.Count * 5;
}
return score;
}
private static PolicyEvaluationResult ApplyExceptionEffect(
PolicyEvaluationResult baseResult,
PolicyEvaluationExceptionInstance instance,
PolicyExceptionEffect effect)
{
var annotationsBuilder = baseResult.Annotations.ToBuilder();
annotationsBuilder["exception.id"] = instance.Id;
annotationsBuilder["exception.effectId"] = effect.Id;
annotationsBuilder["exception.effectType"] = effect.Effect.ToString();
if (!string.IsNullOrWhiteSpace(effect.Name))
{
annotationsBuilder["exception.effectName"] = effect.Name!;
}
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
annotationsBuilder["exception.routingTemplate"] = effect.RoutingTemplate!;
}
if (effect.MaxDurationDays is int durationDays)
{
annotationsBuilder["exception.maxDurationDays"] = durationDays.ToString(CultureInfo.InvariantCulture);
}
foreach (var pair in instance.Metadata)
{
annotationsBuilder[$"exception.meta.{pair.Key}"] = pair.Value;
}
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
metadataBuilder["routingTemplate"] = effect.RoutingTemplate!;
}
if (effect.MaxDurationDays is int metadataDuration)
{
metadataBuilder["maxDurationDays"] = metadataDuration.ToString(CultureInfo.InvariantCulture);
}
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
metadataBuilder["requiredControlId"] = effect.RequiredControlId!;
}
if (!string.IsNullOrWhiteSpace(effect.Name))
{
metadataBuilder["effectName"] = effect.Name!;
}
foreach (var pair in instance.Metadata)
{
metadataBuilder[pair.Key] = pair.Value;
}
var newStatus = baseResult.Status;
var newSeverity = baseResult.Severity;
var warnings = baseResult.Warnings;
switch (effect.Effect)
{
case PolicyExceptionEffectType.Suppress:
newStatus = "suppressed";
annotationsBuilder["exception.status"] = newStatus;
break;
case PolicyExceptionEffectType.Defer:
newStatus = "deferred";
annotationsBuilder["exception.status"] = newStatus;
break;
case PolicyExceptionEffectType.Downgrade:
if (effect.DowngradeSeverity is { } downgradeSeverity)
{
newSeverity = downgradeSeverity.ToString();
annotationsBuilder["exception.severity"] = newSeverity!;
}
break;
case PolicyExceptionEffectType.RequireControl:
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
annotationsBuilder["exception.requiredControl"] = effect.RequiredControlId!;
warnings = warnings.Add($"Exception '{instance.Id}' requires control '{effect.RequiredControlId}'.");
}
break;
}
var application = new PolicyExceptionApplication(
ExceptionId: instance.Id,
EffectId: instance.EffectId,
EffectType: effect.Effect,
OriginalStatus: baseResult.Status,
OriginalSeverity: baseResult.Severity,
AppliedStatus: newStatus,
AppliedSeverity: newSeverity,
Metadata: metadataBuilder.ToImmutable());
return baseResult with
{
Status = newStatus,
Severity = newSeverity,
Annotations = annotationsBuilder.ToImmutable(),
Warnings = warnings,
AppliedException = application,
};
}
}

View File

@@ -9,6 +9,19 @@ namespace StellaOps.Policy.Engine.Evaluation;
internal sealed class PolicyExpressionEvaluator
{
private static readonly IReadOnlyDictionary<string, decimal> SeverityOrder = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["critical"] = 5m,
["high"] = 4m,
["medium"] = 3m,
["moderate"] = 3m,
["low"] = 2m,
["informational"] = 1m,
["info"] = 1m,
["none"] = 0m,
["unknown"] = -1m,
};
private readonly PolicyEvaluationContext context;
public PolicyExpressionEvaluator(PolicyEvaluationContext context)
@@ -208,9 +221,35 @@ internal sealed class PolicyExpressionEvaluator
private EvaluationValue CompareNumeric(PolicyExpression left, PolicyExpression right, EvaluationScope scope, Func<decimal, decimal, bool> comparer)
{
var leftValue = Evaluate(left, scope).AsDecimal();
var rightValue = Evaluate(right, scope).AsDecimal();
return new EvaluationValue(leftValue.HasValue && rightValue.HasValue && comparer(leftValue.Value, rightValue.Value));
var leftValue = Evaluate(left, scope);
var rightValue = Evaluate(right, scope);
if (!TryGetComparableNumber(leftValue, out var leftNumber)
|| !TryGetComparableNumber(rightValue, out var rightNumber))
{
return EvaluationValue.False;
}
return new EvaluationValue(comparer(leftNumber, rightNumber));
}
private static bool TryGetComparableNumber(EvaluationValue value, out decimal number)
{
var numeric = value.AsDecimal();
if (numeric.HasValue)
{
number = numeric.Value;
return true;
}
if (value.Raw is string text && SeverityOrder.TryGetValue(text.Trim(), out var mapped))
{
number = mapped;
return true;
}
number = 0m;
return false;
}
private EvaluationValue Contains(PolicyExpression needleExpr, PolicyExpression haystackExpr, EvaluationScope scope)

View File

@@ -89,7 +89,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-70-001 | TODO | Policy Guild, Governance Guild | POLICY-EXC-25-001 | Implement exception evaluation layer: specificity resolution, effect application (suppress/defer/downgrade/require control), and integration with explain traces. | Engine applies exceptions deterministically; unit/property tests cover precedence; explainer includes exception metadata. |
| POLICY-ENGINE-70-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-EXC-25-001 | Implement exception evaluation layer: specificity resolution, effect application (suppress/defer/downgrade/require control), and integration with explain traces. | Engine applies exceptions deterministically; unit/property tests cover precedence; explainer includes exception metadata. |
| POLICY-ENGINE-70-002 | TODO | Policy Guild, Storage Guild | POLICY-ENGINE-70-001 | Design and create Mongo collections (`exceptions`, `exception_reviews`, `exception_bindings`) with indexes and migrations; expose repository APIs. | Collections created; migrations documented; tests cover CRUD and binding lookups. |
| POLICY-ENGINE-70-003 | TODO | Policy Guild, Runtime Guild | POLICY-ENGINE-70-001 | Build Redis exception decision cache (`exceptions_effective_map`) with warm/invalidation logic reacting to `exception.*` events. | Cache layer operational; metrics track hit/miss; fallback path tested. |
| POLICY-ENGINE-70-004 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-70-001 | Extend metrics/tracing/logging for exception application (latency, counts, expiring events) and include AOC references in logs. | Metrics emitted (`policy_exception_applied_total` etc.); traces updated; log schema documented. |

View File

@@ -1,13 +1,17 @@
# Policy Registry Task Board — Epic 4: Policy Studio
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| REGISTRY-API-27-001 | TODO | Policy Registry Guild | AUTH-CONSOLE-23-001, POLICY-ENGINE-20-001 | Define OpenAPI specification covering workspaces, versions, reviews, simulations, promotions, and attestations; publish typed clients for Console/CLI. | OpenAPI YAML committed, spectral lint passes, SDK regeneration documented, consumers notified. |
| REGISTRY-API-27-001 | TODO | Policy Registry Guild | AUTH-CONSOLE-23-001, POLICY-ENGINE-20-001 | Define OpenAPI specification covering workspaces, versions, reviews, simulations, promotions, and attestations; publish typed clients for Console/CLI. | OpenAPI YAML committed, spectral lint passes, SDK regeneration documented, consumers notified. Docs `DOCS-POLICY-27-001/008/010` waiting on this spec. |
| REGISTRY-API-27-002 | TODO | Policy Registry Guild | REGISTRY-API-27-001 | Implement workspace storage (Mongo collections, object storage buckets) with CRUD endpoints, diff history, and retention policies. | Workspace CRUD passes integration tests; retention job documented; tenancy scopes enforced. |
| REGISTRY-API-27-003 | TODO | Policy Registry Guild | REGISTRY-API-27-002, POLICY-ENGINE-20-001 | Integrate compile endpoint: forward source bundle to Policy Engine, persist diagnostics, symbol table, rule index, and complexity metrics. | Compile API returns diagnostics + symbol table, metrics recorded, failures mapped to `ERR_POL_*`, tests cover success/error cases. |
| REGISTRY-API-27-004 | TODO | Policy Registry Guild | REGISTRY-API-27-003, POLICY-ENGINE-20-002 | Implement quick simulation API with request limits (sample size, timeouts), returning counts, heatmap, sampled explains. | Quick sim enforces limits, results cached with hash, integration tests validate deterministic output. |
| REGISTRY-API-27-005 | TODO | Policy Registry Guild, Scheduler Guild | REGISTRY-API-27-004, SCHED-WORKER-27-301 | Build batch simulation orchestration: enqueue shards, collect partials, reduce deltas, produce evidence bundles + signed manifest. | Batch sim runs end-to-end in staging fixture, manifests stored with checksums, retries/backoff documented. |
> Docs dependency: `DOCS-POLICY-27-004` needs simulation APIs/workers.
| REGISTRY-API-27-006 | TODO | Policy Registry Guild | REGISTRY-API-27-003 | Implement review workflow (comments, votes, required approvers, status transitions) with audit trails and webhooks. | Review endpoints enforce approver quorum, audit log captured, webhook integration tests pass. |
> Docs dependency: `DOCS-POLICY-27-005` waiting on review workflow.
| REGISTRY-API-27-007 | TODO | Policy Registry Guild, Security Guild | REGISTRY-API-27-006, AUTH-POLICY-27-001 | Implement publish pipeline: sign source/compiled digests, create attestations, mark version immutable, emit events. | Published versions immutable, attestations stored & verifiable, metrics/logs emitted, tests cover signing failure. |
> Docs dependency: `DOCS-POLICY-27-003` blocked until publish/sign pipeline ships.
| REGISTRY-API-27-008 | TODO | Policy Registry Guild | REGISTRY-API-27-007, AUTH-POLICY-27-002 | Implement promotion bindings per tenant/environment with canary subsets, rollback path, and environment history. | Promotion API updates bindings atomically, canary percent enforced, rollback recorded, runbooks updated. |
> Docs dependency: `DOCS-POLICY-27-006` requires promotion APIs.
| REGISTRY-API-27-009 | TODO | Policy Registry Guild, Observability Guild | REGISTRY-API-27-002..008 | Instrument metrics/logs/traces (compile time, diagnostics rate, sim queue depth, approval latency) and expose dashboards. | Metrics registered, dashboards seeded, alerts configured, documentation updated. |
| REGISTRY-API-27-010 | TODO | Policy Registry Guild, QA Guild | REGISTRY-API-27-002..008 | Build unit/integration/load test suites for compile/sim/review/publish/promote flows; provide seeded fixtures for CI. | Tests run in CI, load test report documented, determinism checks validated across runs. |

View File

@@ -1,8 +1,9 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Policy.Tests;
@@ -26,8 +27,78 @@ public sealed class PolicyBinderTests
Assert.Equal("1.0", result.Document.Version);
Assert.Single(result.Document.Rules);
Assert.Empty(result.Issues);
}
}
[Fact]
public void Bind_ExceptionsConfigured_ParsesDefinitions()
{
const string yaml = """
version: "1.0"
exceptions:
effects:
- id: suppress-temp
name: Temporary Suppress
effect: suppress
routingTemplate: secops
maxDurationDays: 30
- id: downgrade-ops
name: Downgrade To Low
effect: downgrade
downgradeSeverity: Low
routingTemplates:
- id: secops
authorityRouteId: route-secops
requireMfa: true
rules:
- name: Allow
action: ignore
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.True(result.Success);
var effects = result.Document.Exceptions.Effects;
Assert.Equal(2, effects.Length);
var suppress = effects.Single(effect => effect.Id == "suppress-temp");
Assert.Equal(PolicyExceptionEffectType.Suppress, suppress.Effect);
Assert.Equal("Temporary Suppress", suppress.Name);
Assert.Equal("secops", suppress.RoutingTemplate);
Assert.Equal(30, suppress.MaxDurationDays);
var downgrade = effects.Single(effect => effect.Id == "downgrade-ops");
Assert.Equal(PolicyExceptionEffectType.Downgrade, downgrade.Effect);
Assert.Equal("Downgrade To Low", downgrade.Name);
Assert.Equal(PolicySeverity.Low, downgrade.DowngradeSeverity);
var routing = result.Document.Exceptions.RoutingTemplates;
Assert.Single(routing);
Assert.Equal("secops", routing[0].Id);
Assert.Equal("route-secops", routing[0].AuthorityRouteId);
Assert.True(routing[0].RequireMfa);
}
[Fact]
public void Bind_ExceptionDowngradeMissingSeverity_ReturnsError()
{
const string yaml = """
version: "1.0"
exceptions:
effects:
- id: downgrade-invalid
effect: downgrade
routingTemplates: []
rules:
- name: Allow
action: ignore
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code == "policy.exceptions.effect.downgrade.missingSeverity");
}
[Fact]
public void Bind_InvalidSeverity_ReturnsError()
{

View File

@@ -21,10 +21,11 @@ public sealed class PolicyEvaluationTests
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
@@ -66,10 +67,11 @@ public sealed class PolicyEvaluationTests
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
@@ -107,10 +109,11 @@ public sealed class PolicyEvaluationTests
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(

View File

@@ -180,16 +180,19 @@ public static class PolicyBinder
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonPropertyName("rules")]
public List<PolicyRuleModel>? Rules { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonPropertyName("rules")]
public List<PolicyRuleModel>? Rules { get; init; }
[JsonPropertyName("exceptions")]
public PolicyExceptionsModel? Exceptions { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyRuleModel
{
[JsonPropertyName("id")]
@@ -258,18 +261,78 @@ public static class PolicyBinder
[JsonPropertyName("quiet")]
public bool? Quiet { get; init; }
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed class PolicyNormalizer
{
private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap =
new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase)
{
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyExceptionsModel
{
[JsonPropertyName("effects")]
public List<PolicyExceptionEffectModel>? Effects { get; init; }
[JsonPropertyName("routingTemplates")]
public List<PolicyExceptionRoutingTemplateModel>? RoutingTemplates { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyExceptionEffectModel
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("effect")]
public string? Effect { get; init; }
[JsonPropertyName("downgradeSeverity")]
public string? DowngradeSeverity { get; init; }
[JsonPropertyName("requiredControlId")]
public string? RequiredControlId { get; init; }
[JsonPropertyName("routingTemplate")]
public string? RoutingTemplate { get; init; }
[JsonPropertyName("maxDurationDays")]
public int? MaxDurationDays { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyExceptionRoutingTemplateModel
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("authorityRouteId")]
public string? AuthorityRouteId { get; init; }
[JsonPropertyName("requireMfa")]
public bool? RequireMfa { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed class PolicyNormalizer
{
private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap =
new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase)
{
["critical"] = PolicySeverity.Critical,
["high"] = PolicySeverity.High,
["medium"] = PolicySeverity.Medium,
@@ -282,33 +345,35 @@ public static class PolicyBinder
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
public static (PolicyDocument Document, ImmutableArray<PolicyIssue> Issues) Normalize(PolicyDocumentModel model)
{
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
var version = NormalizeVersion(model.Version, issues);
var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues);
var rules = NormalizeRules(model.Rules, issues);
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
{
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
var version = NormalizeVersion(model.Version, issues);
var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues);
var rules = NormalizeRules(model.Rules, issues);
var exceptions = NormalizeExceptions(model.Exceptions, issues);
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.document.extension",
$"Unrecognized document property '{pair.Key}' has been ignored.",
$"$.{pair.Key}"));
}
}
var document = new PolicyDocument(
version ?? PolicySchema.CurrentVersion,
rules,
metadata);
var orderedIssues = SortIssues(issues);
return (document, orderedIssues);
}
}
var document = new PolicyDocument(
version ?? PolicySchema.CurrentVersion,
rules,
metadata,
exceptions);
var orderedIssues = SortIssues(issues);
return (document, orderedIssues);
}
private static string? NormalizeVersion(JsonNode? versionNode, ImmutableArray<PolicyIssue>.Builder issues)
{
if (versionNode is null)
@@ -392,11 +457,11 @@ public static class PolicyBinder
return builder.ToImmutable();
}
private static ImmutableArray<PolicyRule> NormalizeRules(
List<PolicyRuleModel>? rules,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (rules is null || rules.Count == 0)
private static ImmutableArray<PolicyRule> NormalizeRules(
List<PolicyRuleModel>? rules,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (rules is null || rules.Count == 0)
{
issues.Add(PolicyIssue.Error("policy.rules.empty", "At least one rule must be defined.", "$.rules"));
return ImmutableArray<PolicyRule>.Empty;
@@ -425,19 +490,273 @@ public static class PolicyBinder
normalized.Add((normalizedRule, index));
}
return normalized
.OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Index)
.Select(static tuple => tuple.Rule)
.ToImmutableArray();
}
private static PolicyRule? NormalizeRule(
PolicyRuleModel model,
int index,
ImmutableArray<PolicyIssue>.Builder issues)
{
return normalized
.OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Index)
.Select(static tuple => tuple.Rule)
.ToImmutableArray();
}
private static PolicyExceptionConfiguration NormalizeExceptions(
PolicyExceptionsModel? model,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (model is null)
{
return PolicyExceptionConfiguration.Empty;
}
var effects = NormalizeExceptionEffects(model.Effects, "$.exceptions.effects", issues);
var routingTemplates = NormalizeExceptionRoutingTemplates(model.RoutingTemplates, "$.exceptions.routingTemplates", issues);
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.exceptions.extension",
$"Unrecognized exceptions property '{pair.Key}' has been ignored.",
$"$.exceptions.{pair.Key}"));
}
}
return new PolicyExceptionConfiguration(effects, routingTemplates);
}
private static ImmutableArray<PolicyExceptionEffect> NormalizeExceptionEffects(
List<PolicyExceptionEffectModel>? models,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (models is null || models.Count == 0)
{
return ImmutableArray<PolicyExceptionEffect>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyExceptionEffect>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < models.Count; index++)
{
var model = models[index];
var basePath = $"{path}[{index}]";
var id = NormalizeOptionalString(model.Id);
if (string.IsNullOrEmpty(id))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.id.missing",
"Exception effect id is required.",
$"{basePath}.id"));
continue;
}
if (!seenIds.Add(id))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.id.duplicate",
$"Duplicate exception effect id '{id}'.",
$"{basePath}.id"));
continue;
}
var effectType = NormalizeExceptionEffectType(model.Effect, $"{basePath}.effect", issues);
if (effectType is null)
{
continue;
}
PolicySeverity? downgradeSeverity = null;
if (!string.IsNullOrWhiteSpace(model.DowngradeSeverity))
{
var severityText = NormalizeOptionalString(model.DowngradeSeverity);
if (!string.IsNullOrEmpty(severityText) && SeverityMap.TryGetValue(severityText, out var mapped))
{
downgradeSeverity = mapped;
}
else if (!string.IsNullOrEmpty(severityText))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.downgrade.invalidSeverity",
$"Unknown downgradeSeverity '{severityText}'.",
$"{basePath}.downgradeSeverity"));
}
}
var requiredControlId = NormalizeOptionalString(model.RequiredControlId);
if (effectType == PolicyExceptionEffectType.RequireControl && string.IsNullOrEmpty(requiredControlId))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.control.missing",
"requireControl effects must specify requiredControlId.",
$"{basePath}.requiredControlId"));
continue;
}
if (effectType == PolicyExceptionEffectType.Downgrade && downgradeSeverity is null)
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.downgrade.missingSeverity",
"downgrade effects must specify downgradeSeverity.",
$"{basePath}.downgradeSeverity"));
continue;
}
var name = NormalizeOptionalString(model.Name);
var routingTemplate = NormalizeOptionalString(model.RoutingTemplate);
var description = NormalizeOptionalString(model.Description);
int? maxDurationDays = null;
if (model.MaxDurationDays is { } durationValue)
{
if (durationValue <= 0)
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.duration.invalid",
"maxDurationDays must be greater than zero.",
$"{basePath}.maxDurationDays"));
}
else
{
maxDurationDays = durationValue;
}
}
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.exceptions.effect.extension",
$"Unrecognized exception effect property '{pair.Key}' has been ignored.",
$"{basePath}.{pair.Key}"));
}
}
builder.Add(new PolicyExceptionEffect(
id,
name,
effectType.Value,
downgradeSeverity,
requiredControlId,
routingTemplate,
maxDurationDays,
description));
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyExceptionRoutingTemplate> NormalizeExceptionRoutingTemplates(
List<PolicyExceptionRoutingTemplateModel>? models,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (models is null || models.Count == 0)
{
return ImmutableArray<PolicyExceptionRoutingTemplate>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyExceptionRoutingTemplate>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < models.Count; index++)
{
var model = models[index];
var basePath = $"{path}[{index}]";
var id = NormalizeOptionalString(model.Id);
if (string.IsNullOrEmpty(id))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.routing.id.missing",
"Routing template id is required.",
$"{basePath}.id"));
continue;
}
if (!seenIds.Add(id))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.routing.id.duplicate",
$"Duplicate routing template id '{id}'.",
$"{basePath}.id"));
continue;
}
var authorityRouteId = NormalizeOptionalString(model.AuthorityRouteId);
if (string.IsNullOrEmpty(authorityRouteId))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.routing.authority.missing",
"Routing template must specify authorityRouteId.",
$"{basePath}.authorityRouteId"));
continue;
}
var description = NormalizeOptionalString(model.Description);
var requireMfa = model.RequireMfa ?? false;
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.exceptions.routing.extension",
$"Unrecognized routing template property '{pair.Key}' has been ignored.",
$"{basePath}.{pair.Key}"));
}
}
builder.Add(new PolicyExceptionRoutingTemplate(
id,
authorityRouteId,
requireMfa,
description));
}
return builder.ToImmutable();
}
private static PolicyExceptionEffectType? NormalizeExceptionEffectType(
string? value,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
var normalized = NormalizeOptionalString(value);
if (string.IsNullOrEmpty(normalized))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.type.missing",
"Exception effect type is required.",
path));
return null;
}
switch (normalized.ToLowerInvariant())
{
case "suppress":
return PolicyExceptionEffectType.Suppress;
case "defer":
return PolicyExceptionEffectType.Defer;
case "downgrade":
return PolicyExceptionEffectType.Downgrade;
case "requirecontrol":
return PolicyExceptionEffectType.RequireControl;
default:
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.type.invalid",
$"Unsupported exception effect type '{normalized}'.",
path));
return null;
}
}
private static PolicyRule? NormalizeRule(
PolicyRuleModel model,
int index,
ImmutableArray<PolicyIssue>.Builder issues)
{
var basePath = $"$.rules[{index}]";
var name = NormalizeRequiredString(model.Name, $"{basePath}.name", "Rule name", issues);

View File

@@ -46,16 +46,50 @@ public static class PolicyDigest
}
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
}
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
if (!document.Exceptions.Effects.IsDefaultOrEmpty || !document.Exceptions.RoutingTemplates.IsDefaultOrEmpty)
{
writer.WritePropertyName("exceptions");
writer.WriteStartObject();
if (!document.Exceptions.Effects.IsDefaultOrEmpty)
{
writer.WritePropertyName("effects");
writer.WriteStartArray();
foreach (var effect in document.Exceptions.Effects
.OrderBy(static e => e.Id, StringComparer.Ordinal))
{
WriteExceptionEffect(writer, effect);
}
writer.WriteEndArray();
}
if (!document.Exceptions.RoutingTemplates.IsDefaultOrEmpty)
{
writer.WritePropertyName("routingTemplates");
writer.WriteStartArray();
foreach (var template in document.Exceptions.RoutingTemplates
.OrderBy(static t => t.Id, StringComparer.Ordinal))
{
WriteExceptionRoutingTemplate(writer, template);
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
writer.WriteEndObject();
writer.Flush();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
{
@@ -193,19 +227,78 @@ public static class PolicyDigest
writer.WriteEndArray();
}
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
{
if (values.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName(propertyName);
writer.WriteStartArray();
foreach (var value in values)
{
writer.WriteStringValue(value);
}
writer.WriteEndArray();
}
}
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
{
if (values.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName(propertyName);
writer.WriteStartArray();
foreach (var value in values)
{
writer.WriteStringValue(value);
}
writer.WriteEndArray();
}
private static void WriteExceptionEffect(Utf8JsonWriter writer, PolicyExceptionEffect effect)
{
writer.WriteStartObject();
writer.WriteString("id", effect.Id);
if (!string.IsNullOrWhiteSpace(effect.Name))
{
writer.WriteString("name", effect.Name);
}
writer.WriteString("effect", effect.Effect.ToString().ToLowerInvariant());
if (effect.DowngradeSeverity is { } downgradeSeverity)
{
writer.WriteString("downgradeSeverity", downgradeSeverity.ToString());
}
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
writer.WriteString("requiredControlId", effect.RequiredControlId);
}
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
writer.WriteString("routingTemplate", effect.RoutingTemplate);
}
if (effect.MaxDurationDays is int maxDurationDays)
{
writer.WriteNumber("maxDurationDays", maxDurationDays);
}
if (!string.IsNullOrWhiteSpace(effect.Description))
{
writer.WriteString("description", effect.Description);
}
writer.WriteEndObject();
}
private static void WriteExceptionRoutingTemplate(Utf8JsonWriter writer, PolicyExceptionRoutingTemplate template)
{
writer.WriteStartObject();
writer.WriteString("id", template.Id);
writer.WriteString("authorityRouteId", template.AuthorityRouteId);
if (template.RequireMfa)
{
writer.WriteBoolean("requireMfa", true);
}
if (!string.IsNullOrWhiteSpace(template.Description))
{
writer.WriteString("description", template.Description);
}
writer.WriteEndObject();
}
}

View File

@@ -1,25 +1,28 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
/// <summary>
/// Canonical representation of a StellaOps policy document.
/// </summary>
public sealed record PolicyDocument(
string Version,
ImmutableArray<PolicyRule> Rules,
ImmutableDictionary<string, string> Metadata)
{
public static PolicyDocument Empty { get; } = new(
PolicySchema.CurrentVersion,
ImmutableArray<PolicyRule>.Empty,
ImmutableDictionary<string, string>.Empty);
}
public static class PolicySchema
{
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy;
/// <summary>
/// Canonical representation of a StellaOps policy document.
/// </summary>
public sealed record PolicyDocument(
string Version,
ImmutableArray<PolicyRule> Rules,
ImmutableDictionary<string, string> Metadata,
PolicyExceptionConfiguration Exceptions)
{
public static PolicyDocument Empty { get; } = new(
PolicySchema.CurrentVersion,
ImmutableArray<PolicyRule>.Empty,
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
}
public static class PolicySchema
{
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
public const string CurrentVersion = "1.0";
public static PolicyDocumentFormat DetectFormat(string fileName)
@@ -154,12 +157,12 @@ public sealed record PolicyRuleMatchCriteria(
UsedByEntrypoint.IsDefaultOrEmpty;
}
public sealed record PolicyAction(
PolicyActionType Type,
PolicyIgnoreOptions? Ignore,
PolicyEscalateOptions? Escalate,
PolicyRequireVexOptions? RequireVex,
bool Quiet);
public sealed record PolicyAction(
PolicyActionType Type,
PolicyIgnoreOptions? Ignore,
PolicyEscalateOptions? Escalate,
PolicyRequireVexOptions? RequireVex,
bool Quiet);
public enum PolicyActionType
{
@@ -178,17 +181,61 @@ public sealed record PolicyEscalateOptions(
bool RequireKev,
double? MinimumEpss);
public sealed record PolicyRequireVexOptions(
ImmutableArray<string> Vendors,
ImmutableArray<string> Justifications);
public enum PolicySeverity
{
Critical,
High,
Medium,
Low,
Informational,
None,
Unknown,
}
public sealed record PolicyRequireVexOptions(
ImmutableArray<string> Vendors,
ImmutableArray<string> Justifications);
public enum PolicySeverity
{
Critical,
High,
Medium,
Low,
Informational,
None,
Unknown,
}
public sealed record PolicyExceptionConfiguration(
ImmutableArray<PolicyExceptionEffect> Effects,
ImmutableArray<PolicyExceptionRoutingTemplate> RoutingTemplates)
{
public static PolicyExceptionConfiguration Empty { get; } = new(
ImmutableArray<PolicyExceptionEffect>.Empty,
ImmutableArray<PolicyExceptionRoutingTemplate>.Empty);
public PolicyExceptionEffect? FindEffect(string effectId)
{
if (string.IsNullOrWhiteSpace(effectId) || Effects.IsDefaultOrEmpty)
{
return null;
}
return Effects.FirstOrDefault(effect =>
string.Equals(effect.Id, effectId, StringComparison.OrdinalIgnoreCase));
}
}
public sealed record PolicyExceptionEffect(
string Id,
string? Name,
PolicyExceptionEffectType Effect,
PolicySeverity? DowngradeSeverity,
string? RequiredControlId,
string? RoutingTemplate,
int? MaxDurationDays,
string? Description);
public enum PolicyExceptionEffectType
{
Suppress,
Defer,
Downgrade,
RequireControl,
}
public sealed record PolicyExceptionRoutingTemplate(
string Id,
string AuthorityRouteId,
bool RequireMfa,
string? Description);

View File

@@ -12,17 +12,38 @@
"description": {
"type": "string"
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
},
"rules": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/rule"
"metadata": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
},
"exceptions": {
"type": "object",
"properties": {
"effects": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/exceptionEffect"
},
"uniqueItems": true
},
"routingTemplates": {
"type": "array",
"items": {
"$ref": "#/$defs/exceptionRoutingTemplate"
},
"uniqueItems": true
}
},
"additionalProperties": false
},
"rules": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/rule"
}
}
},
@@ -36,17 +57,97 @@
"type": "string",
"enum": ["Critical", "High", "Medium", "Low", "Informational", "None", "Unknown"]
},
"stringArray": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"rule": {
"type": "object",
"required": ["name", "action"],
"stringArray": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"exceptionEffect": {
"type": "object",
"required": ["id", "effect"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"effect": {
"type": "string",
"enum": ["suppress", "defer", "downgrade", "requireControl"]
},
"downgradeSeverity": {
"$ref": "#/$defs/severity"
},
"requiredControlId": {
"$ref": "#/$defs/identifier"
},
"routingTemplate": {
"$ref": "#/$defs/identifier"
},
"maxDurationDays": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"effect": {
"const": "downgrade"
}
},
"required": ["effect"]
},
"then": {
"required": ["downgradeSeverity"]
}
},
{
"if": {
"properties": {
"effect": {
"const": "requireControl"
}
},
"required": ["effect"]
},
"then": {
"required": ["requiredControlId"]
}
}
]
},
"exceptionRoutingTemplate": {
"type": "object",
"required": ["id", "authorityRouteId"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"description": {
"type": "string"
},
"authorityRouteId": {
"$ref": "#/$defs/identifier"
},
"requireMfa": {
"type": "boolean"
}
},
"additionalProperties": false
},
"rule": {
"type": "object",
"required": ["name", "action"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"

View File

@@ -22,7 +22,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-EXC-25-001 | TODO | Policy Guild, Governance Guild | POLICY-SPL-23-001 | Extend SPL schema/spec to reference exception effects and routing templates; publish updated docs and validation fixtures. | Schema updated with exception references; validation tests cover effect types; docs draft ready. |
| POLICY-EXC-25-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-SPL-23-001 | Extend SPL schema/spec to reference exception effects and routing templates; publish updated docs and validation fixtures. | Schema updated with exception references; validation tests cover effect types; docs draft ready. |
## Reachability v1 (Epic 8)

View File

@@ -1,10 +1,14 @@
# SBOM Service Task Board — Epic 3: Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SBOM-SERVICE-21-001 | TODO | SBOM Service Guild, Cartographer Guild | CONCELIER-GRAPH-21-001 | Publish normalized SBOM projection schema (components, relationships, scopes, entrypoints) and implement read API with pagination + tenant enforcement. | Schema validated with fixtures; API documented; integration tests cover CycloneDX/SPDX inputs. |
| SBOM-SERVICE-21-002 | TODO | SBOM Service Guild, Scheduler Guild | SBOM-SERVICE-21-001, SCHED-MODELS-21-001 | Emit change events (`sbom.version.created`) carrying digest/version metadata for Graph Indexer builds; add replay/backfill tooling. | Events published on new SBOMs; consumer harness validated; replay scripts documented. |
| SBOM-SERVICE-21-003 | TODO | SBOM Service Guild | SBOM-SERVICE-21-001 | Provide entrypoint/service node management API (list/update overrides) feeding Cartographer path relevance with deterministic defaults. | Entrypoint API live; overrides persisted; docs updated; tests cover fallback logic. |
| SBOM-SERVICE-21-004 | TODO | SBOM Service Guild, Observability Guild | SBOM-SERVICE-21-001 | Wire observability: metrics (`sbom_projection_seconds`, `sbom_projection_size`), traces, structured logs with tenant info; set alerts for backlog. | Metrics/traces exposed; dashboards updated; alert thresholds defined. |
| SBOM-SERVICE-21-001 | BLOCKED (2025-10-27) | SBOM Service Guild, Cartographer Guild | CONCELIER-GRAPH-21-001 | Publish normalized SBOM projection schema (components, relationships, scopes, entrypoints) and implement read API with pagination + tenant enforcement. | Schema validated with fixtures; API documented; integration tests cover CycloneDX/SPDX inputs. |
> 2025-10-27: Awaiting projection schema from Concelier (`CONCELIER-GRAPH-21-001`) before we can finalize API payloads and fixtures.
| SBOM-SERVICE-21-002 | BLOCKED (2025-10-27) | SBOM Service Guild, Scheduler Guild | SBOM-SERVICE-21-001, SCHED-MODELS-21-001 | Emit change events (`sbom.version.created`) carrying digest/version metadata for Graph Indexer builds; add replay/backfill tooling. | Events published on new SBOMs; consumer harness validated; replay scripts documented. |
> 2025-10-27: Blocked until `SBOM-SERVICE-21-001` defines projection schema and endpoints.
| SBOM-SERVICE-21-003 | BLOCKED (2025-10-27) | SBOM Service Guild | SBOM-SERVICE-21-001 | Provide entrypoint/service node management API (list/update overrides) feeding Cartographer path relevance with deterministic defaults. | Entrypoint API live; overrides persisted; docs updated; tests cover fallback logic. |
> 2025-10-27: Depends on base projection schema (`SBOM-SERVICE-21-001`) which is blocked.
| SBOM-SERVICE-21-004 | BLOCKED (2025-10-27) | SBOM Service Guild, Observability Guild | SBOM-SERVICE-21-001 | Wire observability: metrics (`sbom_projection_seconds`, `sbom_projection_size`), traces, structured logs with tenant info; set alerts for backlog. | Metrics/traces exposed; dashboards updated; alert thresholds defined. |
> 2025-10-27: Projection pipeline not in place yet; will follow once `SBOM-SERVICE-21-001` unblocks.
## Policy Engine + Editor v1

View File

@@ -26,4 +26,10 @@ public interface IRunRepository
RunQueryOptions? options = null,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<Run>> ListByStateAsync(
RunState state,
int limit = 50,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default);
}

View File

@@ -150,4 +150,27 @@ internal sealed class RunRepository : IRunRepository
var documents = await find.ToListAsync(cancellationToken).ConfigureAwait(false);
return documents.Select(RunDocumentMapper.FromBsonDocument).ToArray();
}
public async Task<IReadOnlyList<Run>> ListByStateAsync(
RunState state,
int limit = 50,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), limit, "Limit must be greater than zero.");
}
var filter = Filter.Eq("state", state.ToString().ToLowerInvariant());
var find = session is null
? _collection.Find(filter)
: _collection.Find(session, filter);
find = find.Sort(Sort.Ascending("createdAt"));
find = find.Limit(limit);
var documents = await find.ToListAsync(cancellationToken).ConfigureAwait(false);
return documents.Select(RunDocumentMapper.FromBsonDocument).ToArray();
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Hosting;
using StellaOps.Scheduler.WebService.Options;
using Xunit;
namespace StellaOps.Scheduler.WebService.Tests;
public class SchedulerPluginHostFactoryTests
{
[Fact]
public void Build_usesDefaults_whenOptionsEmpty()
{
var options = new SchedulerOptions.PluginOptions();
var contentRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(contentRoot);
try
{
var hostOptions = SchedulerPluginHostFactory.Build(options, contentRoot);
var expectedBase = Path.GetFullPath(Path.Combine(contentRoot, ".."));
var expectedPlugins = Path.Combine(expectedBase, "plugins", "scheduler");
Assert.Equal(expectedBase, hostOptions.BaseDirectory);
Assert.Equal(expectedPlugins, hostOptions.PluginsDirectory);
Assert.Single(hostOptions.SearchPatterns, "StellaOps.Scheduler.Plugin.*.dll");
Assert.True(hostOptions.EnsureDirectoryExists);
Assert.False(hostOptions.RecursiveSearch);
Assert.Empty(hostOptions.PluginOrder);
}
finally
{
Directory.Delete(contentRoot, recursive: true);
}
}
[Fact]
public void Build_respectsConfiguredValues()
{
var options = new SchedulerOptions.PluginOptions
{
BaseDirectory = Path.Combine(Path.GetTempPath(), "scheduler-options", Guid.NewGuid().ToString("N")),
Directory = Path.Combine("custom", "plugins"),
RecursiveSearch = true,
EnsureDirectoryExists = false
};
options.SearchPatterns.Add("Custom.Plugin.*.dll");
options.OrderedPlugins.Add("StellaOps.Scheduler.Plugin.Alpha");
Directory.CreateDirectory(options.BaseDirectory!);
try
{
var hostOptions = SchedulerPluginHostFactory.Build(options, contentRootPath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
var expectedPlugins = Path.GetFullPath(Path.Combine(options.BaseDirectory!, options.Directory!));
Assert.Equal(options.BaseDirectory, hostOptions.BaseDirectory);
Assert.Equal(expectedPlugins, hostOptions.PluginsDirectory);
Assert.Single(hostOptions.SearchPatterns, "Custom.Plugin.*.dll");
Assert.Single(hostOptions.PluginOrder, "StellaOps.Scheduler.Plugin.Alpha");
Assert.True(hostOptions.RecursiveSearch);
Assert.False(hostOptions.EnsureDirectoryExists);
}
finally
{
Directory.Delete(options.BaseDirectory!, recursive: true);
}
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.Hosting;
internal static class SchedulerPluginHostFactory
{
public static PluginHostOptions Build(SchedulerOptions.PluginOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(contentRootPath))
{
throw new ArgumentException("Content root path must be provided for plug-in discovery.", nameof(contentRootPath));
}
var baseDirectory = ResolveBaseDirectory(options.BaseDirectory, contentRootPath);
var pluginsDirectory = ResolvePluginsDirectory(options.Directory, baseDirectory);
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
PrimaryPrefix = "StellaOps.Scheduler",
RecursiveSearch = options.RecursiveSearch,
EnsureDirectoryExists = options.EnsureDirectoryExists
};
if (options.OrderedPlugins.Count > 0)
{
foreach (var pluginName in options.OrderedPlugins)
{
hostOptions.PluginOrder.Add(pluginName);
}
}
if (options.SearchPatterns.Count > 0)
{
foreach (var pattern in options.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
}
else
{
hostOptions.SearchPatterns.Add("StellaOps.Scheduler.Plugin.*.dll");
}
return hostOptions;
}
private static string ResolveBaseDirectory(string? configuredBaseDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredBaseDirectory))
{
return Path.GetFullPath(Path.Combine(contentRootPath, ".."));
}
return Path.IsPathRooted(configuredBaseDirectory)
? configuredBaseDirectory
: Path.GetFullPath(Path.Combine(contentRootPath, configuredBaseDirectory));
}
private static string ResolvePluginsDirectory(string? configuredDirectory, string baseDirectory)
{
var pluginsDirectory = string.IsNullOrWhiteSpace(configuredDirectory)
? Path.Combine("plugins", "scheduler")
: configuredDirectory;
return Path.IsPathRooted(pluginsDirectory)
? pluginsDirectory
: Path.GetFullPath(Path.Combine(baseDirectory, pluginsDirectory));
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Scheduler.WebService.Options;
/// <summary>
/// Scheduler host configuration defaults consumed at startup for cross-cutting concerns
/// such as plug-in discovery.
/// </summary>
public sealed class SchedulerOptions
{
public PluginOptions Plugins { get; set; } = new();
public void Validate()
{
Plugins.Validate();
}
public sealed class PluginOptions
{
/// <summary>
/// Base directory resolving relative plug-in paths. Defaults to solution root.
/// </summary>
public string? BaseDirectory { get; set; }
/// <summary>
/// Directory containing plug-in binaries. Defaults to <c>plugins/scheduler</c>.
/// </summary>
public string? Directory { get; set; }
/// <summary>
/// Controls whether sub-directories are scanned for plug-ins.
/// </summary>
public bool RecursiveSearch { get; set; } = false;
/// <summary>
/// Ensures the plug-in directory exists on startup.
/// </summary>
public bool EnsureDirectoryExists { get; set; } = true;
/// <summary>
/// Explicit plug-in discovery patterns (supports globbing).
/// </summary>
public IList<string> SearchPatterns { get; } = new List<string>();
/// <summary>
/// Optional ordered plug-in assembly names (without extension).
/// </summary>
public IList<string> OrderedPlugins { get; } = new List<string>();
public void Validate()
{
foreach (var pattern in SearchPatterns)
{
if (string.IsNullOrWhiteSpace(pattern))
{
throw new InvalidOperationException("Scheduler plug-in search patterns cannot contain null or whitespace entries.");
}
}
foreach (var assemblyName in OrderedPlugins)
{
if (string.IsNullOrWhiteSpace(assemblyName))
{
throw new InvalidOperationException("Scheduler ordered plug-in entries cannot contain null or whitespace values.");
}
}
}
}
}

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