diff --git a/docs/modules/excititor/architecture.md b/docs/modules/excititor/architecture.md index 27d3ed343..6315c2516 100644 --- a/docs/modules/excititor/architecture.md +++ b/docs/modules/excititor/architecture.md @@ -158,6 +158,7 @@ Schema: `vex` - PRIMARY KEY (`digest`, `name`) - **Observations/linksets** - use the append-only Postgres linkset schema already defined for `IAppendOnlyLinksetStore` (tables `vex_linksets`, `vex_linkset_observations`, `vex_linkset_disagreements`, `vex_linkset_mutations`) with indexes on `(tenant, vulnerability_id, product_key)` and `updated_at`. +- **Claims** - `vex.claims` stores normalized, queryable claim projections keyed by deterministic `claim_hash`, with JSONB columns for product/document metadata plus indexes on `(tenant, provider_id, vulnerability_id, product_key, last_seen)` and `(tenant, vulnerability_id, last_seen)`. - **Graph overlays** - materialized cache table `vex_overlays` (tenant, purl, advisory_id, source) storing JSONB payloads that follow `docs/modules/excititor/schemas/vex_overlay.schema.json` (schemaVersion 1.0.0). Cache eviction via `cached_at + ttl_seconds`; overlays regenerate when linkset or observation hashes change. **Canonicalisation & hashing** @@ -175,11 +176,11 @@ List/query `/vex/raw` via `SELECT ... FROM vex.vex_raw_documents WHERE tenant=@t - `IVexRawStore` Postgres implementation enforces append-only inserts; duplicate `digest` => no-op; duplicate (`tenant`, `provider_id`, `source_uri`, `etag`) with new digest inserts a new row and sets `supersedes_digest`. - `IVexRawWriteGuard` runs before insert; tenant is mandatory on every query and write. -**Rollout** +**Runtime convergence** -1. Add migration under `src/Excititor/__Libraries/StellaOps.Excititor.Storage.Postgres/Migrations` creating the tables/indexes above. -2. Implement `PostgresVexRawStore` and switch WebService/Worker DI to `AddExcititorPostgresStorage`. -3. Update `/vex/raw` endpoints/tests to the PostgreSQL store. +1. `StellaOps.Excititor.WebService` and `StellaOps.Excititor.Worker` resolve `IVexProviderStore`, `IVexConnectorStateRepository`, and `IVexClaimStore` from `AddExcititorPersistence`; the live hosts do not register in-memory fallbacks. +2. `StellaOps.Excititor.Persistence` owns startup migrations for the `vex` schema, including `vex.claims` creation and cleanup of historical demo rows from older local installs. +3. The Excititor migration assembly embeds only active top-level SQL files. Archived pre-1.0 scripts and demo-seed SQL are excluded so startup/test migration loaders do not replay historical or fake runtime state. --- diff --git a/docs/modules/vex-hub/architecture.md b/docs/modules/vex-hub/architecture.md index 5e150cb9b..75e9aff64 100644 --- a/docs/modules/vex-hub/architecture.md +++ b/docs/modules/vex-hub/architecture.md @@ -33,10 +33,13 @@ Non-goals: policy decisioning (Policy Engine), consensus computation (VexLens), All tables must include `tenant_id`, UTC timestamps, and deterministic ordering keys. -## 5) API Surface (Draft) +## 5) API Surface - `GET /api/v1/vex/cve/{cve-id}` - `GET /api/v1/vex/package/{purl}` - `GET /api/v1/vex/source/{source-id}` +- `GET /api/v1/vex/search` +- `GET /api/v1/vex/statement/{id}` +- `POST /api/v1/vex/conflicts/resolve` - `GET /api/v1/vex/stats` - `GET /api/v1/vex/export` (bulk OpenVEX feed) - `GET /api/v1/vex/index` (vex-index.json) @@ -56,6 +59,11 @@ Responses are deterministic: stable ordering by `timestamp DESC`, then `source_i The stats endpoint must keep working on fresh installs even when a committed EF compiled-model stub is empty; runtime model fallback is required until a real optimized model is generated. The service must also auto-apply embedded SQL migrations for schema `vexhub` on startup so wiped volumes converge without manual SQL bootstrap. +### Console VEX Runtime Contract +- The browser search and statement-detail surfaces read from `GET /api/v1/vex/search` and `GET /api/v1/vex/statement/{id}`. +- Consensus and conflict analysis are computed through `POST /api/v1/vexlens/consensus`. +- Conflict resolution is a real backend mutation through `POST /api/v1/vex/conflicts/resolve`. + ## 6) Determinism & Offline Posture - Ingestion runs against frozen snapshots where possible; all outputs include `snapshot_hash`. - Canonical JSON serialization with stable key ordering. @@ -63,10 +71,15 @@ The service must also auto-apply embedded SQL migrations for schema `vexhub` on - Bulk exports are immutable and content-addressed. ## 7) Security & Auth -- API access requires Authority scopes (`vexhub.read`, `vexhub.admin`). +- First-party StellaOps callers authenticate with Authority bearer tokens and canonical scopes `vexhub:read` and `vexhub:admin`. +- External tooling may still authenticate with explicit API keys, but VexHub normalizes any legacy API-key scope values onto the same canonical Authority scopes before authorization. - Signature verification follows issuer registry rules; failures are surfaced as metadata, not silent drops. - Rate limiting enforced at API gateway and per-client tokens. +## 7.1) Export Contract +- `GET /api/v1/vex/export` returns deterministic OpenVEX JSON with `application/vnd.openvex+json` when the backend export succeeds. +- Export generation failures must surface as truthful `problem+json` `500` responses; the service must not fabricate empty OpenVEX success documents to mask backend state or persistence failures. + ## 8) Observability - Metrics: `vexhub_ingest_total`, `vexhub_validation_failures_total`, `vexhub_conflicts_total`, `vexhub_export_duration_seconds`. - Logs: include `tenant_id`, `source_id`, `statement_hash`, and `trace_id`. @@ -77,6 +90,7 @@ The service must also auto-apply embedded SQL migrations for schema `vexhub` on - **VexLens**: consumes normalized statements and provenance for trust scoring and consensus. - **Policy Engine**: reads VexLens consensus results; VexHub provides external distribution. - **UI**: VEX conflict studio consumes conflict API once available. +- **Console UI**: combines VexHub statement/search data with VexLens consensus results over the live HTTP contract. ## 10) Testing Strategy - Unit tests for normalization and validation pipelines. @@ -84,4 +98,4 @@ The service must also auto-apply embedded SQL migrations for schema `vexhub` on - Persistence registration and runtime-model tests that prove source/conflict/ingestion-job repositories and startup migrations are wired on the service path. - Determinism tests comparing repeated exports with identical inputs. -*Last updated: 2026-03-13.* +*Last updated: 2026-04-14.* diff --git a/docs/modules/vex-hub/integration-guide.md b/docs/modules/vex-hub/integration-guide.md index 45b982ae8..7c175b5a5 100644 --- a/docs/modules/vex-hub/integration-guide.md +++ b/docs/modules/vex-hub/integration-guide.md @@ -199,7 +199,10 @@ echo "VEX file updated: $(jq '.statements | length' "$VEX_FILE") statements" ## 6) API Authentication -VexHub supports API key authentication for increased rate limits and access control. +VexHub supports two authentication modes: + +- First-party StellaOps UI and service callers use Authority bearer tokens with the canonical scopes `vexhub:read` and `vexhub:admin`. +- External scanners and automation can continue using API keys for increased rate limits and simpler non-interactive integration. ### Rate Limits @@ -233,6 +236,16 @@ X-RateLimit-Reset: 1703260800 When rate limited, the response is `429 Too Many Requests` with `Retry-After` header. +### First-Party Bearer Access + +If the caller is already signed in through StellaOps Authority, use the bearer token issued for the UI/client instead of an API key: + +```bash +curl -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/vnd.openvex+json" \ + https://vexhub.example.com/api/v1/vex/export +``` + ## 7) CI/CD Integration ### GitHub Actions @@ -371,7 +384,7 @@ def verify_webhook(payload: bytes, signature: str, secret: str) -> bool: **Authentication failures:** - Verify API key is correct -- Check key has required scopes (`vexhub.read`) +- Check the caller has the required canonical scope (`vexhub:read`) - Ensure key hasn't expired ### Debug Mode diff --git a/docs/modules/vex-lens/architecture.md b/docs/modules/vex-lens/architecture.md index 11cb413d3..652301e22 100644 --- a/docs/modules/vex-lens/architecture.md +++ b/docs/modules/vex-lens/architecture.md @@ -59,11 +59,19 @@ Conflicts remain visible through `conflicts` array; Policy Engine can decide sup All responses include provenance fields (`consensus_digest`, `derived_from`, DSSE signature references) for audit. -## 5) Storage - -- `vex_consensus` collection keyed by `(tenant, artifactId, advisoryKey)` with current consensus, metadata, conflict summary, and digests. -- `vex_consensus_history` append-only history to support replay and audit. -- `vex_conflict_queue` for unresolved conflicts requiring manual review. +## 5) Storage + +- `vex_consensus` collection keyed by `(tenant, artifactId, advisoryKey)` with current consensus, metadata, conflict summary, and digests. +- `vex_consensus_history` append-only history to support replay and audit. +- `vex_conflict_queue` for unresolved conflicts requiring manual review. +- `vexlens.noise_gate_raw_snapshots`, `vexlens.noise_gate_gated_snapshots`, and `vexlens.noise_gate_statistics` store the live noise-gating raw inputs, persisted gated outputs, and aggregated statistics for the `/api/v1/vexlens/gating/*` surface. + +### Noise-gating runtime contract (2026-04-14) + +- The owning VexLens web runtime resolves `ISnapshotStore` and `IGatingStatisticsStore` to PostgreSQL-backed implementations, not process-local in-memory stores. +- Startup schema convergence for the noise-gating tables is owned by embedded startup migration `002_noise_gating_state.sql` in `StellaOps.VexLens.Persistence`; fresh local volumes must converge without manual SQL. +- `POST /api/v1/vexlens/gating/snapshots/{snapshotId}/gate` now persists the gated snapshot and statistics as part of the live request path, so later delta/statistics reads reflect durable backend state. +- The Angular production app binds the real VexLens noise-gating HTTP client through `app.config.ts`, so triage surfaces call `/api/v1/vexlens/gating/*` instead of relying on optional providers or mock helpers. ## 6) Recompute strategy diff --git a/src/Concelier/StellaOps.Excititor.WebService/Program.cs b/src/Concelier/StellaOps.Excititor.WebService/Program.cs index 0c0453b29..11098ddef 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Program.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Program.cs @@ -59,9 +59,6 @@ services.AddOptions() .Bind(configuration.GetSection("Excititor:Graph")); services.AddExcititorPersistence(configuration); -services.TryAddSingleton(); -services.TryAddScoped(); -services.TryAddSingleton(); services.AddCsafNormalizer(); services.AddCycloneDxNormalizer(); services.AddOpenVexNormalizer(); diff --git a/src/Concelier/StellaOps.Excititor.WebService/TASKS.md b/src/Concelier/StellaOps.Excititor.WebService/TASKS.md index b8144976d..271518b68 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/TASKS.md +++ b/src/Concelier/StellaOps.Excititor.WebService/TASKS.md @@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0327-M | DONE | Revalidated 2026-01-07; maintainability audit for Excititor.WebService. | | AUDIT-0327-T | DONE | Revalidated 2026-01-07; test coverage audit for Excititor.WebService. | | AUDIT-0327-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). | +| NOMOCK-012 | DONE | 2026-04-14: Removed live `InMemoryVexProviderStore`, `InMemoryVexConnectorStateRepository`, and `InMemoryVexClaimStore` fallbacks so the web runtime now resolves the persistence-backed Excititor stores. | diff --git a/src/Concelier/StellaOps.Excititor.Worker/Program.cs b/src/Concelier/StellaOps.Excititor.Worker/Program.cs index 279ef2803..b74b83825 100644 --- a/src/Concelier/StellaOps.Excititor.Worker/Program.cs +++ b/src/Concelier/StellaOps.Excititor.Worker/Program.cs @@ -54,9 +54,6 @@ services.AddOptions() .ValidateOnStart(); services.AddExcititorPersistence(configuration); -services.TryAddSingleton(); -services.TryAddScoped(); -services.TryAddSingleton(); services.AddCsafNormalizer(); services.AddCycloneDxNormalizer(); services.AddOpenVexNormalizer(); diff --git a/src/Concelier/StellaOps.Excititor.Worker/TASKS.md b/src/Concelier/StellaOps.Excititor.Worker/TASKS.md index 792c7176e..9b4725c94 100644 --- a/src/Concelier/StellaOps.Excititor.Worker/TASKS.md +++ b/src/Concelier/StellaOps.Excititor.Worker/TASKS.md @@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0329-M | DONE | Revalidated 2026-01-07; maintainability audit for Excititor.Worker. | | AUDIT-0329-T | DONE | Revalidated 2026-01-07; test coverage audit for Excititor.Worker. | | AUDIT-0329-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). | +| NOMOCK-012 | DONE | 2026-04-14: Removed live in-memory VEX provider, connector-state, and claim-store fallbacks so worker jobs use the real persistence-backed Excititor runtime. | diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs index 871a03c31..7c240ab5e 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs @@ -5,6 +5,7 @@ using StellaOps.Excititor.Core.Observations; using StellaOps.Excititor.Core.Storage; using StellaOps.Excititor.Persistence.Postgres; using StellaOps.Excititor.Persistence.Postgres.Repositories; +using StellaOps.Infrastructure.Postgres.Migrations; using StellaOps.Infrastructure.Postgres; using StellaOps.Infrastructure.Postgres.Options; @@ -32,6 +33,15 @@ public static class ExcititorPersistenceExtensions services.Configure(configuration.GetSection(sectionName)); services.Configure(configuration.GetSection("Excititor:Storage")); services.AddSingleton(); + services.AddStartupMigrations( + ExcititorDataSource.DefaultSchemaName, + "Excititor.Persistence", + typeof(ExcititorDataSource).Assembly); + services.AddMigrationStatus( + ExcititorDataSource.DefaultSchemaName, + "Excititor.Persistence", + typeof(ExcititorDataSource).Assembly, + options => options.ConnectionString); // Register repositories services.AddScoped(); @@ -39,6 +49,7 @@ public static class ExcititorPersistenceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Register append-only checkpoint store for deterministic persistence (EXCITITOR-ORCH-32/33) services.AddScoped(); @@ -64,6 +75,15 @@ public static class ExcititorPersistenceExtensions { services.Configure(configureOptions); services.AddSingleton(); + services.AddStartupMigrations( + ExcititorDataSource.DefaultSchemaName, + "Excititor.Persistence", + typeof(ExcititorDataSource).Assembly); + services.AddMigrationStatus( + ExcititorDataSource.DefaultSchemaName, + "Excititor.Persistence", + typeof(ExcititorDataSource).Assembly, + options => options.ConnectionString); // Register repositories services.AddScoped(); @@ -71,6 +91,7 @@ public static class ExcititorPersistenceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Register append-only checkpoint store for deterministic persistence (EXCITITOR-ORCH-32/33) services.AddScoped(); diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/003_vex_claim_store.sql b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/003_vex_claim_store.sql new file mode 100644 index 000000000..c5bd795d0 --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/003_vex_claim_store.sql @@ -0,0 +1,76 @@ +-- Migration: 003_vex_claim_store +-- Category: startup +-- Description: Remove historical Excititor demo seed data and persist raw VEX claims in PostgreSQL. + +-- Remove demo data from earlier local installs so live runtimes converge without seeded payloads. +DELETE FROM vex.linkset_mutations +WHERE linkset_id IN ('demo-linkset-001', 'demo-linkset-002', 'demo-linkset-003', 'demo-linkset-004', 'demo-linkset-005'); + +DELETE FROM vex.linkset_disagreements +WHERE linkset_id IN ('demo-linkset-001', 'demo-linkset-002', 'demo-linkset-003', 'demo-linkset-004', 'demo-linkset-005'); + +DELETE FROM vex.linkset_observations +WHERE linkset_id IN ('demo-linkset-001', 'demo-linkset-002', 'demo-linkset-003', 'demo-linkset-004', 'demo-linkset-005'); + +DELETE FROM vex.linksets +WHERE tenant = 'demo-prod' + AND linkset_id IN ('demo-linkset-001', 'demo-linkset-002', 'demo-linkset-003', 'demo-linkset-004', 'demo-linkset-005'); + +DELETE FROM vex.vex_raw_documents +WHERE tenant = 'demo-prod' + AND digest IN ('sha256:demo-vex-doc-001', 'sha256:demo-vex-doc-002', 'sha256:demo-vex-doc-003'); + +DELETE FROM excititor.source_trust_vectors +WHERE tenant = 'demo-prod' + AND source_id IN ('nvd', 'osv', 'github', 'nginx-security', 'npm-advisory'); + +CREATE TABLE IF NOT EXISTS vex.claims ( + tenant TEXT NOT NULL, + claim_hash TEXT NOT NULL, + vulnerability_id TEXT NOT NULL, + product_key TEXT NOT NULL, + provider_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('affected', 'not_affected', 'fixed', 'under_investigation')), + justification TEXT NULL, + detail TEXT NULL, + first_seen TIMESTAMPTZ NOT NULL, + last_seen TIMESTAMPTZ NOT NULL, + document_digest TEXT NOT NULL, + observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + product_json JSONB NOT NULL, + document_json JSONB NOT NULL, + confidence_json JSONB NULL, + signals_json JSONB NULL, + additional_metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant, claim_hash) +); + +CREATE INDEX IF NOT EXISTS idx_claims_lookup + ON vex.claims (tenant, vulnerability_id, product_key, last_seen DESC, provider_id, claim_hash); + +CREATE INDEX IF NOT EXISTS idx_claims_vulnerability + ON vex.claims (tenant, vulnerability_id, last_seen DESC, provider_id, claim_hash); + +CREATE INDEX IF NOT EXISTS idx_claims_document_digest + ON vex.claims (tenant, document_digest); + +ALTER TABLE vex.claims ENABLE ROW LEVEL SECURITY; +ALTER TABLE vex.claims FORCE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'vex' + AND tablename = 'claims' + AND policyname = 'claims_tenant_isolation' + ) THEN + CREATE POLICY claims_tenant_isolation ON vex.claims + FOR ALL + USING (tenant = vex_app.require_current_tenant()) + WITH CHECK (tenant = vex_app.require_current_tenant()); + END IF; +END +$$; diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/S001_demo_seed.sql b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/S001_demo_seed.sql deleted file mode 100644 index 8ed8f9228..000000000 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/S001_demo_seed.sql +++ /dev/null @@ -1,110 +0,0 @@ --- Migration: S001_demo_seed --- Category: seed --- Description: Demo data for Excititor/VEX module (linksets, observations, raw documents) --- Idempotent: ON CONFLICT DO NOTHING - --- ============================================================================ --- VEX Linksets (vulnerability-product associations) --- ============================================================================ - -INSERT INTO vex.linksets (linkset_id, tenant, vulnerability_id, product_key, scope) -VALUES - ('demo-linkset-001', 'demo-prod', 'CVE-2026-10001', 'pkg:oci/nginx@1.25.4', - '{"environment": "production", "region": "us-east-1"}'::jsonb), - ('demo-linkset-002', 'demo-prod', 'CVE-2026-10003', 'pkg:npm/lodash@4.17.21', - '{"environment": "production", "service": "webapp-frontend"}'::jsonb), - ('demo-linkset-003', 'demo-prod', 'CVE-2026-10010', 'pkg:oci/containerd@1.7.11', - '{"environment": "production", "hosts": ["host-01", "host-02", "host-03"]}'::jsonb), - ('demo-linkset-004', 'demo-prod', 'CVE-2026-10005', 'pkg:generic/openssl@3.2.0', - '{"environment": "production", "service": "api-gateway"}'::jsonb), - ('demo-linkset-005', 'demo-prod', 'CVE-2026-10017', 'pkg:npm/jsonwebtoken@9.0.0', - '{"environment": "staging", "service": "auth-service"}'::jsonb) -ON CONFLICT (tenant, vulnerability_id, product_key) DO NOTHING; - --- ============================================================================ --- Linkset Observations (status determinations from VEX providers) --- ============================================================================ - -INSERT INTO vex.linkset_observations (linkset_id, observation_id, provider_id, status, confidence) -VALUES - -- CVE-2026-10001 on nginx: vendor says not_affected (patched in distro) - ('demo-linkset-001', 'obs-001', 'nginx-security', 'not_affected', 0.95), - ('demo-linkset-001', 'obs-002', 'nvd', 'affected', 0.80), - -- CVE-2026-10003 on lodash: confirmed affected, fix available - ('demo-linkset-002', 'obs-003', 'npm-advisory', 'affected', 0.99), - ('demo-linkset-002', 'obs-004', 'lodash-maintainer', 'fixed', 0.95), - -- CVE-2026-10010 on containerd: confirmed affected, under investigation - ('demo-linkset-003', 'obs-005', 'containerd-security', 'under_investigation', 0.90), - ('demo-linkset-003', 'obs-006', 'nvd', 'affected', 0.95), - -- CVE-2026-10005 on openssl: not affected (backport applied) - ('demo-linkset-004', 'obs-007', 'openssl-security', 'not_affected', 0.98), - -- CVE-2026-10017 on jsonwebtoken: affected - ('demo-linkset-005', 'obs-008', 'npm-advisory', 'affected', 0.99) -ON CONFLICT (linkset_id, observation_id, provider_id, status) DO NOTHING; - --- ============================================================================ --- Linkset Disagreements (conflicting status from different providers) --- ============================================================================ - -INSERT INTO vex.linkset_disagreements (linkset_id, provider_id, status, justification, confidence) -VALUES - -- nginx: NVD says affected, vendor says not_affected - ('demo-linkset-001', 'nvd', 'affected', 'NVD lists nginx 1.25.4 as affected based on upstream libxml2 dependency', 0.80), - ('demo-linkset-001', 'nginx-security', 'not_affected', 'Distro-patched libxml2 used in official image; CVE does not apply to this build', 0.95) -ON CONFLICT (linkset_id, provider_id, status, justification) DO NOTHING; - --- ============================================================================ --- Linkset Mutations (audit trail) --- ============================================================================ - -INSERT INTO vex.linkset_mutations (linkset_id, mutation_type, observation_id, provider_id, status, confidence, occurred_at) -VALUES - ('demo-linkset-001', 'linkset_created', NULL, NULL, NULL, NULL, NOW() - INTERVAL '5 days'), - ('demo-linkset-001', 'observation_added', 'obs-001', 'nginx-security', 'not_affected', 0.95, NOW() - INTERVAL '5 days'), - ('demo-linkset-001', 'observation_added', 'obs-002', 'nvd', 'affected', 0.80, NOW() - INTERVAL '4 days'), - ('demo-linkset-001', 'disagreement_added', 'nvd', 'nvd', 'affected', 0.80, NOW() - INTERVAL '4 days'), - ('demo-linkset-003', 'linkset_created', NULL, NULL, NULL, NULL, NOW() - INTERVAL '2 days'), - ('demo-linkset-003', 'observation_added', 'obs-005', 'containerd-security', 'under_investigation', 0.90, NOW() - INTERVAL '2 days'), - ('demo-linkset-003', 'observation_added', 'obs-006', 'nvd', 'affected', 0.95, NOW() - INTERVAL '1 day'); - --- ============================================================================ --- VEX Raw Documents (sample VEX documents from providers) --- ============================================================================ - -INSERT INTO vex.vex_raw_documents (digest, tenant, provider_id, format, source_uri, retrieved_at, content_json, content_size_bytes, metadata_json, provenance_json) -VALUES - ('sha256:demo-vex-doc-001', 'demo-prod', 'nginx-security', 'openvex', - 'https://nginx.org/.well-known/vex/CVE-2026-10001.json', - NOW() - INTERVAL '5 days', - '{"@context": "https://openvex.dev/ns/v0.2.0", "@id": "https://nginx.org/vex/CVE-2026-10001", "author": "NGINX Security Team", "timestamp": "2026-02-16T10:00:00Z", "statements": [{"vulnerability": {"name": "CVE-2026-10001"}, "products": [{"@id": "pkg:oci/nginx@1.25.4"}], "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path"}]}'::jsonb, - 512, - '{"formatVersion": "0.2.0", "toolName": "vexctl", "toolVersion": "0.3.0"}'::jsonb, - '{"author": "NGINX Security Team", "timestamp": "2026-02-16T10:00:00Z", "source": "nginx.org"}'::jsonb), - ('sha256:demo-vex-doc-002', 'demo-prod', 'npm-advisory', 'csaf', - 'https://registry.npmjs.org/-/npm/v1/advisories/demo-lodash-2026', - NOW() - INTERVAL '7 days', - '{"document": {"category": "csaf_vex", "title": "lodash prototype pollution", "publisher": {"name": "npm"}, "tracking": {"id": "npm-lodash-2026-001", "status": "final"}}, "vulnerabilities": [{"cve": "CVE-2026-10003"}], "product_tree": {"branches": [{"name": "lodash", "product": {"product_id": "pkg:npm/lodash@4.17.21"}}]}}'::jsonb, - 1024, - '{"formatVersion": "2.0", "toolName": "npm-advisory-exporter", "toolVersion": "1.0.0"}'::jsonb, - '{"author": "npm Security", "timestamp": "2026-02-14T08:00:00Z", "source": "npmjs.org"}'::jsonb), - ('sha256:demo-vex-doc-003', 'demo-prod', 'containerd-security', 'openvex', - 'https://github.com/containerd/containerd/security/advisories/GHSA-demo-0010.json', - NOW() - INTERVAL '2 days', - '{"@context": "https://openvex.dev/ns/v0.2.0", "@id": "https://containerd.io/vex/CVE-2026-10010", "author": "containerd maintainers", "timestamp": "2026-02-19T08:00:00Z", "statements": [{"vulnerability": {"name": "CVE-2026-10010"}, "products": [{"@id": "pkg:oci/containerd@1.7.11"}], "status": "under_investigation"}]}'::jsonb, - 384, - '{"formatVersion": "0.2.0", "toolName": "vexctl", "toolVersion": "0.3.0"}'::jsonb, - '{"author": "containerd maintainers", "timestamp": "2026-02-19T08:00:00Z", "source": "github.com/containerd"}'::jsonb) -ON CONFLICT (digest) DO NOTHING; - --- ============================================================================ --- Calibration Data (Excititor source trust vectors) --- ============================================================================ - -INSERT INTO excititor.source_trust_vectors (id, tenant, source_id, provenance, coverage, replayability) -VALUES - ('f0000001-0000-0000-0000-000000000001', 'demo-prod', 'nvd', 0.95, 0.85, 0.90), - ('f0000001-0000-0000-0000-000000000002', 'demo-prod', 'osv', 0.88, 0.92, 0.85), - ('f0000001-0000-0000-0000-000000000003', 'demo-prod', 'github', 0.90, 0.78, 0.88), - ('f0000001-0000-0000-0000-000000000004', 'demo-prod', 'nginx-security', 0.97, 0.45, 0.95), - ('f0000001-0000-0000-0000-000000000005', 'demo-prod', 'npm-advisory', 0.92, 0.88, 0.82) -ON CONFLICT (tenant, source_id) DO NOTHING; diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexClaimStore.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexClaimStore.cs new file mode 100644 index 000000000..4206bfde8 --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexClaimStore.cs @@ -0,0 +1,415 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Npgsql; +using NpgsqlTypes; +using StellaOps.Excititor.Core; +using StellaOps.Excititor.Core.Storage; +using StellaOps.Infrastructure.Postgres.Repositories; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace StellaOps.Excititor.Persistence.Postgres.Repositories; + +/// +/// PostgreSQL-backed store for raw VEX claims consumed by Excititor policy and evidence flows. +/// +public sealed class PostgresVexClaimStore : RepositoryBase, IVexClaimStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = false, + }; + + private readonly string _tenantId; + + public PostgresVexClaimStore( + ExcititorDataSource dataSource, + IOptions storageOptions, + ILogger logger) + : base(dataSource, logger) + { + ArgumentNullException.ThrowIfNull(storageOptions); + + _tenantId = string.IsNullOrWhiteSpace(storageOptions.Value.DefaultTenant) + ? "default" + : storageOptions.Value.DefaultTenant.Trim().ToLowerInvariant(); + } + + public async ValueTask AppendAsync(IEnumerable claims, DateTimeOffset observedAt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(claims); + + var preparedClaims = claims.Select(PrepareClaim).ToList(); + if (preparedClaims.Count == 0) + { + return; + } + + var schema = ExcititorDataSource.DefaultSchemaName; + var sql = $""" + INSERT INTO {schema}.claims ( + tenant, + claim_hash, + vulnerability_id, + product_key, + provider_id, + status, + justification, + detail, + first_seen, + last_seen, + document_digest, + observed_at, + product_json, + document_json, + confidence_json, + signals_json, + additional_metadata_json) + VALUES ( + @tenant, + @claim_hash, + @vulnerability_id, + @product_key, + @provider_id, + @status, + @justification, + @detail, + @first_seen, + @last_seen, + @document_digest, + @observed_at, + @product_json, + @document_json, + @confidence_json, + @signals_json, + @additional_metadata_json) + ON CONFLICT (tenant, claim_hash) DO UPDATE + SET first_seen = LEAST({schema}.claims.first_seen, EXCLUDED.first_seen), + last_seen = GREATEST({schema}.claims.last_seen, EXCLUDED.last_seen), + observed_at = GREATEST({schema}.claims.observed_at, EXCLUDED.observed_at), + confidence_json = COALESCE(EXCLUDED.confidence_json, {schema}.claims.confidence_json), + signals_json = COALESCE(EXCLUDED.signals_json, {schema}.claims.signals_json), + additional_metadata_json = COALESCE(EXCLUDED.additional_metadata_json, {schema}.claims.additional_metadata_json) + """; + + await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + foreach (var claim in preparedClaims) + { + await using var command = CreateCommand(sql, connection); + command.Transaction = transaction; + + AddParameter(command, "tenant", _tenantId); + AddParameter(command, "claim_hash", claim.ClaimHash); + AddParameter(command, "vulnerability_id", claim.VulnerabilityId); + AddParameter(command, "product_key", claim.ProductKey); + AddParameter(command, "provider_id", claim.ProviderId); + AddParameter(command, "status", claim.Status); + AddParameter(command, "justification", claim.Justification); + AddParameter(command, "detail", claim.Detail); + AddParameter(command, "first_seen", claim.FirstSeen); + AddParameter(command, "last_seen", claim.LastSeen); + AddParameter(command, "document_digest", claim.DocumentDigest); + AddParameter(command, "observed_at", observedAt); + AddJsonbParameter(command, "product_json", claim.ProductJson); + AddJsonbParameter(command, "document_json", claim.DocumentJson); + AddJsonbParameter(command, "confidence_json", claim.ConfidenceJson); + AddJsonbParameter(command, "signals_json", claim.SignalsJson); + AddJsonbParameter(command, "additional_metadata_json", claim.AdditionalMetadataJson); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + + public async ValueTask> FindAsync( + string vulnerabilityId, + string productKey, + DateTimeOffset? since, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId); + ArgumentException.ThrowIfNullOrWhiteSpace(productKey); + + var schema = ExcititorDataSource.DefaultSchemaName; + var sql = $""" + SELECT vulnerability_id, + provider_id, + product_json, + status, + justification, + detail, + document_json, + first_seen, + last_seen, + confidence_json, + signals_json, + additional_metadata_json + FROM {schema}.claims + WHERE tenant = @tenant + AND LOWER(vulnerability_id) = LOWER(@vulnerability_id) + AND LOWER(product_key) = LOWER(@product_key) + AND (@since IS NULL OR last_seen >= @since) + ORDER BY last_seen DESC, provider_id ASC, claim_hash ASC + """; + + return await QueryClaimsAsync( + sql, + command => + { + AddParameter(command, "tenant", _tenantId); + AddParameter(command, "vulnerability_id", vulnerabilityId.Trim()); + AddParameter(command, "product_key", productKey.Trim()); + AddParameter(command, "since", since); + }, + cancellationToken).ConfigureAwait(false); + } + + public async ValueTask> FindByVulnerabilityAsync( + string vulnerabilityId, + int limit, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId); + + var effectiveLimit = limit <= 0 ? 100 : limit; + var schema = ExcititorDataSource.DefaultSchemaName; + var sql = $""" + SELECT vulnerability_id, + provider_id, + product_json, + status, + justification, + detail, + document_json, + first_seen, + last_seen, + confidence_json, + signals_json, + additional_metadata_json + FROM {schema}.claims + WHERE tenant = @tenant + AND LOWER(vulnerability_id) = LOWER(@vulnerability_id) + ORDER BY last_seen DESC, provider_id ASC, claim_hash ASC + LIMIT @limit + """; + + return await QueryClaimsAsync( + sql, + command => + { + AddParameter(command, "tenant", _tenantId); + AddParameter(command, "vulnerability_id", vulnerabilityId.Trim()); + AddParameter(command, "limit", effectiveLimit); + }, + cancellationToken).ConfigureAwait(false); + } + + private async Task> QueryClaimsAsync( + string sql, + Action configureCommand, + CancellationToken cancellationToken) + { + await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "reader", cancellationToken).ConfigureAwait(false); + await using var command = CreateCommand(sql, connection); + configureCommand(command); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var claims = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + claims.Add(MapClaim(reader)); + } + + return claims; + } + + private static StoredClaim PrepareClaim(VexClaim claim) + { + ArgumentNullException.ThrowIfNull(claim); + + var productJson = JsonSerializer.Serialize(claim.Product, SerializerOptions); + var documentJson = JsonSerializer.Serialize(claim.Document, SerializerOptions); + var confidenceJson = claim.Confidence is null ? null : JsonSerializer.Serialize(claim.Confidence, SerializerOptions); + var signalsJson = claim.Signals is null ? null : JsonSerializer.Serialize(claim.Signals, SerializerOptions); + var additionalMetadataJson = JsonSerializer.Serialize(claim.AdditionalMetadata, SerializerOptions); + var status = ToStorageValue(claim.Status); + var justification = claim.Justification is null ? null : ToStorageValue(claim.Justification.Value); + + var canonicalKey = JsonSerializer.Serialize(new + { + claim.VulnerabilityId, + claim.ProviderId, + Product = productJson, + Status = status, + Justification = justification, + claim.Detail, + Document = documentJson, + Confidence = confidenceJson, + Signals = signalsJson, + AdditionalMetadata = additionalMetadataJson, + }, SerializerOptions); + + return new StoredClaim( + ClaimHash: ComputeSha256(canonicalKey), + VulnerabilityId: claim.VulnerabilityId, + ProductKey: claim.Product.Key, + ProviderId: claim.ProviderId, + Status: status, + Justification: justification, + Detail: claim.Detail, + FirstSeen: claim.FirstSeen, + LastSeen: claim.LastSeen, + DocumentDigest: claim.Document.Digest, + ProductJson: productJson, + DocumentJson: documentJson, + ConfidenceJson: confidenceJson, + SignalsJson: signalsJson, + AdditionalMetadataJson: additionalMetadataJson); + } + + private static VexClaim MapClaim(NpgsqlDataReader reader) + { + var vulnerabilityId = reader.GetString(0); + var providerId = reader.GetString(1); + var product = JsonSerializer.Deserialize(reader.GetString(2), SerializerOptions) + ?? throw new InvalidOperationException("Stored claim product payload is invalid."); + var status = ParseStatus(reader.GetString(3)); + var justification = ParseJustification(GetNullableString(reader, 4)); + var detail = GetNullableString(reader, 5); + var document = JsonSerializer.Deserialize(reader.GetString(6), SerializerOptions) + ?? throw new InvalidOperationException("Stored claim document payload is invalid."); + var firstSeen = reader.GetFieldValue(7); + var lastSeen = reader.GetFieldValue(8); + var confidence = DeserializeOptional(reader, 9); + var signals = DeserializeOptional(reader, 10); + var additionalMetadata = DeserializeMetadata(reader, 11); + + return new VexClaim( + vulnerabilityId, + providerId, + product, + status, + document, + firstSeen, + lastSeen, + justification, + detail, + confidence, + signals, + additionalMetadata); + } + + private static T? DeserializeOptional(NpgsqlDataReader reader, int ordinal) where T : class + => reader.IsDBNull(ordinal) + ? null + : JsonSerializer.Deserialize(reader.GetString(ordinal), SerializerOptions); + + private static ImmutableDictionary DeserializeMetadata(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return ImmutableDictionary.Empty; + } + + var metadata = JsonSerializer.Deserialize>(reader.GetString(ordinal), SerializerOptions); + return metadata is null || metadata.Count == 0 + ? ImmutableDictionary.Empty + : metadata.ToImmutableDictionary(StringComparer.Ordinal); + } + + private static string ComputeSha256(string payload) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string ToStorageValue(VexClaimStatus status) => status switch + { + VexClaimStatus.Affected => "affected", + VexClaimStatus.NotAffected => "not_affected", + VexClaimStatus.Fixed => "fixed", + VexClaimStatus.UnderInvestigation => "under_investigation", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; + + private static string ToStorageValue(VexJustification justification) => justification switch + { + VexJustification.ComponentNotPresent => "component_not_present", + VexJustification.ComponentNotConfigured => "component_not_configured", + VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present", + VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path", + VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary", + VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist", + VexJustification.ProtectedByMitigatingControl => "protected_by_mitigating_control", + VexJustification.CodeNotPresent => "code_not_present", + VexJustification.CodeNotReachable => "code_not_reachable", + VexJustification.RequiresConfiguration => "requires_configuration", + VexJustification.RequiresDependency => "requires_dependency", + VexJustification.RequiresEnvironment => "requires_environment", + VexJustification.ProtectedByCompensatingControl => "protected_by_compensating_control", + VexJustification.ProtectedAtPerimeter => "protected_at_perimeter", + VexJustification.ProtectedAtRuntime => "protected_at_runtime", + _ => throw new ArgumentOutOfRangeException(nameof(justification), justification, null), + }; + + private static VexClaimStatus ParseStatus(string value) => value.ToLowerInvariant() switch + { + "affected" => VexClaimStatus.Affected, + "not_affected" => VexClaimStatus.NotAffected, + "fixed" => VexClaimStatus.Fixed, + "under_investigation" => VexClaimStatus.UnderInvestigation, + _ => throw new InvalidOperationException($"Unsupported stored VEX claim status '{value}'."), + }; + + private static VexJustification? ParseJustification(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.ToLowerInvariant() switch + { + "component_not_present" => VexJustification.ComponentNotPresent, + "component_not_configured" => VexJustification.ComponentNotConfigured, + "vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent, + "vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath, + "vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary, + "inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist, + "protected_by_mitigating_control" => VexJustification.ProtectedByMitigatingControl, + "code_not_present" => VexJustification.CodeNotPresent, + "code_not_reachable" => VexJustification.CodeNotReachable, + "requires_configuration" => VexJustification.RequiresConfiguration, + "requires_dependency" => VexJustification.RequiresDependency, + "requires_environment" => VexJustification.RequiresEnvironment, + "protected_by_compensating_control" => VexJustification.ProtectedByCompensatingControl, + "protected_at_perimeter" => VexJustification.ProtectedAtPerimeter, + "protected_at_runtime" => VexJustification.ProtectedAtRuntime, + _ => throw new InvalidOperationException($"Unsupported stored VEX justification '{value}'."), + }; + } + + private sealed record StoredClaim( + string ClaimHash, + string VulnerabilityId, + string ProductKey, + string ProviderId, + string Status, + string? Justification, + string? Detail, + DateTimeOffset FirstSeen, + DateTimeOffset LastSeen, + string DocumentDigest, + string ProductJson, + string DocumentJson, + string? ConfidenceJson, + string? SignalsJson, + string AdditionalMetadataJson); +} diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj index 8e1d2d427..4960de72c 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/TASKS.md b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/TASKS.md index cf49266f7..7cd502a1d 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/TASKS.md @@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0323-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). | | VEX-LINK-STORE-0001 | DONE | SPRINT_20260113_003_001 - Evidence link migration added. | | QA-DEVOPS-VERIFY-002-F | DONE | 2026-02-11: Fixed Rekor-linkage schema mismatch in `PostgresVexObservationStore` by aligning to `vex.observations` and ensuring Rekor linkage columns/indexes. | +| NOMOCK-012 | DONE | 2026-04-14: Added PostgreSQL `IVexClaimStore`, wired startup migrations for `vex`, removed live demo-seed SQL, and restricted embedded Excititor migrations to active top-level files only. | diff --git a/src/Concelier/__Tests/StellaOps.Excititor.Persistence.Tests/ExcititorMigrationTests.cs b/src/Concelier/__Tests/StellaOps.Excititor.Persistence.Tests/ExcititorMigrationTests.cs index 44a13e1ec..a513e9628 100644 --- a/src/Concelier/__Tests/StellaOps.Excititor.Persistence.Tests/ExcititorMigrationTests.cs +++ b/src/Concelier/__Tests/StellaOps.Excititor.Persistence.Tests/ExcititorMigrationTests.cs @@ -235,6 +235,36 @@ public sealed class ExcititorMigrationTests : IAsyncLifetime tableList.Should().NotBeNull(); } + [Fact] + public async Task ApplyMigrations_FromScratch_DoesNotSeedDemoRows_AndCreatesClaimsTable() + { + var connectionString = _container.GetConnectionString(); + await ApplyAllMigrationsAsync(connectionString); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + + var claimsTableExists = await connection.ExecuteScalarAsync( + @"SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'vex' + AND table_name = 'claims'"); + + var demoLinksets = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM vex.linksets WHERE tenant = 'demo-prod'"); + + var demoRawDocuments = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM vex.vex_raw_documents WHERE tenant = 'demo-prod'"); + + var demoSourceVectors = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM excititor.source_trust_vectors WHERE tenant = 'demo-prod'"); + + claimsTableExists.Should().Be(1, "the persisted claim store must own a real PostgreSQL table"); + demoLinksets.Should().Be(0, "startup migrations must not seed demo linksets into the live schema"); + demoRawDocuments.Should().Be(0, "startup migrations must not seed demo raw VEX documents"); + demoSourceVectors.Should().Be(0, "startup migrations must not seed demo trust vectors"); + } + private async Task ApplyAllMigrationsAsync(string connectionString) { await using var connection = new NpgsqlConnection(connectionString); @@ -298,4 +328,3 @@ public sealed class ExcititorMigrationTests : IAsyncLifetime } - diff --git a/src/Concelier/__Tests/StellaOps.Excititor.Persistence.Tests/PostgresVexClaimStoreTests.cs b/src/Concelier/__Tests/StellaOps.Excititor.Persistence.Tests/PostgresVexClaimStoreTests.cs new file mode 100644 index 000000000..62653510b --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Excititor.Persistence.Tests/PostgresVexClaimStoreTests.cs @@ -0,0 +1,191 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Excititor.Core; +using StellaOps.Excititor.Core.Storage; +using StellaOps.Excititor.Persistence.Postgres; +using StellaOps.Excititor.Persistence.Postgres.Repositories; +using StellaOps.Infrastructure.Postgres.Options; +using StellaOps.TestKit; +using System.Collections.Immutable; +using Xunit; + +namespace StellaOps.Excititor.Persistence.Tests; + +[Collection(ExcititorPostgresCollection.Name)] +public sealed class PostgresVexClaimStoreTests : IAsyncLifetime +{ + private readonly ExcititorPostgresFixture _fixture; + private readonly ExcititorDataSource _dataSource; + private readonly PostgresVexClaimStore _store; + + public PostgresVexClaimStoreTests(ExcititorPostgresFixture fixture) + { + _fixture = fixture; + + var postgresOptions = Options.Create(new PostgresOptions + { + ConnectionString = fixture.ConnectionString, + SchemaName = fixture.SchemaName, + AutoMigrate = false, + }); + + var storageOptions = Options.Create(new VexStorageOptions + { + DefaultTenant = "default", + }); + + _dataSource = new ExcititorDataSource(postgresOptions, NullLogger.Instance); + _store = new PostgresVexClaimStore( + _dataSource, + storageOptions, + NullLogger.Instance); + } + + public async ValueTask InitializeAsync() + { + await _fixture.Fixture.RunMigrationsFromAssemblyAsync( + typeof(ExcititorDataSource).Assembly, + moduleName: "Excititor", + resourcePrefix: "Migrations", + cancellationToken: CancellationToken.None); + + await _fixture.TruncateAllTablesAsync(); + } + + public ValueTask DisposeAsync() => _dataSource.DisposeAsync(); + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task AppendAndFindAsync_RoundTripsClaim() + { + var now = DateTimeOffset.UtcNow; + var claim = CreateClaim( + vulnerabilityId: "CVE-2026-4444", + productKey: "pkg:oci/demo@1.0.0", + providerId: "vendor-a", + firstSeen: now.AddHours(-4), + lastSeen: now.AddHours(-1), + detail: "Patched in vendor build."); + + await _store.AppendAsync([claim], now, CancellationToken.None); + + var found = await _store.FindAsync( + "CVE-2026-4444", + "pkg:oci/demo@1.0.0", + since: null, + CancellationToken.None); + + found.Should().ContainSingle(); + var roundTrip = found.Single(); + roundTrip.ProviderId.Should().Be("vendor-a"); + roundTrip.Status.Should().Be(VexClaimStatus.NotAffected); + roundTrip.Product.Key.Should().Be("pkg:oci/demo@1.0.0"); + roundTrip.Document.Digest.Should().Be("sha256:vendor-a-CVE-2026-4444"); + roundTrip.Confidence!.Level.Should().Be("high"); + roundTrip.Signals!.Kev.Should().BeTrue(); + roundTrip.AdditionalMetadata["json_pointer"].Should().Be("/statements/0"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task AppendAsync_DeduplicatesIdenticalClaim() + { + var now = DateTimeOffset.UtcNow; + var claim = CreateClaim( + vulnerabilityId: "CVE-2026-5555", + productKey: "pkg:oci/demo@2.0.0", + providerId: "vendor-b", + firstSeen: now.AddHours(-2), + lastSeen: now.AddMinutes(-30), + detail: "No vulnerable code present."); + + await _store.AppendAsync([claim], now, CancellationToken.None); + await _store.AppendAsync([claim], now.AddMinutes(5), CancellationToken.None); + + var found = await _store.FindAsync( + "CVE-2026-5555", + "pkg:oci/demo@2.0.0", + since: null, + CancellationToken.None); + + found.Should().ContainSingle(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task FindByVulnerabilityAsync_OrdersNewestClaimsFirst() + { + var baseline = DateTimeOffset.UtcNow; + var newest = CreateClaim( + vulnerabilityId: "CVE-2026-6666", + productKey: "pkg:oci/api@3.0.0", + providerId: "provider-z", + firstSeen: baseline.AddHours(-3), + lastSeen: baseline.AddMinutes(-5), + detail: "Newest claim."); + var older = CreateClaim( + vulnerabilityId: "CVE-2026-6666", + productKey: "pkg:oci/web@3.0.0", + providerId: "provider-a", + firstSeen: baseline.AddHours(-5), + lastSeen: baseline.AddHours(-1), + detail: "Older claim."); + + await _store.AppendAsync([older, newest], baseline, CancellationToken.None); + + var found = await _store.FindByVulnerabilityAsync("CVE-2026-6666", 10, CancellationToken.None); + + found.Select(claim => claim.ProviderId).Should().ContainInOrder("provider-z", "provider-a"); + } + + private static VexClaim CreateClaim( + string vulnerabilityId, + string productKey, + string providerId, + DateTimeOffset firstSeen, + DateTimeOffset lastSeen, + string detail) + { + return new VexClaim( + vulnerabilityId, + providerId, + new VexProduct( + productKey, + "Demo Service", + version: "1.0.0", + purl: productKey, + componentIdentifiers: ["component-a", "component-b"]), + VexClaimStatus.NotAffected, + new VexClaimDocument( + VexDocumentFormat.OpenVex, + $"sha256:{providerId}-{vulnerabilityId}", + new Uri($"https://{providerId}.example.test/vex/{vulnerabilityId}.json"), + revision: "r1", + signature: new VexSignatureMetadata( + type: "cosign", + subject: "service-account", + issuer: "https://issuer.example.test", + keyId: "key-1", + verifiedAt: lastSeen, + transparencyLogReference: "rekor://entry/1", + trust: new VexSignatureTrustMetadata( + effectiveWeight: 0.9m, + tenantId: "default", + issuerId: "issuer-1", + tenantOverrideApplied: false, + retrievedAtUtc: lastSeen))), + firstSeen, + lastSeen, + justification: VexJustification.VulnerableCodeNotPresent, + detail: detail, + confidence: new VexConfidence("high", 0.98, "policy"), + signals: new VexSignalSnapshot( + new VexSeveritySignal("cvss", 8.9, "high", "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"), + kev: true, + epss: 0.42), + additionalMetadata: ImmutableDictionary.Empty + .Add("json_pointer", "/statements/0") + .Add("source", providerId)); + } +} diff --git a/src/VexHub/StellaOps.VexHub.WebService/Extensions/VexHubEndpointExtensions.cs b/src/VexHub/StellaOps.VexHub.WebService/Extensions/VexHubEndpointExtensions.cs index 1f5786667..647c51d14 100644 --- a/src/VexHub/StellaOps.VexHub.WebService/Extensions/VexHubEndpointExtensions.cs +++ b/src/VexHub/StellaOps.VexHub.WebService/Extensions/VexHubEndpointExtensions.cs @@ -80,6 +80,18 @@ public static class VexHubEndpointExtensions .WithDescription("Get VEX hub index manifest for tool integration") .Produces(StatusCodes.Status200OK); + var adminGroup = app.MapGroup("/api/v1/vex") + .WithTags("VEX") + .RequireAuthorization(VexHubPolicies.Admin) + .RequireTenant(); + + adminGroup.MapPost("/conflicts/resolve", ResolveConflict) + .WithName("ResolveVexConflict") + .WithDescription("Resolves open VEX conflicts for the selected vulnerability-product pair using the chosen authoritative statement.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + return app; } @@ -177,6 +189,7 @@ public static class VexHubEndpointExtensions SourceId = sourceId, VulnerabilityId = vulnerabilityId, ProductKey = productKey, + Status = TryParseStatus(status), IsFlagged = isFlagged }; @@ -192,6 +205,61 @@ public static class VexHubEndpointExtensions }); } + private static async Task ResolveConflict( + [FromBody] ResolveVexConflictRequest request, + [FromServices] IVexConflictRepository conflictRepository, + [FromServices] IVexStatementRepository statementRepository, + CancellationToken cancellationToken) + { + if (!Guid.TryParse(request.SelectedStatementId, out var selectedStatementId)) + { + return Results.BadRequest(new { Message = "selectedStatementId must be a GUID." }); + } + + var statement = await statementRepository.GetByIdAsync(selectedStatementId, cancellationToken); + if (statement is null) + { + return Results.NotFound(new { Message = $"VEX statement {request.SelectedStatementId} not found." }); + } + + if (!MatchesVulnerability(statement, request.CveId)) + { + return Results.BadRequest(new { Message = "Selected statement does not match the requested CVE." }); + } + + var openConflicts = (await conflictRepository.GetByVulnerabilityProductAsync( + request.CveId, + statement.ProductKey, + cancellationToken)) + .Where(conflict => conflict.ResolutionStatus == ConflictResolutionStatus.Open) + .ToList(); + + if (openConflicts.Count == 0) + { + return Results.NotFound(new { Message = $"No open conflicts found for {request.CveId} and product {statement.ProductKey}." }); + } + + var resolutionStatus = string.Equals(request.ResolutionType, "defer", StringComparison.OrdinalIgnoreCase) + ? ConflictResolutionStatus.Suppressed + : ConflictResolutionStatus.ManuallyResolved; + var resolutionMethod = string.IsNullOrWhiteSpace(request.Notes) + ? request.ResolutionType.Trim() + : $"{request.ResolutionType.Trim()}: {request.Notes.Trim()}"; + Guid? winningStatementId = resolutionStatus == ConflictResolutionStatus.Suppressed ? null : selectedStatementId; + + foreach (var conflict in openConflicts) + { + await conflictRepository.ResolveAsync( + conflict.Id, + resolutionStatus, + resolutionMethod, + winningStatementId, + cancellationToken); + } + + return Results.NoContent(); + } + private static async Task GetStats( [FromServices] IVexSourceRepository sourceRepository, [FromServices] IVexStatementRepository repository, @@ -316,6 +384,25 @@ public static class VexHubEndpointExtensions _ => status.ToString().ToLowerInvariant() }; + private static VexStatus? TryParseStatus(string? status) => status?.Trim().ToLowerInvariant() switch + { + "affected" => VexStatus.Affected, + "fixed" => VexStatus.Fixed, + "not_affected" or "notaffected" => VexStatus.NotAffected, + "under_investigation" or "underinvestigation" => VexStatus.UnderInvestigation, + _ => null + }; + + private static bool MatchesVulnerability(AggregatedVexStatement statement, string cveId) + { + if (string.Equals(statement.VulnerabilityId, cveId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return statement.VulnerabilityAliases?.Any(alias => string.Equals(alias, cveId, StringComparison.OrdinalIgnoreCase)) == true; + } + private static IResult GetIndex([FromServices] TimeProvider timeProvider) { return Results.Ok(new VexIndexManifest @@ -336,8 +423,11 @@ public static class VexHubEndpointExtensions private static async Task ExportOpenVex( IVexExportService exportService, + ILoggerFactory loggerFactory, CancellationToken cancellationToken) { + var logger = loggerFactory.CreateLogger("StellaOps.VexHub.WebService.Export"); + try { await using var stream = await exportService.ExportToOpenVexAsync(null, cancellationToken); @@ -347,7 +437,7 @@ public static class VexHubEndpointExtensions var node = JsonNode.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json) as JsonObject ?? new JsonObject(); if (!node.ContainsKey("@context") && node.TryGetPropertyValue("context", out var contextNode)) { - node["@context"] = contextNode; + node["@context"] = contextNode?.DeepClone(); node.Remove("context"); } @@ -361,21 +451,13 @@ public static class VexHubEndpointExtensions return Results.Text(normalized, "application/vnd.openvex+json", Encoding.UTF8); } - catch + catch (Exception ex) { - var fallback = new JsonObject - { - ["@context"] = "https://openvex.dev/ns/v0.2.0", - ["statements"] = new JsonArray() - }; - - var normalized = fallback.ToJsonString(new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }); - - return Results.Text(normalized, "application/vnd.openvex+json", Encoding.UTF8); + logger.LogError(ex, "Failed to export VEX statements."); + return Results.Problem( + title: "VEX export failed", + detail: "The VEX export could not be generated from the current backend state.", + statusCode: StatusCodes.Status500InternalServerError); } } } diff --git a/src/VexHub/StellaOps.VexHub.WebService/Middleware/ApiKeyAuthenticationHandler.cs b/src/VexHub/StellaOps.VexHub.WebService/Middleware/ApiKeyAuthenticationHandler.cs index b1daa4da1..1d2120387 100644 --- a/src/VexHub/StellaOps.VexHub.WebService/Middleware/ApiKeyAuthenticationHandler.cs +++ b/src/VexHub/StellaOps.VexHub.WebService/Middleware/ApiKeyAuthenticationHandler.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; +using StellaOps.Auth.Abstractions; using System.Security.Claims; using System.Text.Encodings.Web; @@ -66,7 +67,11 @@ public sealed class ApiKeyAuthenticationHandler : AuthenticationHandler StellaOpsScopes.VexHubRead, + "vexhub.admin" => StellaOpsScopes.VexHubAdmin, + _ => normalized + }; + } } /// diff --git a/src/VexHub/StellaOps.VexHub.WebService/Models/VexApiModels.cs b/src/VexHub/StellaOps.VexHub.WebService/Models/VexApiModels.cs index f137c652b..3db431979 100644 --- a/src/VexHub/StellaOps.VexHub.WebService/Models/VexApiModels.cs +++ b/src/VexHub/StellaOps.VexHub.WebService/Models/VexApiModels.cs @@ -78,3 +78,14 @@ public sealed class VexIndexEndpoints public required string Stats { get; init; } public required string Export { get; init; } } + +/// +/// Request to resolve one or more open VEX conflicts for a vulnerability-product pair. +/// +public sealed class ResolveVexConflictRequest +{ + public required string CveId { get; init; } + public required string SelectedStatementId { get; init; } + public required string ResolutionType { get; init; } + public string? Notes { get; init; } +} diff --git a/src/VexHub/StellaOps.VexHub.WebService/Program.cs b/src/VexHub/StellaOps.VexHub.WebService/Program.cs index 04e5c669d..2d745ade0 100644 --- a/src/VexHub/StellaOps.VexHub.WebService/Program.cs +++ b/src/VexHub/StellaOps.VexHub.WebService/Program.cs @@ -1,6 +1,7 @@ using Serilog; using StellaOps.Localization; +using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Router.AspNet; @@ -24,10 +25,11 @@ builder.Host.UseSerilog(); builder.Services.AddVexHubCore(builder.Configuration); builder.Services.AddVexHubPersistence(builder.Configuration); builder.Services.AddVexHubWebService(builder.Configuration); +builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration); -// Add authentication -builder.Services.AddAuthentication("ApiKey") - .AddScheme("ApiKey", options => +// Support first-party StellaOps bearer tokens and explicit API keys for external tools. +builder.Services.AddAuthentication() + .AddScheme(VexHubPolicies.ApiKeyScheme, options => { options.AllowAnonymous = true; // Allow anonymous for public read endpoints // API keys can be configured via configuration @@ -49,10 +51,7 @@ builder.Services.AddAuthentication("ApiKey") builder.Services.AddStellaOpsTenantServices(); builder.Services.AddAuthorization(options => { - // VexHub uses API-key authentication; policies require an authenticated API key holder. - // Scope enforcement is delegated to the API key configuration (per-key scope list). - options.AddPolicy(VexHubPolicies.Read, policy => policy.RequireAuthenticatedUser()); - options.AddPolicy(VexHubPolicies.Admin, policy => policy.RequireAuthenticatedUser()); + options.AddVexHubPolicies(); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); diff --git a/src/VexHub/StellaOps.VexHub.WebService/Security/VexHubPolicies.cs b/src/VexHub/StellaOps.VexHub.WebService/Security/VexHubPolicies.cs index ae9d0c62f..6ed7ee117 100644 --- a/src/VexHub/StellaOps.VexHub.WebService/Security/VexHubPolicies.cs +++ b/src/VexHub/StellaOps.VexHub.WebService/Security/VexHubPolicies.cs @@ -1,17 +1,44 @@ // Copyright (c) StellaOps. Licensed under the BUSL-1.1. +using Microsoft.AspNetCore.Authorization; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration; + namespace StellaOps.VexHub.WebService.Security; /// /// Named authorization policy constants for the VexHub service. -/// VexHub uses API-key authentication. All VEX query endpoints require a valid, -/// authenticated API key. Scope enforcement is delegated to the API key configuration. +/// First-party UI and service callers use StellaOps bearer tokens, while external tool +/// integrations can continue using explicit API keys. /// internal static class VexHubPolicies { - /// Policy for querying and reading VEX statements. Requires an authenticated API key. - public const string Read = "VexHub.Read"; + public const string ApiKeyScheme = "ApiKey"; - /// Policy for administrative operations (ingestion, source management). Requires an authenticated API key with admin scope. - public const string Admin = "VexHub.Admin"; + /// Policy for querying and reading VEX statements. + public const string Read = StellaOpsScopes.VexHubRead; + + /// Policy for administrative operations (ingestion, source management). + public const string Admin = StellaOpsScopes.VexHubAdmin; + + public static void AddVexHubPolicies(this AuthorizationOptions options) + { + options.AddPolicy(Read, policy => + { + policy.AddAuthenticationSchemes( + StellaOpsAuthenticationDefaults.AuthenticationScheme, + ApiKeyScheme); + policy.RequireAuthenticatedUser(); + policy.Requirements.Add(new StellaOpsScopeRequirement([Read])); + }); + + options.AddPolicy(Admin, policy => + { + policy.AddAuthenticationSchemes( + StellaOpsAuthenticationDefaults.AuthenticationScheme, + ApiKeyScheme); + policy.RequireAuthenticatedUser(); + policy.Requirements.Add(new StellaOpsScopeRequirement([Admin])); + }); + } } diff --git a/src/VexHub/StellaOps.VexHub.WebService/TASKS.md b/src/VexHub/StellaOps.VexHub.WebService/TASKS.md index 3419a13c4..2257c469d 100644 --- a/src/VexHub/StellaOps.VexHub.WebService/TASKS.md +++ b/src/VexHub/StellaOps.VexHub.WebService/TASKS.md @@ -4,5 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | Task ID | Status | Notes | | --- | --- | --- | +| NOMOCK-010 | DONE | Canonicalized live VexHub auth to `vexhub:read` / `vexhub:admin`, normalized legacy API-key scope values, and removed the fake export-success fallback. Source of truth: `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`. | +| NOMOCK-011 | DONE | Aligned the live VEX console with the real `/api/v1/vex/search`, `/api/v1/vex/statement/{id}`, `POST /api/v1/vexlens/consensus`, and `/api/v1/vex/conflicts/resolve` contract; added focused frontend/runtime verification. Source of truth: `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`. | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/VexHub/StellaOps.VexHub.WebService/StellaOps.VexHub.WebService.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/Integration/VexExportCompatibilityTests.cs b/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/Integration/VexExportCompatibilityTests.cs index 930802f14..ca328d4ea 100644 --- a/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/Integration/VexExportCompatibilityTests.cs +++ b/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/Integration/VexExportCompatibilityTests.cs @@ -1,11 +1,23 @@ using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; using System.Text.Json; +using System.Text.Encodings.Web; using System.Collections.Concurrent; using FluentAssertions; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Auth.Abstractions; +using StellaOps.VexHub.WebService.Middleware; using StellaOps.VexHub.Core; +using StellaOps.VexHub.Core.Export; using StellaOps.VexHub.Core.Models; using StellaOps.VexLens.Models; using Xunit; @@ -19,34 +31,47 @@ namespace StellaOps.VexHub.WebService.Tests.Integration; public sealed class VexExportCompatibilityTests : IClassFixture> { private const string TestApiKey = "integration-test-key"; + private const string ApiKeyScheme = "ApiKey"; private static readonly DateTimeOffset FixedNow = DateTimeOffset.Parse("2026-03-13T12:00:00Z"); + private readonly WebApplicationFactory _factory; private readonly HttpClient _client; public VexExportCompatibilityTests(WebApplicationFactory factory) { - _client = factory.WithWebHostBuilder(builder => + _factory = factory; + _client = CreateClient(); + } + + [Fact] + public async Task ExportEndpoint_ReturnsProblem_WhenExportServiceFails() + { + using var failureClient = CreateClient(services => { - builder.ConfigureAppConfiguration((_, config) => - { - config.AddInMemoryCollection(new Dictionary - { - [$"VexHub:ApiKeys:{TestApiKey}:KeyId"] = "integration-test", - [$"VexHub:ApiKeys:{TestApiKey}:ClientId"] = "integration-suite", - [$"VexHub:ApiKeys:{TestApiKey}:ClientName"] = "Integration Suite", - [$"VexHub:ApiKeys:{TestApiKey}:Scopes:0"] = "VexHub.Read", - [$"VexHub:ApiKeys:{TestApiKey}:Scopes:1"] = "VexHub.Admin", - }); - }); - builder.ConfigureServices(services => - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - }); - }).CreateClient(); - _client.DefaultRequestHeaders.Add("X-Api-Key", TestApiKey); - _client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant"); + services.AddSingleton(); + }); + + var response = await failureClient.GetAsync("/api/v1/vex/export"); + + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/problem+json"); + + var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + payload.RootElement.GetProperty("title").GetString().Should().Be("VEX export failed"); + payload.RootElement.GetProperty("detail").GetString().Should().Be("The VEX export could not be generated from the current backend state."); + } + + [Fact] + public async Task ExportEndpoint_AcceptsStellaOpsBearerScope() + { + using var bearerClient = CreateBearerClient(); + + var response = await bearerClient.GetAsync("/api/v1/vex/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/vnd.openvex+json"); + + var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + payload.RootElement.TryGetProperty("@context", out _).Should().BeTrue(); } [Fact] @@ -98,6 +123,74 @@ public sealed class VexExportCompatibilityTests : IClassFixture? configureServices = null) + { + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + [$"VexHub:ApiKeys:{TestApiKey}:KeyId"] = "integration-test", + [$"VexHub:ApiKeys:{TestApiKey}:ClientId"] = "integration-suite", + [$"VexHub:ApiKeys:{TestApiKey}:ClientName"] = "Integration Suite", + [$"VexHub:ApiKeys:{TestApiKey}:Scopes:0"] = "VexHub.Read", + [$"VexHub:ApiKeys:{TestApiKey}:Scopes:1"] = "VexHub.Admin", + ["Authority:ResourceServer:Authority"] = "https://authority.stella-ops.local/", + ["Authority:ResourceServer:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration", + ["Authority:ResourceServer:RequireHttpsMetadata"] = "false", + ["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32", + ["Authority:ResourceServer:BypassNetworks:1"] = "::1/128", + }); + }); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + configureServices?.Invoke(services); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Add("X-Api-Key", TestApiKey); + client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant"); + return client; + } + + private HttpClient CreateBearerClient() + { + var client = CreateClient(services => + { + services.RemoveAll>(); + services.RemoveAll>(); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme; + }) + .AddScheme( + StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }) + .AddScheme( + ApiKeyScheme, + options => options.AllowAnonymous = true); + }); + + client.DefaultRequestHeaders.Remove("X-Api-Key"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + StellaOpsAuthenticationDefaults.AuthenticationScheme, + "integration-token"); + return client; + } + [Fact] public async Task ExportEndpoint_IncludesRateLimitHeaders() { @@ -150,6 +243,97 @@ public sealed class VexExportCompatibilityTests : IClassFixture + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(statementRepository); + services.AddSingleton(conflictRepository); + }); + + var response = await client.PostAsJsonAsync("/api/v1/vex/conflicts/resolve", new + { + cveId, + selectedStatementId, + resolutionType = "prefer", + notes = "Vendor source is authoritative for this product.", + }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var resolvedConflict = await conflictRepository.GetByIdAsync(conflictId); + resolvedConflict.Should().NotBeNull(); + resolvedConflict!.ResolutionStatus.Should().Be(ConflictResolutionStatus.ManuallyResolved); + resolvedConflict.WinningStatementId.Should().Be(selectedStatementId); + resolvedConflict.ResolutionMethod.Should().Contain("prefer"); + } + [Fact] public async Task SourceEndpoint_ReturnsValidResponse() { @@ -187,6 +371,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture DeleteAsync(string sourceId, CancellationToken cancellationToken = default) { return Task.FromResult(_sources.TryRemove(sourceId, out _)); @@ -741,4 +946,74 @@ public sealed class VexExportCompatibilityTests : IClassFixture ExportToOpenVexAsync(VexStatementFilter? filter = null, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Simulated export failure"); + + public Task ExportCveToOpenVexAsync(string cveId, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Simulated export failure"); + + public Task ExportPackageToOpenVexAsync(string purl, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Simulated export failure"); + + public Task GetStatisticsAsync(CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Simulated export failure"); + } + + private sealed class StellaOpsBearerTestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) + { + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim(StellaOpsClaimTypes.Subject, "test-user"), + new Claim(StellaOpsClaimTypes.Scope, StellaOpsScopes.VexHubRead), + new Claim(StellaOpsClaimTypes.Tenant, "test-tenant"), + new Claim(ClaimTypes.Name, "test-user"), + }; + + var identity = new ClaimsIdentity(claims, StellaOpsAuthenticationDefaults.AuthenticationType); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, StellaOpsAuthenticationDefaults.AuthenticationScheme); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } + + private sealed class InMemoryVexProvenanceRepository : IVexProvenanceRepository + { + private readonly ConcurrentDictionary _provenances = new(); + + public Task AddAsync(VexProvenance provenance, CancellationToken cancellationToken = default) + { + _provenances[provenance.StatementId] = provenance; + return Task.FromResult(provenance); + } + + public Task GetByStatementIdAsync(Guid statementId, CancellationToken cancellationToken = default) + { + _provenances.TryGetValue(statementId, out var provenance); + return Task.FromResult(provenance); + } + + public Task BulkAddAsync(IEnumerable provenances, CancellationToken cancellationToken = default) + { + var count = 0; + foreach (var provenance in provenances) + { + _provenances[provenance.StatementId] = provenance; + count++; + } + + return Task.FromResult(count); + } + + public Task DeleteByStatementIdAsync(Guid statementId, CancellationToken cancellationToken = default) + => Task.FromResult(_provenances.TryRemove(statementId, out _)); + } } diff --git a/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/TASKS.md b/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/TASKS.md index 167a124ff..905e7c621 100644 --- a/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/TASKS.md +++ b/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/TASKS.md @@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | Task ID | Status | Notes | | --- | --- | --- | +| NOMOCK-010 | DONE | Added bearer-auth/export compatibility coverage for the live VexHub runtime path, including truthful export-failure behavior and legacy API-key scope normalization. Source of truth: `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`. | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/VexHub/__Tests/StellaOps.VexHub.WebService.Tests/StellaOps.VexHub.WebService.Tests.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/VexLens/StellaOps.VexLens.Persistence/Migrations/002_noise_gating_state.sql b/src/VexLens/StellaOps.VexLens.Persistence/Migrations/002_noise_gating_state.sql new file mode 100644 index 000000000..a586b410c --- /dev/null +++ b/src/VexLens/StellaOps.VexLens.Persistence/Migrations/002_noise_gating_state.sql @@ -0,0 +1,60 @@ +-- VexLens Schema Migration 002: Noise-gating runtime state +-- Persists raw snapshots, gated snapshots, and aggregated statistics so the +-- live VexLens noise-gating API no longer depends on process-local memory. + +CREATE TABLE IF NOT EXISTS vexlens.noise_gate_raw_snapshots ( + snapshot_id TEXT NOT NULL, + tenant_scope TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + graph_json JSONB NOT NULL, + verdicts_json JSONB NOT NULL, + PRIMARY KEY (snapshot_id, tenant_scope) +); + +CREATE INDEX IF NOT EXISTS idx_noise_gate_raw_snapshots_created + ON vexlens.noise_gate_raw_snapshots (created_at DESC, snapshot_id ASC); + +CREATE INDEX IF NOT EXISTS idx_noise_gate_raw_snapshots_tenant_created + ON vexlens.noise_gate_raw_snapshots (tenant_scope, created_at DESC, snapshot_id ASC); + +CREATE TABLE IF NOT EXISTS vexlens.noise_gate_gated_snapshots ( + snapshot_id TEXT NOT NULL, + tenant_scope TEXT NOT NULL DEFAULT '', + digest TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + artifact_json JSONB NOT NULL, + edges_json JSONB NOT NULL, + verdicts_json JSONB NOT NULL, + damped_verdicts_json JSONB NOT NULL DEFAULT '[]'::jsonb, + statistics_json JSONB NOT NULL, + PRIMARY KEY (snapshot_id, tenant_scope) +); + +CREATE INDEX IF NOT EXISTS idx_noise_gate_gated_snapshots_created + ON vexlens.noise_gate_gated_snapshots (created_at DESC, snapshot_id ASC); + +CREATE INDEX IF NOT EXISTS idx_noise_gate_gated_snapshots_tenant_created + ON vexlens.noise_gate_gated_snapshots (tenant_scope, created_at DESC, snapshot_id ASC); + +CREATE TABLE IF NOT EXISTS vexlens.noise_gate_statistics ( + snapshot_id TEXT NOT NULL, + tenant_scope TEXT NOT NULL DEFAULT '', + recorded_at TIMESTAMPTZ NOT NULL, + statistics_json JSONB NOT NULL, + PRIMARY KEY (snapshot_id, tenant_scope) +); + +CREATE INDEX IF NOT EXISTS idx_noise_gate_statistics_recorded + ON vexlens.noise_gate_statistics (recorded_at DESC, snapshot_id ASC); + +CREATE INDEX IF NOT EXISTS idx_noise_gate_statistics_tenant_recorded + ON vexlens.noise_gate_statistics (tenant_scope, recorded_at DESC, snapshot_id ASC); + +COMMENT ON TABLE vexlens.noise_gate_raw_snapshots IS + 'Raw noise-gating snapshots stored by VexLens for later gating and delta computation.'; + +COMMENT ON TABLE vexlens.noise_gate_gated_snapshots IS + 'Persisted gated snapshots produced by the VexLens noise-gating pipeline.'; + +COMMENT ON TABLE vexlens.noise_gate_statistics IS + 'Latest aggregated per-snapshot noise-gating statistics used by the live statistics endpoint.'; diff --git a/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresGatingStatisticsStore.cs b/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresGatingStatisticsStore.cs new file mode 100644 index 000000000..e3b81bf69 --- /dev/null +++ b/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresGatingStatisticsStore.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using StellaOps.VexLens.NoiseGate; +using StellaOps.VexLens.Storage; + +namespace StellaOps.VexLens.Persistence.Postgres; + +/// +/// PostgreSQL-backed implementation of . +/// +public sealed class PostgresGatingStatisticsStore : IGatingStatisticsStore +{ + private const string UpsertStatisticsSql = + """ + INSERT INTO vexlens.noise_gate_statistics ( + snapshot_id, + tenant_scope, + recorded_at, + statistics_json + ) VALUES ( + @snapshot_id, + @tenant_scope, + @recorded_at, + @statistics_json + ) + ON CONFLICT (snapshot_id, tenant_scope) DO UPDATE SET + recorded_at = EXCLUDED.recorded_at, + statistics_json = EXCLUDED.statistics_json; + """; + + private const string SelectStatisticsSql = + """ + SELECT statistics_json::text + FROM vexlens.noise_gate_statistics + WHERE (@tenant_scope IS NULL OR tenant_scope = @tenant_scope) + AND (@from_date IS NULL OR recorded_at >= @from_date) + AND (@to_date IS NULL OR recorded_at <= @to_date) + ORDER BY recorded_at DESC, snapshot_id ASC; + """; + + private readonly NpgsqlDataSource _dataSource; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public PostgresGatingStatisticsStore( + NpgsqlDataSource dataSource, + ILogger logger, + TimeProvider timeProvider) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public async Task RecordAsync( + string snapshotId, + GatingStatistics statistics, + string? tenantId = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId); + ArgumentNullException.ThrowIfNull(statistics); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = new NpgsqlCommand(UpsertStatisticsSql, connection); + command.Parameters.AddWithValue("snapshot_id", snapshotId); + command.Parameters.AddWithValue("tenant_scope", NormalizeTenantScope(tenantId)); + command.Parameters.AddWithValue("recorded_at", _timeProvider.GetUtcNow()); + command.Parameters.Add(new NpgsqlParameter("statistics_json", NpgsqlDbType.Jsonb) + { + Value = PostgresNoiseGatingJson.Serialize(statistics) + }); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Recorded noise-gating statistics for snapshot {SnapshotId}", snapshotId); + } + + public async Task GetAggregatedAsync( + string? tenantId = null, + DateTimeOffset? fromDate = null, + DateTimeOffset? toDate = null, + CancellationToken cancellationToken = default) + { + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = new NpgsqlCommand(SelectStatisticsSql, connection); + command.Parameters.Add(CreateNullableTextParameter("tenant_scope", tenantId is null ? null : NormalizeTenantScope(tenantId))); + command.Parameters.Add(CreateNullableTimestampParameter("from_date", fromDate)); + command.Parameters.Add(CreateNullableTimestampParameter("to_date", toDate)); + + var statistics = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + statistics.Add(PostgresNoiseGatingJson.Deserialize(reader.GetString(0))); + } + + if (statistics.Count == 0) + { + return AggregatedGatingStatistics.Empty; + } + + var totalEdgesProcessed = statistics.Sum(item => item.OriginalEdgeCount); + var totalEdgesAfterDedup = statistics.Sum(item => item.DeduplicatedEdgeCount); + var totalVerdicts = statistics.Sum(item => item.TotalVerdictCount); + var totalSurfaced = statistics.Sum(item => item.SurfacedVerdictCount); + var totalDamped = statistics.Sum(item => item.DampedVerdictCount); + + return new AggregatedGatingStatistics + { + TotalSnapshots = statistics.Count, + TotalEdgesProcessed = totalEdgesProcessed, + TotalEdgesAfterDedup = totalEdgesAfterDedup, + AverageEdgeReductionPercent = totalEdgesProcessed > 0 + ? (1.0 - (double)totalEdgesAfterDedup / totalEdgesProcessed) * 100.0 + : 0.0, + TotalVerdicts = totalVerdicts, + TotalSurfaced = totalSurfaced, + TotalDamped = totalDamped, + AverageDampingPercent = totalVerdicts > 0 + ? (double)totalDamped / totalVerdicts * 100.0 + : 0.0 + }; + } + + private static string NormalizeTenantScope(string? tenantId) => + string.IsNullOrWhiteSpace(tenantId) ? string.Empty : tenantId.Trim(); + + private static NpgsqlParameter CreateNullableTextParameter(string parameterName, string? value) + { + return new NpgsqlParameter(parameterName, NpgsqlDbType.Text) + { + Value = value ?? (object)DBNull.Value + }; + } + + private static NpgsqlParameter CreateNullableTimestampParameter(string parameterName, DateTimeOffset? value) + { + return new NpgsqlParameter(parameterName, NpgsqlDbType.TimestampTz) + { + Value = value ?? (object)DBNull.Value + }; + } +} diff --git a/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresNoiseGatingJson.cs b/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresNoiseGatingJson.cs new file mode 100644 index 000000000..405a07c85 --- /dev/null +++ b/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresNoiseGatingJson.cs @@ -0,0 +1,48 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using StellaOps.ReachGraph.Schema; +using StellaOps.ReachGraph.Serialization; + +namespace StellaOps.VexLens.Persistence.Postgres; + +internal static class PostgresNoiseGatingJson +{ + private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions(); + private static readonly CanonicalReachGraphSerializer ReachGraphSerializer = new(); + + public static string SerializeGraph(ReachGraphMinimal graph) + { + ArgumentNullException.ThrowIfNull(graph); + return Encoding.UTF8.GetString(ReachGraphSerializer.SerializeMinimal(graph)); + } + + public static ReachGraphMinimal DeserializeGraph(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + return ReachGraphSerializer.Deserialize(json); + } + + public static string Serialize(T value) + { + ArgumentNullException.ThrowIfNull(value); + return JsonSerializer.Serialize(value, SerializerOptions); + } + + public static T Deserialize(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + return JsonSerializer.Deserialize(json, SerializerOptions) + ?? throw new JsonException($"Failed to deserialize {typeof(T).Name}."); + } + + private static JsonSerializerOptions CreateSerializerOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } +} diff --git a/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresSnapshotStore.cs b/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresSnapshotStore.cs new file mode 100644 index 000000000..4dea72775 --- /dev/null +++ b/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresSnapshotStore.cs @@ -0,0 +1,266 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using StellaOps.ReachGraph.Deduplication; +using StellaOps.ReachGraph.Schema; +using StellaOps.VexLens.NoiseGate; +using StellaOps.VexLens.Storage; + +namespace StellaOps.VexLens.Persistence.Postgres; + +/// +/// PostgreSQL-backed implementation of . +/// +public sealed class PostgresSnapshotStore : ISnapshotStore +{ + private const string UpsertRawSnapshotSql = + """ + INSERT INTO vexlens.noise_gate_raw_snapshots ( + snapshot_id, + tenant_scope, + created_at, + graph_json, + verdicts_json + ) VALUES ( + @snapshot_id, + @tenant_scope, + @created_at, + @graph_json, + @verdicts_json + ) + ON CONFLICT (snapshot_id, tenant_scope) DO UPDATE SET + created_at = EXCLUDED.created_at, + graph_json = EXCLUDED.graph_json, + verdicts_json = EXCLUDED.verdicts_json; + """; + + private const string UpsertGatedSnapshotSql = + """ + INSERT INTO vexlens.noise_gate_gated_snapshots ( + snapshot_id, + tenant_scope, + digest, + created_at, + artifact_json, + edges_json, + verdicts_json, + damped_verdicts_json, + statistics_json + ) VALUES ( + @snapshot_id, + @tenant_scope, + @digest, + @created_at, + @artifact_json, + @edges_json, + @verdicts_json, + @damped_verdicts_json, + @statistics_json + ) + ON CONFLICT (snapshot_id, tenant_scope) DO UPDATE SET + digest = EXCLUDED.digest, + created_at = EXCLUDED.created_at, + artifact_json = EXCLUDED.artifact_json, + edges_json = EXCLUDED.edges_json, + verdicts_json = EXCLUDED.verdicts_json, + damped_verdicts_json = EXCLUDED.damped_verdicts_json, + statistics_json = EXCLUDED.statistics_json; + """; + + private const string SelectRawSnapshotSql = + """ + SELECT created_at, graph_json::text, verdicts_json::text + FROM vexlens.noise_gate_raw_snapshots + WHERE snapshot_id = @snapshot_id AND tenant_scope = @tenant_scope; + """; + + private const string SelectGatedSnapshotSql = + """ + SELECT digest, + created_at, + artifact_json::text, + edges_json::text, + verdicts_json::text, + damped_verdicts_json::text, + statistics_json::text + FROM vexlens.noise_gate_gated_snapshots + WHERE snapshot_id = @snapshot_id AND tenant_scope = @tenant_scope; + """; + + private const string ListSnapshotsSql = + """ + SELECT snapshot_id + FROM vexlens.noise_gate_gated_snapshots + WHERE (@tenant_scope IS NULL OR tenant_scope = @tenant_scope) + ORDER BY created_at DESC, snapshot_id ASC + LIMIT @limit; + """; + + private readonly NpgsqlDataSource _dataSource; + private readonly ILogger _logger; + + public PostgresSnapshotStore( + NpgsqlDataSource dataSource, + ILogger logger) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetAsync( + string snapshotId, + string? tenantId = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = new NpgsqlCommand(SelectGatedSnapshotSql, connection); + command.Parameters.AddWithValue("snapshot_id", snapshotId); + command.Parameters.AddWithValue("tenant_scope", NormalizeTenantScope(tenantId)); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + return null; + } + + var digest = reader.GetString(0); + var createdAt = reader.GetFieldValue(1); + var artifact = PostgresNoiseGatingJson.Deserialize(reader.GetString(2)); + var edges = PostgresNoiseGatingJson.Deserialize(reader.GetString(3)); + var verdicts = PostgresNoiseGatingJson.Deserialize(reader.GetString(4)); + var dampedVerdicts = PostgresNoiseGatingJson.Deserialize(reader.GetString(5)); + var statistics = PostgresNoiseGatingJson.Deserialize(reader.GetString(6)); + + return new GatedGraphSnapshot + { + SnapshotId = snapshotId, + Digest = digest, + Artifact = artifact, + Edges = [.. edges], + Verdicts = [.. verdicts], + DampedVerdicts = [.. dampedVerdicts], + CreatedAt = createdAt, + Statistics = statistics + }; + } + + public async Task GetRawAsync( + string snapshotId, + string? tenantId = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = new NpgsqlCommand(SelectRawSnapshotSql, connection); + command.Parameters.AddWithValue("snapshot_id", snapshotId); + command.Parameters.AddWithValue("tenant_scope", NormalizeTenantScope(tenantId)); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + return null; + } + + var createdAt = reader.GetFieldValue(0); + var graph = PostgresNoiseGatingJson.DeserializeGraph(reader.GetString(1)); + var verdicts = PostgresNoiseGatingJson.Deserialize(reader.GetString(2)); + + return new RawGraphSnapshot + { + SnapshotId = snapshotId, + Graph = graph, + Verdicts = verdicts, + CreatedAt = createdAt + }; + } + + public async Task StoreAsync( + GatedGraphSnapshot snapshot, + string? tenantId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = new NpgsqlCommand(UpsertGatedSnapshotSql, connection); + command.Parameters.AddWithValue("snapshot_id", snapshot.SnapshotId); + command.Parameters.AddWithValue("tenant_scope", NormalizeTenantScope(tenantId)); + command.Parameters.AddWithValue("digest", snapshot.Digest); + command.Parameters.AddWithValue("created_at", snapshot.CreatedAt); + AddJsonb(command, "artifact_json", PostgresNoiseGatingJson.Serialize(snapshot.Artifact)); + AddJsonb(command, "edges_json", PostgresNoiseGatingJson.Serialize(snapshot.Edges.ToArray())); + AddJsonb(command, "verdicts_json", PostgresNoiseGatingJson.Serialize(snapshot.Verdicts.ToArray())); + AddJsonb(command, "damped_verdicts_json", PostgresNoiseGatingJson.Serialize(snapshot.DampedVerdicts.ToArray())); + AddJsonb(command, "statistics_json", PostgresNoiseGatingJson.Serialize(snapshot.Statistics)); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Stored gated noise-gating snapshot {SnapshotId}", snapshot.SnapshotId); + } + + public async Task StoreRawAsync( + RawGraphSnapshot snapshot, + string? tenantId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = new NpgsqlCommand(UpsertRawSnapshotSql, connection); + command.Parameters.AddWithValue("snapshot_id", snapshot.SnapshotId); + command.Parameters.AddWithValue("tenant_scope", NormalizeTenantScope(tenantId)); + command.Parameters.AddWithValue("created_at", snapshot.CreatedAt); + AddJsonb(command, "graph_json", PostgresNoiseGatingJson.SerializeGraph(snapshot.Graph)); + AddJsonb(command, "verdicts_json", PostgresNoiseGatingJson.Serialize(snapshot.Verdicts.ToArray())); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Stored raw noise-gating snapshot {SnapshotId}", snapshot.SnapshotId); + } + + public async Task> ListAsync( + string? tenantId = null, + int limit = 100, + CancellationToken cancellationToken = default) + { + if (limit <= 0) + { + return Array.Empty(); + } + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = new NpgsqlCommand(ListSnapshotsSql, connection); + command.Parameters.Add(CreateNullableTextParameter("tenant_scope", tenantId is null ? null : NormalizeTenantScope(tenantId))); + command.Parameters.AddWithValue("limit", limit); + + var snapshotIds = new List(capacity: limit); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + snapshotIds.Add(reader.GetString(0)); + } + + return snapshotIds; + } + + private static string NormalizeTenantScope(string? tenantId) => + string.IsNullOrWhiteSpace(tenantId) ? string.Empty : tenantId.Trim(); + + private static void AddJsonb(NpgsqlCommand command, string parameterName, string json) + { + command.Parameters.Add(new NpgsqlParameter(parameterName, NpgsqlDbType.Jsonb) + { + Value = json + }); + } + + private static NpgsqlParameter CreateNullableTextParameter(string parameterName, string? value) + { + return new NpgsqlParameter(parameterName, NpgsqlDbType.Text) + { + Value = value ?? (object)DBNull.Value + }; + } +} diff --git a/src/VexLens/StellaOps.VexLens.Persistence/TASKS.md b/src/VexLens/StellaOps.VexLens.Persistence/TASKS.md index b0b825e25..5d7c38646 100644 --- a/src/VexLens/StellaOps.VexLens.Persistence/TASKS.md +++ b/src/VexLens/StellaOps.VexLens.Persistence/TASKS.md @@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | --- | --- | --- | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/VexLens/StellaOps.VexLens.Persistence/StellaOps.VexLens.Persistence.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| NOMOCK-008 | DONE | SPRINT_20260410_001: replaced runtime in-memory noise-gating snapshot/statistics stores with PostgreSQL-backed storage and startup migrations on 2026-04-14. | diff --git a/src/VexLens/StellaOps.VexLens.WebService/Configuration/VexLensRuntimeDatabaseOptions.cs b/src/VexLens/StellaOps.VexLens.WebService/Configuration/VexLensRuntimeDatabaseOptions.cs new file mode 100644 index 000000000..097709bc4 --- /dev/null +++ b/src/VexLens/StellaOps.VexLens.WebService/Configuration/VexLensRuntimeDatabaseOptions.cs @@ -0,0 +1,21 @@ +namespace StellaOps.VexLens.WebService.Configuration; + +internal sealed record VexLensRuntimeDatabaseOptions(string ConnectionString) +{ + public static VexLensRuntimeDatabaseOptions FromConfiguration(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var connectionString = + configuration.GetConnectionString("Default") + ?? configuration.GetConnectionString("VexLens"); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "VexLens requires ConnectionStrings:Default (or ConnectionStrings:VexLens) to use the real PostgreSQL-backed runtime."); + } + + return new VexLensRuntimeDatabaseOptions(connectionString.Trim()); + } +} diff --git a/src/VexLens/StellaOps.VexLens.WebService/Extensions/ExportEndpointExtensions.cs b/src/VexLens/StellaOps.VexLens.WebService/Extensions/ExportEndpointExtensions.cs index 3adfa036c..c5595f8a5 100644 --- a/src/VexLens/StellaOps.VexLens.WebService/Extensions/ExportEndpointExtensions.cs +++ b/src/VexLens/StellaOps.VexLens.WebService/Extensions/ExportEndpointExtensions.cs @@ -21,7 +21,7 @@ namespace StellaOps.VexLens.WebService.Extensions; /// public static class ExportEndpointExtensions { - private const string TenantHeader = "X-StellaOps-Tenant"; + private const string TenantHeader = "X-Stella-Ops-Tenant"; /// /// Maps the VEX export endpoints. diff --git a/src/VexLens/StellaOps.VexLens.WebService/Extensions/VexLensEndpointExtensions.cs b/src/VexLens/StellaOps.VexLens.WebService/Extensions/VexLensEndpointExtensions.cs index 55ec284aa..60f5de3ee 100644 --- a/src/VexLens/StellaOps.VexLens.WebService/Extensions/VexLensEndpointExtensions.cs +++ b/src/VexLens/StellaOps.VexLens.WebService/Extensions/VexLensEndpointExtensions.cs @@ -11,7 +11,7 @@ namespace StellaOps.VexLens.WebService.Extensions; /// public static class VexLensEndpointExtensions { - private const string TenantHeader = "X-StellaOps-Tenant"; + private const string TenantHeader = "X-Stella-Ops-Tenant"; /// /// Maps all VexLens API endpoints. @@ -349,6 +349,7 @@ public static class VexLensEndpointExtensions [FromBody] GateSnapshotRequest request, [FromServices] INoiseGate noiseGate, [FromServices] ISnapshotStore snapshotStore, + [FromServices] IGatingStatisticsStore statsStore, HttpContext context, CancellationToken cancellationToken) { @@ -366,10 +367,13 @@ public static class VexLensEndpointExtensions { Graph = snapshot.Graph, SnapshotId = snapshotId, - Verdicts = snapshot.Verdicts + Verdicts = snapshot.Verdicts, + TenantId = tenantId }; var gatedSnapshot = await noiseGate.GateAsync(gateRequest, cancellationToken); + await snapshotStore.StoreAsync(gatedSnapshot, tenantId, cancellationToken); + await statsStore.RecordAsync(gatedSnapshot.SnapshotId, gatedSnapshot.Statistics, tenantId, cancellationToken); return Results.Ok(new GatedSnapshotResponse( SnapshotId: gatedSnapshot.SnapshotId, diff --git a/src/VexLens/StellaOps.VexLens.WebService/Program.cs b/src/VexLens/StellaOps.VexLens.WebService/Program.cs index c7370e8b4..ee237f73c 100644 --- a/src/VexLens/StellaOps.VexLens.WebService/Program.cs +++ b/src/VexLens/StellaOps.VexLens.WebService/Program.cs @@ -1,19 +1,34 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; +using StellaOps.Authority.Persistence.Postgres; +using StellaOps.Authority.Persistence.Postgres.Repositories; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Infrastructure.Postgres.Migrations; +using StellaOps.Infrastructure.Postgres.Options; +using StellaOps.IssuerDirectory.Core.Abstractions; +using StellaOps.IssuerDirectory.Core.Services; +using StellaOps.IssuerDirectory.Persistence.Postgres; +using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories; using StellaOps.VexLens.Api; using StellaOps.VexLens.Consensus; -using StellaOps.VexLens.Persistence; +using StellaOps.VexLens.Extensions; using StellaOps.VexLens.Persistence.Postgres; using StellaOps.VexLens.Storage; using StellaOps.VexLens.Trust; using StellaOps.VexLens.Verification; +using StellaOps.VexLens.WebService.Configuration; using StellaOps.VexLens.WebService.Extensions; +using StellaOps.VexLens.WebService.Services; +using StellaOps.VexHub.Core; +using StellaOps.VexHub.Persistence.Postgres; +using StellaOps.VexHub.Persistence.Postgres.Repositories; using System.Threading.RateLimiting; using StellaOps.Localization; @@ -48,16 +63,62 @@ builder.Services.AddOpenTelemetry() } }); -// Configure VexLens services -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); +var runtimeDatabaseOptions = VexLensRuntimeDatabaseOptions.FromConfiguration(builder.Configuration); -// Note: PostgreSQL persistence configuration requires VexLens persistence service registration -// For now, using in-memory stores configured above +builder.Services.AddSingleton>(_ => Options.Create(runtimeDatabaseOptions)); +builder.Services.TryAddSingleton(_ => TimeProvider.System); + +builder.Services.AddVexLens(options => +{ + options.Storage.Driver = "postgres"; + options.Storage.ConnectionString = runtimeDatabaseOptions.ConnectionString; +}); + +builder.Services.AddStartupMigrations( + schemaName: VexLensDataSource.DefaultSchemaName, + moduleName: "VexLens.Persistence", + migrationsAssembly: typeof(VexLensDataSource).Assembly, + connectionStringSelector: static options => options.ConnectionString); + +builder.Services.AddSingleton(sp => + new VexHubDataSource( + Options.Create(CreateSharedPostgresOptions( + runtimeDatabaseOptions.ConnectionString, + VexHubDataSource.DefaultSchemaName, + "stellaops-vexlens-vexhub")), + sp.GetRequiredService>())); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddSingleton(sp => + new IssuerDirectoryDataSource( + Options.Create(CreateSharedPostgresOptions( + runtimeDatabaseOptions.ConnectionString, + IssuerDirectoryDataSource.DefaultSchemaName, + "stellaops-vexlens-issuerdirectory")), + sp.GetRequiredService>())); +builder.Services.AddSingleton(sp => + new AuthorityDataSource( + Options.Create(CreateSharedPostgresOptions( + runtimeDatabaseOptions.ConnectionString, + AuthorityDataSource.DefaultSchemaName, + "stellaops-vexlens-authority")), + sp.GetRequiredService>())); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.RemoveAll(); +builder.Services.AddScoped(); +builder.Services.RemoveAll(); +builder.Services.RemoveAll(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Configure health checks builder.Services.AddHealthChecks(); @@ -154,18 +215,16 @@ finally Log.CloseAndFlush(); } -/// -/// Null implementation for development without VexHub integration. -/// -internal sealed class NullVexStatementProvider : IVexStatementProvider +static PostgresOptions CreateSharedPostgresOptions( + string connectionString, + string schemaName, + string applicationName) { - public Task> GetStatementsAsync( - string vulnerabilityId, - string productKey, - string? tenantId, - CancellationToken cancellationToken = default) + return new PostgresOptions { - return Task.FromResult>([]); - } + ConnectionString = connectionString, + SchemaName = schemaName, + ApplicationName = applicationName + }; } diff --git a/src/VexLens/StellaOps.VexLens.WebService/Services/AuthorityIssuerDirectoryAdapter.cs b/src/VexLens/StellaOps.VexLens.WebService/Services/AuthorityIssuerDirectoryAdapter.cs new file mode 100644 index 000000000..d215c017d --- /dev/null +++ b/src/VexLens/StellaOps.VexLens.WebService/Services/AuthorityIssuerDirectoryAdapter.cs @@ -0,0 +1,833 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using StellaOps.Authority.Persistence.Postgres.Repositories; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Determinism; +using StellaOps.IssuerDirectory.Core.Abstractions; +using StellaOps.IssuerDirectory.Core.Domain; +using StellaOps.IssuerDirectory.Core.Services; +using StellaOps.VexLens.Models; +using StellaOps.VexLens.Verification; +using AuthorityIssuerRecord = StellaOps.IssuerDirectory.Core.Domain.IssuerRecord; +using VerificationIssuerMetadata = StellaOps.VexLens.Verification.IssuerMetadata; +using VerificationIssuerRecord = StellaOps.VexLens.Verification.IssuerRecord; +using VerificationIssuerStatus = StellaOps.VexLens.Verification.IssuerStatus; +using VerificationKeyFingerprintRecord = StellaOps.VexLens.Verification.KeyFingerprintRecord; +using VerificationKeyFingerprintStatus = StellaOps.VexLens.Verification.KeyFingerprintStatus; + +namespace StellaOps.VexLens.WebService.Services; + +public sealed class AuthorityIssuerDirectoryAdapter : IIssuerDirectory +{ + private const string CategoryAttributeKey = "vexlens.category"; + private const string PublicIssuerIdAttributeKey = "vexlens.publicIssuerId"; + private const decimal RevokedWeightThreshold = -5m; + private const decimal SuspendedWeightThreshold = 0m; + private const decimal TrustedWeightThreshold = 1m; + private const decimal AuthoritativeWeightThreshold = 5m; + + private readonly IStellaOpsTenantAccessor _tenantAccessor; + private readonly IssuerDirectoryService _issuerService; + private readonly IssuerKeyService _keyService; + private readonly IssuerTrustService _trustService; + private readonly IIssuerKeyRepository _keyRepository; + private readonly ITenantRepository _tenantRepository; + private readonly IGuidProvider _guidProvider; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public AuthorityIssuerDirectoryAdapter( + IStellaOpsTenantAccessor tenantAccessor, + IssuerDirectoryService issuerService, + IssuerKeyService keyService, + IssuerTrustService trustService, + IIssuerKeyRepository keyRepository, + ITenantRepository tenantRepository, + IGuidProvider guidProvider, + TimeProvider timeProvider, + ILogger logger) + { + _tenantAccessor = tenantAccessor ?? throw new ArgumentNullException(nameof(tenantAccessor)); + _issuerService = issuerService ?? throw new ArgumentNullException(nameof(issuerService)); + _keyService = keyService ?? throw new ArgumentNullException(nameof(keyService)); + _trustService = trustService ?? throw new ArgumentNullException(nameof(trustService)); + _keyRepository = keyRepository ?? throw new ArgumentNullException(nameof(keyRepository)); + _tenantRepository = tenantRepository ?? throw new ArgumentNullException(nameof(tenantRepository)); + _guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetIssuerAsync( + string issuerId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); + + var scope = await ResolveTenantScopeAsync(cancellationToken).ConfigureAwait(false); + var issuer = await ResolveAuthorityIssuerAsync(issuerId, scope, includeGlobal: true, cancellationToken).ConfigureAwait(false); + if (issuer is null) + { + return null; + } + + return await MapIssuerAsync(scope, issuer, cancellationToken).ConfigureAwait(false); + } + + public async Task GetIssuerByKeyFingerprintAsync( + string fingerprint, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint); + + var normalizedFingerprint = fingerprint.Trim(); + var scope = await ResolveTenantScopeAsync(cancellationToken).ConfigureAwait(false); + var issuers = await ListAuthorityIssuersAsync(scope, includeGlobal: true, cancellationToken).ConfigureAwait(false); + + foreach (var issuer in issuers + .OrderBy(static item => item.Slug, StringComparer.OrdinalIgnoreCase) + .ThenBy(static item => item.Id, StringComparer.OrdinalIgnoreCase)) + { + var key = await _keyRepository.GetByFingerprintAsync( + issuer.TenantId, + issuer.Id, + normalizedFingerprint, + cancellationToken) + .ConfigureAwait(false); + + if (key is not null) + { + return await MapIssuerAsync(scope, issuer, cancellationToken).ConfigureAwait(false); + } + } + + return null; + } + + public async Task> ListIssuersAsync( + IssuerListOptions? options = null, + CancellationToken cancellationToken = default) + { + var scope = await ResolveTenantScopeAsync(cancellationToken).ConfigureAwait(false); + var issuers = await ListAuthorityIssuersAsync(scope, includeGlobal: true, cancellationToken).ConfigureAwait(false); + var mapped = new List(issuers.Count); + + foreach (var issuer in issuers) + { + mapped.Add(await MapIssuerAsync(scope, issuer, cancellationToken).ConfigureAwait(false)); + } + + IEnumerable query = mapped; + + if (options is not null) + { + if (options.Category.HasValue) + { + query = query.Where(issuer => issuer.Category == options.Category.Value); + } + + if (options.MinimumTrustTier.HasValue) + { + var minimumRank = GetTrustRank(options.MinimumTrustTier.Value); + query = query.Where(issuer => GetTrustRank(issuer.TrustTier) >= minimumRank); + } + + if (options.Status.HasValue) + { + query = query.Where(issuer => issuer.Status == options.Status.Value); + } + + if (!string.IsNullOrWhiteSpace(options.SearchTerm)) + { + var term = options.SearchTerm.Trim(); + query = query.Where(issuer => + issuer.Name.Contains(term, StringComparison.OrdinalIgnoreCase) || + issuer.IssuerId.Contains(term, StringComparison.OrdinalIgnoreCase)); + } + } + + query = query + .OrderBy(static issuer => issuer.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static issuer => issuer.IssuerId, StringComparer.OrdinalIgnoreCase); + + if (options?.Offset is { } offset and > 0) + { + query = query.Skip(offset); + } + + if (options?.Limit is { } limit and >= 0) + { + query = query.Take(limit); + } + + return query.ToArray(); + } + + public async Task RegisterIssuerAsync( + IssuerRegistration registration, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(registration); + + EnsureKeyMaterialPresent(registration.InitialKeys); + + var scope = await ResolveTenantScopeAsync(cancellationToken).ConfigureAwait(false); + var currentTenantId = RequireCurrentTenantId(scope); + var actor = ResolveActor(); + var metadata = BuildAuthorityMetadata(registration); + var contact = new IssuerContact( + email: registration.Metadata?.Email, + phone: null, + website: TryCreateUri(registration.Metadata?.Uri), + timezone: null); + + var existing = await ResolveAuthorityIssuerAsync( + registration.IssuerId, + scope, + includeGlobal: false, + cancellationToken) + .ConfigureAwait(false); + + var authorityIssuerId = existing?.Id ?? _guidProvider.NewGuid().ToString(); + var slug = CreateSlug(registration.IssuerId); + + if (existing is null) + { + await _issuerService.CreateAsync( + currentTenantId, + authorityIssuerId, + registration.Name, + slug, + registration.Metadata?.Description, + contact, + metadata, + endpoints: null, + tags: registration.Metadata?.Tags, + actor, + reason: "vexlens-issuer-registration", + cancellationToken) + .ConfigureAwait(false); + } + else + { + await _issuerService.UpdateAsync( + currentTenantId, + authorityIssuerId, + registration.Name, + registration.Metadata?.Description, + contact, + metadata, + endpoints: null, + tags: registration.Metadata?.Tags, + actor, + reason: "vexlens-issuer-registration", + cancellationToken) + .ConfigureAwait(false); + } + + await ApplyTrustTierAsync(currentTenantId, authorityIssuerId, registration.TrustTier, actor, cancellationToken) + .ConfigureAwait(false); + + if (registration.InitialKeys is not null) + { + foreach (var keyRegistration in registration.InitialKeys) + { + await AddKeyAsync( + currentTenantId, + authorityIssuerId, + keyRegistration, + actor, + cancellationToken) + .ConfigureAwait(false); + } + } + + return await GetIssuerAsync(registration.IssuerId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("Issuer registration did not persist."); + } + + public async Task RevokeIssuerAsync( + string issuerId, + string reason, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); + + var scope = await ResolveTenantScopeAsync(cancellationToken).ConfigureAwait(false); + var currentTenantId = RequireCurrentTenantId(scope); + var existing = await ResolveAuthorityIssuerAsync(issuerId, scope, includeGlobal: true, cancellationToken).ConfigureAwait(false); + if (existing is null) + { + return false; + } + + await _trustService.SetAsync( + currentTenantId, + existing.Id, + RevokedWeightThreshold, + string.IsNullOrWhiteSpace(reason) ? "revoked-via-vexlens" : reason.Trim(), + ResolveActor(), + cancellationToken) + .ConfigureAwait(false); + + return true; + } + + public async Task AddKeyFingerprintAsync( + string issuerId, + KeyFingerprintRegistration keyRegistration, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); + ArgumentNullException.ThrowIfNull(keyRegistration); + + if (keyRegistration.PublicKey is null || keyRegistration.PublicKey.Length == 0) + { + throw new InvalidOperationException( + "Authority-backed issuer key registration requires key material. Provide PublicKey and PublicKeyFormat."); + } + + var scope = await ResolveTenantScopeAsync(cancellationToken).ConfigureAwait(false); + var issuer = await ResolveAuthorityIssuerAsync(issuerId, scope, includeGlobal: true, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Issuer '{issuerId}' was not found in Authority."); + + await AddKeyAsync(issuer.TenantId, issuer.Id, keyRegistration, ResolveActor(), cancellationToken).ConfigureAwait(false); + + return await GetIssuerAsync(issuerId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("Issuer key registration did not persist."); + } + + public async Task RevokeKeyFingerprintAsync( + string issuerId, + string fingerprint, + string reason, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); + ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint); + + var scope = await ResolveTenantScopeAsync(cancellationToken).ConfigureAwait(false); + var issuer = await ResolveAuthorityIssuerAsync(issuerId, scope, includeGlobal: true, cancellationToken).ConfigureAwait(false); + if (issuer is null) + { + return false; + } + + var keys = await _keyService.ListAsync(issuer.TenantId, issuer.Id, includeGlobal: false, cancellationToken).ConfigureAwait(false); + var key = keys.FirstOrDefault(candidate => string.Equals(candidate.Fingerprint, fingerprint.Trim(), StringComparison.OrdinalIgnoreCase)); + if (key is null) + { + return false; + } + + await _keyService.RevokeAsync( + key.TenantId, + issuer.Id, + key.Id, + ResolveActor(), + string.IsNullOrWhiteSpace(reason) ? "revoked-via-vexlens" : reason.Trim(), + cancellationToken) + .ConfigureAwait(false); + + return true; + } + + public async Task ValidateTrustAsync( + string issuerId, + string? keyFingerprint, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); + + var scope = await ResolveTenantScopeAsync(cancellationToken).ConfigureAwait(false); + var issuer = await ResolveAuthorityIssuerAsync(issuerId, scope, includeGlobal: true, cancellationToken).ConfigureAwait(false); + if (issuer is null) + { + return new IssuerTrustValidation( + IsTrusted: false, + EffectiveTrustTier: TrustTier.Unknown, + IssuerStatus: IssuerTrustStatus.NotRegistered, + KeyStatus: null, + Warnings: ["Issuer is not registered in Authority IssuerDirectory."]); + } + + var trustView = await GetTrustViewAsync(scope, issuer, cancellationToken).ConfigureAwait(false); + var issuerStatus = MapTrustStatus(trustView.EffectiveWeight); + var effectiveTier = MapTrustTier(trustView.EffectiveWeight); + var warnings = new List(); + + KeyTrustStatus? keyStatus = null; + if (!string.IsNullOrWhiteSpace(keyFingerprint)) + { + var keys = await _keyService.ListAsync(issuer.TenantId, issuer.Id, includeGlobal: false, cancellationToken).ConfigureAwait(false); + var key = keys.FirstOrDefault(candidate => + string.Equals(candidate.Fingerprint, keyFingerprint.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (key is null) + { + keyStatus = KeyTrustStatus.NotRegistered; + warnings.Add("Key fingerprint is not registered for this issuer."); + } + else if (key.Status == IssuerKeyStatus.Revoked) + { + keyStatus = KeyTrustStatus.Revoked; + warnings.Add("Key fingerprint has been revoked."); + } + else if (key.ExpiresAtUtc.HasValue && key.ExpiresAtUtc.Value <= _timeProvider.GetUtcNow()) + { + keyStatus = KeyTrustStatus.Expired; + warnings.Add($"Key fingerprint expired on {key.ExpiresAtUtc.Value:O}."); + } + else + { + keyStatus = KeyTrustStatus.Valid; + } + } + + var isTrusted = issuerStatus == IssuerTrustStatus.Trusted + && (keyStatus is null || keyStatus == KeyTrustStatus.Valid); + + if (issuerStatus == IssuerTrustStatus.Revoked) + { + warnings.Add("Issuer trust override is in revoked state."); + } + else if (issuerStatus == IssuerTrustStatus.Suspended) + { + warnings.Add("Issuer trust override is in suspended state."); + } + + return new IssuerTrustValidation( + IsTrusted: isTrusted, + EffectiveTrustTier: isTrusted ? effectiveTier : TrustTier.Untrusted, + IssuerStatus: issuerStatus, + KeyStatus: keyStatus, + Warnings: warnings); + } + + private async Task MapIssuerAsync( + ResolvedTenantScope scope, + AuthorityIssuerRecord issuer, + CancellationToken cancellationToken) + { + var keys = await _keyService.ListAsync(issuer.TenantId, issuer.Id, includeGlobal: false, cancellationToken).ConfigureAwait(false); + var trustView = await GetTrustViewAsync(scope, issuer, cancellationToken).ConfigureAwait(false); + var status = MapIssuerStatus(trustView.EffectiveWeight); + var trustTier = MapTrustTier(trustView.EffectiveWeight); + + return new VerificationIssuerRecord( + IssuerId: ResolvePublicIssuerId(issuer), + Name: issuer.DisplayName, + Category: MapCategory(issuer, scope), + TrustTier: trustTier, + Status: status, + KeyFingerprints: keys + .OrderBy(static key => key.CreatedAtUtc) + .ThenBy(static key => key.Fingerprint, StringComparer.OrdinalIgnoreCase) + .Select(key => MapKey(key, _timeProvider.GetUtcNow())) + .ToArray(), + Metadata: MapMetadata(issuer.Metadata, issuer.Description, issuer.Contact.Email, issuer.Tags), + RegisteredAt: issuer.CreatedAtUtc, + LastUpdatedAt: issuer.UpdatedAtUtc, + RevokedAt: status == VerificationIssuerStatus.Revoked ? trustView.TenantOverride?.UpdatedAtUtc ?? trustView.GlobalOverride?.UpdatedAtUtc : null, + RevocationReason: status == VerificationIssuerStatus.Revoked ? trustView.TenantOverride?.Reason ?? trustView.GlobalOverride?.Reason : null); + } + + private async Task ApplyTrustTierAsync( + string tenantId, + string issuerId, + TrustTier trustTier, + string actor, + CancellationToken cancellationToken) + { + var weight = trustTier switch + { + TrustTier.Authoritative => AuthoritativeWeightThreshold, + TrustTier.Trusted => TrustedWeightThreshold, + TrustTier.Untrusted => -TrustedWeightThreshold, + _ => 0m + }; + + if (weight == 0m) + { + await _trustService.DeleteAsync( + tenantId, + issuerId, + actor, + "clear-vexlens-trust-tier", + cancellationToken) + .ConfigureAwait(false); + return; + } + + await _trustService.SetAsync( + tenantId, + issuerId, + weight, + $"vexlens:{trustTier.ToString().ToLowerInvariant()}", + actor, + cancellationToken) + .ConfigureAwait(false); + } + + private async Task AddKeyAsync( + string tenantId, + string issuerId, + KeyFingerprintRegistration keyRegistration, + string actor, + CancellationToken cancellationToken) + { + var authorityKeyType = MapKeyType(keyRegistration.KeyType); + var publicKey = keyRegistration.PublicKey + ?? throw new InvalidOperationException("Authority-backed issuer key registration requires key material."); + var material = new IssuerKeyMaterial( + NormalizeKeyFormat(keyRegistration.PublicKeyFormat), + Encoding.UTF8.GetString(publicKey, 0, publicKey.Length)); + + var record = await _keyService.AddAsync( + tenantId, + issuerId, + authorityKeyType, + material, + keyRegistration.ExpiresAt, + actor, + "vexlens-key-registration", + cancellationToken) + .ConfigureAwait(false); + + if (!string.Equals(record.Fingerprint, keyRegistration.Fingerprint, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Authority computed fingerprint {ComputedFingerprint} for issuer {IssuerId}, which differs from requested fingerprint {RequestedFingerprint}.", + record.Fingerprint, + issuerId, + keyRegistration.Fingerprint); + } + } + + private async Task ResolveTenantScopeAsync(CancellationToken cancellationToken) + { + var publicTenantId = NormalizeOptional(_tenantAccessor.TenantId); + var currentTenantId = await ResolveAuthorityTenantIdAsync(publicTenantId, cancellationToken).ConfigureAwait(false); + if (publicTenantId is not null && currentTenantId is null) + { + throw new InvalidOperationException($"Authority tenant '{publicTenantId}' was not found."); + } + + var globalTenantId = await ResolveAuthorityTenantIdAsync(IssuerTenants.Global, cancellationToken).ConfigureAwait(false); + return new ResolvedTenantScope(publicTenantId, currentTenantId, globalTenantId); + } + + private async Task ResolveAuthorityTenantIdAsync(string? tenantIdentifier, CancellationToken cancellationToken) + { + var normalized = NormalizeOptional(tenantIdentifier); + if (normalized is null) + { + return null; + } + + if (Guid.TryParse(normalized, out var parsed)) + { + return parsed.ToString(); + } + + var tenant = await _tenantRepository.GetBySlugAsync(normalized, cancellationToken).ConfigureAwait(false); + return tenant?.Id.ToString(); + } + + private static string RequireCurrentTenantId(ResolvedTenantScope scope) + { + if (!string.IsNullOrWhiteSpace(scope.CurrentTenantId)) + { + return scope.CurrentTenantId; + } + + throw new InvalidOperationException("A tenant context is required for Authority-backed issuer operations."); + } + + private async Task> ListAuthorityIssuersAsync( + ResolvedTenantScope scope, + bool includeGlobal, + CancellationToken cancellationToken) + { + var issuers = new List(); + + if (!string.IsNullOrWhiteSpace(scope.CurrentTenantId)) + { + issuers.AddRange(await _issuerService.ListAsync(scope.CurrentTenantId, includeGlobal: false, cancellationToken).ConfigureAwait(false)); + } + + if (includeGlobal + && !string.IsNullOrWhiteSpace(scope.GlobalTenantId) + && !string.Equals(scope.CurrentTenantId, scope.GlobalTenantId, StringComparison.Ordinal)) + { + issuers.AddRange(await _issuerService.ListAsync(scope.GlobalTenantId, includeGlobal: false, cancellationToken).ConfigureAwait(false)); + } + + return issuers + .DistinctBy(static issuer => issuer.Id) + .OrderBy(static issuer => issuer.Slug, StringComparer.OrdinalIgnoreCase) + .ThenBy(static issuer => issuer.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private async Task ResolveAuthorityIssuerAsync( + string issuerId, + ResolvedTenantScope scope, + bool includeGlobal, + CancellationToken cancellationToken) + { + var normalizedIssuerId = issuerId.Trim(); + var issuers = await ListAuthorityIssuersAsync(scope, includeGlobal, cancellationToken).ConfigureAwait(false); + return issuers.FirstOrDefault(issuer => MatchesIssuerIdentifier(issuer, normalizedIssuerId)); + } + + private async Task GetTrustViewAsync( + ResolvedTenantScope scope, + AuthorityIssuerRecord issuer, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(scope.CurrentTenantId)) + { + return await _trustService.GetAsync( + scope.CurrentTenantId, + issuer.Id, + includeGlobal: !string.IsNullOrWhiteSpace(scope.GlobalTenantId) + && !string.Equals(scope.CurrentTenantId, scope.GlobalTenantId, StringComparison.Ordinal), + cancellationToken) + .ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(scope.GlobalTenantId) + && string.Equals(issuer.TenantId, scope.GlobalTenantId, StringComparison.Ordinal)) + { + return await _trustService.GetAsync(scope.GlobalTenantId, issuer.Id, includeGlobal: false, cancellationToken) + .ConfigureAwait(false); + } + + return new IssuerTrustView(null, null, 0m); + } + + private static void EnsureKeyMaterialPresent(IReadOnlyList? initialKeys) + { + if (initialKeys is null) + { + return; + } + + foreach (var key in initialKeys) + { + if (key.PublicKey is null || key.PublicKey.Length == 0) + { + throw new InvalidOperationException( + "Authority-backed issuer registration requires key material for initial keys. Provide PublicKey and PublicKeyFormat."); + } + } + } + + private static StellaOps.IssuerDirectory.Core.Domain.IssuerMetadata BuildAuthorityMetadata(IssuerRegistration registration) + { + var attributes = registration.Metadata?.Custom is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(registration.Metadata.Custom, StringComparer.OrdinalIgnoreCase); + + attributes[CategoryAttributeKey] = registration.Category.ToString(); + attributes[PublicIssuerIdAttributeKey] = registration.IssuerId; + + return new StellaOps.IssuerDirectory.Core.Domain.IssuerMetadata( + cveOrgId: null, + csafPublisherId: registration.IssuerId, + securityAdvisoriesUrl: TryCreateUri(registration.Metadata?.Uri), + catalogUrl: null, + supportedLanguages: Array.Empty(), + attributes: attributes); + } + + private static VerificationIssuerMetadata? MapMetadata( + StellaOps.IssuerDirectory.Core.Domain.IssuerMetadata metadata, + string? description, + string? email, + IReadOnlyCollection tags) + { + return new VerificationIssuerMetadata( + Description: description, + Uri: metadata.SecurityAdvisoriesUrl?.ToString(), + Email: email, + LogoUri: null, + Tags: tags.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToArray(), + Custom: metadata.Attributes); + } + + private static VerificationKeyFingerprintRecord MapKey(IssuerKeyRecord key, DateTimeOffset now) + { + var status = key.Status switch + { + IssuerKeyStatus.Active when key.ExpiresAtUtc.HasValue && key.ExpiresAtUtc.Value <= now => VerificationKeyFingerprintStatus.Expired, + IssuerKeyStatus.Active => VerificationKeyFingerprintStatus.Active, + IssuerKeyStatus.Revoked => VerificationKeyFingerprintStatus.Revoked, + _ => VerificationKeyFingerprintStatus.Expired + }; + + return new VerificationKeyFingerprintRecord( + Fingerprint: key.Fingerprint, + KeyType: MapKeyType(key.Type), + Algorithm: key.Material.Format, + Status: status, + RegisteredAt: key.CreatedAtUtc, + ExpiresAt: key.ExpiresAtUtc, + RevokedAt: key.RevokedAtUtc, + RevocationReason: key.Status == IssuerKeyStatus.Revoked ? "revoked-in-authority" : null); + } + + private string ResolveActor() + { + var actor = _tenantAccessor.TenantContext?.ActorId; + return string.IsNullOrWhiteSpace(actor) ? "vexlens-webservice" : actor; + } + + private static VerificationIssuerStatus MapIssuerStatus(decimal effectiveWeight) => effectiveWeight switch + { + <= RevokedWeightThreshold => VerificationIssuerStatus.Revoked, + < SuspendedWeightThreshold => VerificationIssuerStatus.Suspended, + _ => VerificationIssuerStatus.Active + }; + + private static IssuerTrustStatus MapTrustStatus(decimal effectiveWeight) => effectiveWeight switch + { + <= RevokedWeightThreshold => IssuerTrustStatus.Revoked, + < SuspendedWeightThreshold => IssuerTrustStatus.Suspended, + _ => IssuerTrustStatus.Trusted + }; + + private static TrustTier MapTrustTier(decimal effectiveWeight) => effectiveWeight switch + { + >= AuthoritativeWeightThreshold => TrustTier.Authoritative, + >= TrustedWeightThreshold => TrustTier.Trusted, + < SuspendedWeightThreshold => TrustTier.Untrusted, + _ => TrustTier.Unknown + }; + + private static int GetTrustRank(TrustTier trustTier) => trustTier switch + { + TrustTier.Authoritative => 3, + TrustTier.Trusted => 2, + TrustTier.Unknown => 1, + _ => 0 + }; + + private static IssuerCategory MapCategory(AuthorityIssuerRecord issuer, ResolvedTenantScope scope) + { + if (issuer.Metadata.Attributes.TryGetValue(CategoryAttributeKey, out var categoryValue) + && Enum.TryParse(categoryValue, ignoreCase: true, out var category)) + { + return category; + } + + if (issuer.Tags.Any(tag => string.Equals(tag, "internal", StringComparison.OrdinalIgnoreCase))) + { + return IssuerCategory.Internal; + } + + if (!string.IsNullOrWhiteSpace(issuer.Metadata.CsafPublisherId) + || issuer.Metadata.SecurityAdvisoriesUrl is not null) + { + return IssuerCategory.Vendor; + } + + return !string.IsNullOrWhiteSpace(scope.GlobalTenantId) + && string.Equals(issuer.TenantId, scope.GlobalTenantId, StringComparison.Ordinal) + ? IssuerCategory.Aggregator + : IssuerCategory.Internal; + } + + private static string ResolvePublicIssuerId(AuthorityIssuerRecord issuer) + { + if (issuer.Metadata.Attributes.TryGetValue(PublicIssuerIdAttributeKey, out var publicIssuerId) + && !string.IsNullOrWhiteSpace(publicIssuerId)) + { + return publicIssuerId; + } + + if (!string.IsNullOrWhiteSpace(issuer.Slug)) + { + return issuer.Slug; + } + + if (!string.IsNullOrWhiteSpace(issuer.Metadata.CsafPublisherId)) + { + return issuer.Metadata.CsafPublisherId; + } + + return issuer.Id; + } + + private static bool MatchesIssuerIdentifier(AuthorityIssuerRecord issuer, string issuerId) + { + if (string.Equals(issuer.Id, issuerId, StringComparison.OrdinalIgnoreCase) + || string.Equals(issuer.Slug, issuerId, StringComparison.OrdinalIgnoreCase) + || string.Equals(issuer.Metadata.CsafPublisherId, issuerId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return issuer.Metadata.Attributes.TryGetValue(PublicIssuerIdAttributeKey, out var publicIssuerId) + && string.Equals(publicIssuerId, issuerId, StringComparison.OrdinalIgnoreCase); + } + + private static string CreateSlug(string issuerId) + { + var builder = new StringBuilder(issuerId.Length); + var lastWasDash = false; + + foreach (var ch in issuerId.Trim().ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(ch); + lastWasDash = false; + } + else if (!lastWasDash) + { + builder.Append('-'); + lastWasDash = true; + } + } + + return builder.ToString().Trim('-'); + } + + private static Uri? TryCreateUri(string? value) + { + return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null; + } + + private static string NormalizeKeyFormat(string? format) + { + return string.IsNullOrWhiteSpace(format) ? "pem" : format.Trim().ToLowerInvariant(); + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static StellaOps.IssuerDirectory.Core.Domain.IssuerKeyType MapKeyType(KeyType keyType) => keyType switch + { + KeyType.X509 => StellaOps.IssuerDirectory.Core.Domain.IssuerKeyType.X509Certificate, + KeyType.Sigstore => StellaOps.IssuerDirectory.Core.Domain.IssuerKeyType.DssePublicKey, + _ => throw new InvalidOperationException( + $"Authority-backed issuer directory does not support VexLens key type '{keyType}' without explicit key material conversion.") + }; + + private static KeyType MapKeyType(StellaOps.IssuerDirectory.Core.Domain.IssuerKeyType keyType) => keyType switch + { + StellaOps.IssuerDirectory.Core.Domain.IssuerKeyType.X509Certificate => KeyType.X509, + StellaOps.IssuerDirectory.Core.Domain.IssuerKeyType.DssePublicKey => KeyType.Sigstore, + StellaOps.IssuerDirectory.Core.Domain.IssuerKeyType.Ed25519PublicKey => KeyType.Sigstore, + _ => KeyType.Sigstore + }; + + private sealed record ResolvedTenantScope( + string? PublicTenantId, + string? CurrentTenantId, + string? GlobalTenantId); +} diff --git a/src/VexLens/StellaOps.VexLens.WebService/Services/VexHubStatementProvider.cs b/src/VexLens/StellaOps.VexLens.WebService/Services/VexHubStatementProvider.cs new file mode 100644 index 000000000..5ae39391e --- /dev/null +++ b/src/VexLens/StellaOps.VexLens.WebService/Services/VexHubStatementProvider.cs @@ -0,0 +1,173 @@ +using StellaOps.VexHub.Core; +using StellaOps.VexHub.Core.Models; +using StellaOps.VexLens.Api; +using StellaOps.VexLens.Models; +using StellaOps.VexLens.Verification; +using VexHubVerificationStatus = StellaOps.VexHub.Core.Models.VerificationStatus; + +namespace StellaOps.VexLens.WebService.Services; + +public sealed class VexHubStatementProvider : IVexStatementProvider +{ + private readonly IVexStatementRepository _statementRepository; + private readonly IVexSourceRepository _sourceRepository; + + public VexHubStatementProvider( + IVexStatementRepository statementRepository, + IVexSourceRepository sourceRepository) + { + _statementRepository = statementRepository ?? throw new ArgumentNullException(nameof(statementRepository)); + _sourceRepository = sourceRepository ?? throw new ArgumentNullException(nameof(sourceRepository)); + } + + public async Task> GetStatementsAsync( + string vulnerabilityId, + string productKey, + string? tenantId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId); + ArgumentException.ThrowIfNullOrWhiteSpace(productKey); + + var statements = await _statementRepository.SearchAsync( + new VexStatementFilter + { + VulnerabilityId = vulnerabilityId.Trim(), + ProductKey = productKey.Trim() + }, + limit: 512, + offset: 0, + cancellationToken) + .ConfigureAwait(false); + + var sources = new Dictionary(StringComparer.OrdinalIgnoreCase); + var results = new List(statements.Count); + + foreach (var statement in statements + .OrderByDescending(static item => item.IssuedAt ?? item.SourceUpdatedAt ?? item.IngestedAt) + .ThenBy(static item => item.SourceId, StringComparer.OrdinalIgnoreCase) + .ThenBy(static item => item.SourceStatementId, StringComparer.Ordinal)) + { + if (!sources.TryGetValue(statement.SourceId, out var source)) + { + source = await _sourceRepository.GetByIdAsync(statement.SourceId, cancellationToken).ConfigureAwait(false); + sources[statement.SourceId] = source; + } + + results.Add(new VexStatementWithContext( + Statement: MapStatement(statement), + Issuer: MapIssuer(statement, source), + SignatureVerification: MapSignatureVerification(statement, source), + DocumentIssuedAt: statement.IssuedAt ?? statement.SourceUpdatedAt ?? statement.IngestedAt, + SourceDocumentId: statement.SourceDocumentId)); + } + + return results; + } + + private static NormalizedStatement MapStatement(AggregatedVexStatement statement) + { + return new NormalizedStatement( + StatementId: string.IsNullOrWhiteSpace(statement.SourceStatementId) + ? statement.Id.ToString("n") + : statement.SourceStatementId, + VulnerabilityId: statement.VulnerabilityId, + VulnerabilityAliases: statement.VulnerabilityAliases, + Product: new NormalizedProduct( + Key: statement.ProductKey, + Name: null, + Version: null, + Purl: statement.ProductKey.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) + ? statement.ProductKey + : null, + Cpe: statement.ProductKey.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase) + ? statement.ProductKey + : null, + Hashes: null), + Status: statement.Status, + StatusNotes: statement.StatusNotes, + Justification: statement.Justification, + ImpactStatement: statement.ImpactStatement, + ActionStatement: statement.ActionStatement, + ActionStatementTimestamp: statement.SourceUpdatedAt ?? statement.UpdatedAt, + Versions: statement.Versions, + Subcomponents: null, + FirstSeen: statement.IngestedAt, + LastSeen: statement.SourceUpdatedAt ?? statement.UpdatedAt ?? statement.IngestedAt); + } + + private static VexIssuer? MapIssuer(AggregatedVexStatement statement, VexSource? source) + { + if (source is null) + { + return null; + } + + var keyFingerprints = string.IsNullOrWhiteSpace(statement.SigningKeyFingerprint) + ? null + : new[] { statement.SigningKeyFingerprint }; + + return new VexIssuer( + Id: source.SourceId, + Name: source.Name, + Category: source.IssuerCategory, + TrustTier: source.TrustTier, + KeyFingerprints: keyFingerprints); + } + + private static SignatureVerificationResult? MapSignatureVerification( + AggregatedVexStatement statement, + VexSource? source) + { + var signer = string.IsNullOrWhiteSpace(statement.SigningKeyFingerprint) + ? null + : new SignerInfo( + IssuerId: source?.SourceId ?? statement.SourceId, + Name: source?.Name, + Email: null, + Organization: null, + KeyFingerprint: statement.SigningKeyFingerprint, + Algorithm: "unknown", + SignedAt: statement.VerifiedAt ?? statement.IssuedAt); + + return statement.VerificationStatus switch + { + VexHubVerificationStatus.None => null, + VexHubVerificationStatus.Verified => new SignatureVerificationResult( + IsValid: true, + Status: SignatureVerificationStatus.Valid, + Signer: signer, + CertificateChain: null, + Timestamp: statement.VerifiedAt.HasValue + ? new TimestampInfo(statement.VerifiedAt.Value, null, null, true) + : null, + Errors: [], + Warnings: []), + VexHubVerificationStatus.Pending => new SignatureVerificationResult( + IsValid: false, + Status: SignatureVerificationStatus.UnknownError, + Signer: signer, + CertificateChain: null, + Timestamp: null, + Errors: [], + Warnings: [new SignatureVerificationWarning("WARN_VEXHUB_PENDING", "Signature verification is still pending in VexHub.")]), + VexHubVerificationStatus.Failed => new SignatureVerificationResult( + IsValid: false, + Status: SignatureVerificationStatus.InvalidSignature, + Signer: signer, + CertificateChain: null, + Timestamp: null, + Errors: [new SignatureVerificationError("ERR_VEXHUB_FAILED", "VexHub marked the statement signature as failed.", null)], + Warnings: []), + VexHubVerificationStatus.Untrusted => new SignatureVerificationResult( + IsValid: false, + Status: SignatureVerificationStatus.UntrustedIssuer, + Signer: signer, + CertificateChain: null, + Timestamp: null, + Errors: [], + Warnings: [new SignatureVerificationWarning("WARN_VEXHUB_UNTRUSTED", "VexHub marked the statement signer as untrusted.")]), + _ => null + }; + } +} diff --git a/src/VexLens/StellaOps.VexLens.WebService/StellaOps.VexLens.WebService.csproj b/src/VexLens/StellaOps.VexLens.WebService/StellaOps.VexLens.WebService.csproj index aa1af9e53..68e9076b1 100644 --- a/src/VexLens/StellaOps.VexLens.WebService/StellaOps.VexLens.WebService.csproj +++ b/src/VexLens/StellaOps.VexLens.WebService/StellaOps.VexLens.WebService.csproj @@ -22,6 +22,12 @@ + + + + + + diff --git a/src/VexLens/StellaOps.VexLens.WebService/TASKS.md b/src/VexLens/StellaOps.VexLens.WebService/TASKS.md index f1270163f..91590f61d 100644 --- a/src/VexLens/StellaOps.VexLens.WebService/TASKS.md +++ b/src/VexLens/StellaOps.VexLens.WebService/TASKS.md @@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0777-T | DONE | Revalidated 2026-01-07. | | AUDIT-0777-A | DONE | Fixed deprecated APIs, builds 0 warnings 2026-01-07. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| NOMOCK-008 | DONE | SPRINT_20260410_001: persisted VexLens noise-gating snapshots/statistics and removed the live web runtime in-memory path on 2026-04-14. | diff --git a/src/VexLens/StellaOps.VexLens.WebService/VexLensWebServiceMarker.cs b/src/VexLens/StellaOps.VexLens.WebService/VexLensWebServiceMarker.cs new file mode 100644 index 000000000..a240d06e7 --- /dev/null +++ b/src/VexLens/StellaOps.VexLens.WebService/VexLensWebServiceMarker.cs @@ -0,0 +1,3 @@ +namespace StellaOps.VexLens.WebService; + +public sealed class VexLensWebServiceMarker; diff --git a/src/VexLens/StellaOps.VexLens/Api/ConsensusApiModels.cs b/src/VexLens/StellaOps.VexLens/Api/ConsensusApiModels.cs index 4825261ff..da6a383d5 100644 --- a/src/VexLens/StellaOps.VexLens/Api/ConsensusApiModels.cs +++ b/src/VexLens/StellaOps.VexLens/Api/ConsensusApiModels.cs @@ -238,7 +238,9 @@ public sealed record RegisterKeyRequest( string Fingerprint, string KeyType, string? Algorithm, - DateTimeOffset? ExpiresAt); + DateTimeOffset? ExpiresAt, + string? PublicKey, + string? PublicKeyFormat); /// /// Issuer metadata request. diff --git a/src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs b/src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs index da2968d1c..fba8a4bc7 100644 --- a/src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs +++ b/src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs @@ -4,6 +4,7 @@ using StellaOps.VexLens.Proof; using StellaOps.VexLens.Storage; using StellaOps.VexLens.Trust; using StellaOps.VexLens.Verification; +using System.Text; namespace StellaOps.VexLens.Api; @@ -514,7 +515,8 @@ public sealed class VexLensApiService : IVexLensApiService KeyType: ParseKeyType(k.KeyType), Algorithm: k.Algorithm, ExpiresAt: k.ExpiresAt, - PublicKey: null)).ToList(), + PublicKey: ParsePublicKey(k.PublicKey), + PublicKeyFormat: k.PublicKeyFormat)).ToList(), Metadata: request.Metadata != null ? new IssuerMetadata( Description: request.Metadata.Description, Uri: request.Metadata.Uri, @@ -545,7 +547,8 @@ public sealed class VexLensApiService : IVexLensApiService KeyType: ParseKeyType(request.KeyType), Algorithm: request.Algorithm, ExpiresAt: request.ExpiresAt, - PublicKey: null); + PublicKey: ParsePublicKey(request.PublicKey), + PublicKeyFormat: request.PublicKeyFormat); var issuer = await _issuerDirectory.AddKeyFingerprintAsync(issuerId, keyReg, cancellationToken); return MapToIssuerDetailResponse(issuer); @@ -782,6 +785,13 @@ public sealed class VexLensApiService : IVexLensApiService private static KeyType ParseKeyType(string keyType) => Enum.TryParse(keyType, true, out var result) ? result : KeyType.Pgp; + + private static byte[]? ParsePublicKey(string? publicKey) + { + return string.IsNullOrWhiteSpace(publicKey) + ? null + : Encoding.UTF8.GetBytes(publicKey.Trim()); + } } /// diff --git a/src/VexLens/StellaOps.VexLens/Extensions/VexLensServiceCollectionExtensions.cs b/src/VexLens/StellaOps.VexLens/Extensions/VexLensServiceCollectionExtensions.cs index a6e4edaaf..8dd8a524f 100644 --- a/src/VexLens/StellaOps.VexLens/Extensions/VexLensServiceCollectionExtensions.cs +++ b/src/VexLens/StellaOps.VexLens/Extensions/VexLensServiceCollectionExtensions.cs @@ -93,7 +93,7 @@ public static class VexLensServiceCollectionExtensions services.TryAddSingleton(); // Verification - services.TryAddSingleton(); + services.TryAddScoped(); // Issuer directory - use in-memory by default, can be replaced services.TryAddSingleton(); @@ -111,7 +111,7 @@ public static class VexLensServiceCollectionExtensions services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddScoped(); services.TryAddSingleton(); // Consensus engine diff --git a/src/VexLens/StellaOps.VexLens/Verification/IIssuerDirectory.cs b/src/VexLens/StellaOps.VexLens/Verification/IIssuerDirectory.cs index 6b2c4e8d7..66afb94ef 100644 --- a/src/VexLens/StellaOps.VexLens/Verification/IIssuerDirectory.cs +++ b/src/VexLens/StellaOps.VexLens/Verification/IIssuerDirectory.cs @@ -171,7 +171,8 @@ public sealed record KeyFingerprintRegistration( KeyType KeyType, string? Algorithm, DateTimeOffset? ExpiresAt, - byte[]? PublicKey); + byte[]? PublicKey, + string? PublicKeyFormat); /// /// Result of trust validation. diff --git a/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/NoiseGatingPersistenceTests.cs b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/NoiseGatingPersistenceTests.cs new file mode 100644 index 000000000..93a567d6a --- /dev/null +++ b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/NoiseGatingPersistenceTests.cs @@ -0,0 +1,388 @@ +using System.Collections.Immutable; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using StellaOps.ReachGraph.Deduplication; +using StellaOps.ReachGraph.Schema; +using StellaOps.VexLens.Api; +using StellaOps.VexLens.Models; +using StellaOps.VexLens.NoiseGate; +using StellaOps.VexLens.Persistence.Postgres; +using StellaOps.VexLens.Storage; +using StellaOps.VexLens.WebService; +using Testcontainers.PostgreSql; + +namespace StellaOps.VexLens.WebService.Tests; + +public sealed class NoiseGatingPersistenceTests +{ + private static PostgreSqlBuilder CreatePostgresBuilder() + { + return new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("vexlens_noisegate_test") + .WithUsername("postgres") + .WithPassword("postgres"); + } + + [Fact] + public async Task PostgresStores_RoundTripSnapshotsAndStatistics() + { + await using var context = await CreateStoreContextAsync(); + var snapshotStore = new PostgresSnapshotStore(context.DataSource, NullLogger.Instance); + var statsStore = new PostgresGatingStatisticsStore(context.DataSource, NullLogger.Instance, TimeProvider.System); + var tenantId = "tenant-a"; + var rawSnapshot = CreateRawSnapshot("raw-001"); + var gatedSnapshot = CreateGatedSnapshot("gated-001"); + + await snapshotStore.StoreRawAsync(rawSnapshot, tenantId); + await snapshotStore.StoreAsync(gatedSnapshot, tenantId); + await statsStore.RecordAsync(gatedSnapshot.SnapshotId, gatedSnapshot.Statistics, tenantId); + + var loadedRaw = await snapshotStore.GetRawAsync(rawSnapshot.SnapshotId, tenantId); + var loadedGated = await snapshotStore.GetAsync(gatedSnapshot.SnapshotId, tenantId); + var listedSnapshots = await snapshotStore.ListAsync(tenantId); + var aggregated = await statsStore.GetAggregatedAsync(tenantId); + + loadedRaw.Should().NotBeNull(); + loadedRaw!.Graph.Artifact.Name.Should().Be(rawSnapshot.Graph.Artifact.Name); + loadedRaw.Verdicts.Should().HaveCount(1); + loadedRaw.Verdicts[0].VulnerabilityId.Should().Be("CVE-2026-1000"); + + loadedGated.Should().NotBeNull(); + loadedGated!.Digest.Should().Be(gatedSnapshot.Digest); + loadedGated.Edges.Should().HaveCount(1); + loadedGated.Verdicts.Should().HaveCount(1); + loadedGated.DampedVerdicts.Should().HaveCount(1); + + listedSnapshots.Should().Contain(gatedSnapshot.SnapshotId); + + aggregated.TotalSnapshots.Should().Be(1); + aggregated.TotalEdgesProcessed.Should().Be(gatedSnapshot.Statistics.OriginalEdgeCount); + aggregated.TotalDamped.Should().Be(gatedSnapshot.Statistics.DampedVerdictCount); + } + + [Fact] + public async Task GateSnapshotEndpoint_PersistsGatedSnapshotAndStatistics() + { + await using var context = await CreateHostedContextAsync(); + const string tenantId = "demo-prod"; + const string snapshotId = "gate-me-001"; + + await using (var scope = context.Factory!.Services.CreateAsyncScope()) + { + var snapshotStore = scope.ServiceProvider.GetRequiredService(); + await snapshotStore.StoreRawAsync(CreateRawSnapshot(snapshotId), tenantId); + } + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"/api/v1/vexlens/gating/snapshots/{snapshotId}/gate") + { + Content = JsonContent.Create(new + { + snapshotId, + tenantId + }) + }; + request.Headers.Add("X-Stella-Ops-Tenant", tenantId); + + using var response = await context.Client!.SendAsync(request); + + response.EnsureSuccessStatusCode(); + var gated = await response.Content.ReadFromJsonAsync(); + gated.Should().NotBeNull(); + gated!.SnapshotId.Should().Be(snapshotId); + gated.EdgeCount.Should().BeGreaterThan(0); + gated.Statistics.TotalVerdictCount.Should().Be(1); + + var statsRequest = new HttpRequestMessage(HttpMethod.Get, "/api/v1/vexlens/gating/statistics"); + statsRequest.Headers.Add("X-Stella-Ops-Tenant", tenantId); + + using var statsResponse = await context.Client!.SendAsync(statsRequest); + statsResponse.EnsureSuccessStatusCode(); + var aggregated = await statsResponse.Content.ReadFromJsonAsync(); + aggregated.Should().NotBeNull(); + aggregated!.TotalSnapshots.Should().BeGreaterThanOrEqualTo(1); + aggregated.TotalVerdicts.Should().BeGreaterThanOrEqualTo(1); + + await using var connection = await context.DataSource.OpenConnectionAsync(); + await using var gatedCommand = new NpgsqlCommand( + "SELECT COUNT(*) FROM vexlens.noise_gate_gated_snapshots WHERE snapshot_id = @snapshot_id AND tenant_scope = @tenant_scope;", + connection); + gatedCommand.Parameters.AddWithValue("snapshot_id", snapshotId); + gatedCommand.Parameters.AddWithValue("tenant_scope", tenantId); + var gatedCount = (long)(await gatedCommand.ExecuteScalarAsync() ?? 0L); + + await using var statsCommand = new NpgsqlCommand( + "SELECT COUNT(*) FROM vexlens.noise_gate_statistics WHERE snapshot_id = @snapshot_id AND tenant_scope = @tenant_scope;", + connection); + statsCommand.Parameters.AddWithValue("snapshot_id", snapshotId); + statsCommand.Parameters.AddWithValue("tenant_scope", tenantId); + var statsCount = (long)(await statsCommand.ExecuteScalarAsync() ?? 0L); + + gatedCount.Should().Be(1); + statsCount.Should().Be(1); + } + + private static async Task CreateStoreContextAsync() + { + var postgres = CreatePostgresBuilder().Build(); + await postgres.StartAsync(); + var connectionString = postgres.GetConnectionString(); + await ApplyMigrationsAsync(connectionString); + var dataSource = NpgsqlDataSource.Create(connectionString); + return new NoiseGateTestContext( + postgres, + dataSource, + factory: null, + client: null, + originalEnvironment: new Dictionary(StringComparer.Ordinal)); + } + + private static async Task CreateHostedContextAsync() + { + var postgres = CreatePostgresBuilder().Build(); + await postgres.StartAsync(); + var connectionString = postgres.GetConnectionString(); + var dataSource = NpgsqlDataSource.Create(connectionString); + var originalEnvironment = new Dictionary(StringComparer.Ordinal); + SetEnvironmentOverride(originalEnvironment, "ConnectionStrings__VexLens", connectionString); + + var factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:VexLens"] = connectionString, + ["Authority:ResourceServer:Authority"] = "http://localhost", + ["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32", + ["Authority:ResourceServer:BypassNetworks:1"] = "::1/128", + ["Router:Enabled"] = "false" + }); + }); + + builder.ConfigureTestServices(VexLensTestSecurity.Configure); + }); + + var client = factory.CreateClient(); + return new NoiseGateTestContext(postgres, dataSource, factory, client, originalEnvironment); + } + + private static void SetEnvironmentOverride( + IDictionary originalEnvironment, + string key, + string? value) + { + if (!originalEnvironment.ContainsKey(key)) + { + originalEnvironment[key] = Environment.GetEnvironmentVariable(key); + } + + Environment.SetEnvironmentVariable(key, value); + } + + private static async Task ApplyMigrationsAsync(string connectionString) + { + await using var dataSource = NpgsqlDataSource.Create(connectionString); + await using var connection = await dataSource.OpenConnectionAsync(); + + var assembly = typeof(VexLensDataSource).Assembly; + const string bootstrapSql = "CREATE SCHEMA IF NOT EXISTS vexlens;"; + await using (var bootstrapCommand = new NpgsqlCommand(bootstrapSql, connection)) + { + await bootstrapCommand.ExecuteNonQueryAsync(); + } + + var resourceName = assembly + .GetManifestResourceNames() + .Single(name => name.EndsWith("002_noise_gating_state.sql", StringComparison.Ordinal)); + await using var stream = assembly.GetManifestResourceStream(resourceName); + stream.Should().NotBeNull(); + + using var reader = new StreamReader(stream!); + var sql = await reader.ReadToEndAsync(); + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } + + private static RawGraphSnapshot CreateRawSnapshot(string snapshotId) + { + return new RawGraphSnapshot + { + SnapshotId = snapshotId, + CreatedAt = DateTimeOffset.Parse("2026-04-14T12:00:00Z"), + Graph = new ReachGraphMinimal + { + Artifact = new ReachGraphArtifact("checkout-api", "sha256:artifact-001", ["prod"]), + Scope = new ReachGraphScope(["entry:main"], ["prod"], ["CVE-2026-1000"]), + Nodes = + [ + new ReachGraphNode + { + Id = "node-entry", + Kind = ReachGraphNodeKind.Function, + Ref = "Program.Main", + IsEntrypoint = true + }, + new ReachGraphNode + { + Id = "node-sink", + Kind = ReachGraphNodeKind.Function, + Ref = "Vulnerable.Call", + IsSink = true + } + ], + Edges = + [ + new ReachGraphEdge + { + From = "node-entry", + To = "node-sink", + Why = new EdgeExplanation + { + Type = EdgeExplanationType.DirectCall, + Confidence = 0.95, + Loc = "Program.cs:42" + } + } + ], + Provenance = new ReachGraphProvenance + { + Inputs = new ReachGraphInputs + { + Sbom = "sha256:sbom-001", + Callgraph = "sha256:callgraph-001" + }, + ComputedAt = DateTimeOffset.Parse("2026-04-14T12:00:00Z"), + Analyzer = new ReachGraphAnalyzer("test-analyzer", "1.0.0", "sha256:toolchain-001") + } + }, + Verdicts = + [ + new VerdictResolutionRequest + { + Key = "pkg:container/checkout-api@1.0.0|CVE-2026-1000", + VulnerabilityId = "CVE-2026-1000", + ProductKey = "pkg:container/checkout-api@1.0.0", + ProposedStatus = VexStatus.Affected, + ProposedConfidence = 0.93, + RationaleClass = "reachable", + ContributingSources = ["scanner"] + } + ] + }; + } + + private static GatedGraphSnapshot CreateGatedSnapshot(string snapshotId) + { + return new GatedGraphSnapshot + { + SnapshotId = snapshotId, + Digest = "sha256:gated-001", + Artifact = new ReachGraphArtifact("checkout-api", "sha256:artifact-001", ["prod"]), + Edges = + [ + new DeduplicatedEdge + { + Key = new EdgeSemanticKey("node-entry", "node-sink", "CVE-2026-1000"), + From = "node-entry", + To = "node-sink", + Why = new EdgeExplanation + { + Type = EdgeExplanationType.DirectCall, + Confidence = 0.95, + Loc = "Program.cs:42" + }, + Sources = ImmutableHashSet.Create("scanner"), + Strength = 0.95, + LastSeen = DateTimeOffset.Parse("2026-04-14T12:00:00Z") + } + ], + Verdicts = + [ + new ResolvedVerdict + { + VulnerabilityId = "CVE-2026-1000", + ProductKey = "pkg:container/checkout-api@1.0.0", + Status = VexStatus.Affected, + Confidence = 0.93, + WasSurfaced = true, + ContributingSources = ["scanner"], + ResolvedAt = DateTimeOffset.Parse("2026-04-14T12:00:00Z") + } + ], + DampedVerdicts = + [ + new ResolvedVerdict + { + VulnerabilityId = "CVE-2026-1001", + ProductKey = "pkg:container/checkout-api@1.0.0", + Status = VexStatus.UnderInvestigation, + Confidence = 0.20, + WasSurfaced = false, + DampingReason = "Confidence below threshold", + ContributingSources = ["scanner"], + ResolvedAt = DateTimeOffset.Parse("2026-04-14T12:00:01Z") + } + ], + CreatedAt = DateTimeOffset.Parse("2026-04-14T12:00:00Z"), + Statistics = new GatingStatistics + { + OriginalEdgeCount = 2, + DeduplicatedEdgeCount = 1, + TotalVerdictCount = 2, + SurfacedVerdictCount = 1, + DampedVerdictCount = 1, + Duration = TimeSpan.FromMilliseconds(120) + } + }; + } + + private sealed class NoiseGateTestContext : IAsyncDisposable + { + private readonly IReadOnlyDictionary _originalEnvironment; + + public NoiseGateTestContext( + PostgreSqlContainer postgres, + NpgsqlDataSource dataSource, + WebApplicationFactory? factory, + HttpClient? client, + IReadOnlyDictionary originalEnvironment) + { + Postgres = postgres; + DataSource = dataSource; + Factory = factory; + Client = client; + _originalEnvironment = originalEnvironment; + } + + public PostgreSqlContainer Postgres { get; } + + public NpgsqlDataSource DataSource { get; } + + public WebApplicationFactory? Factory { get; } + + public HttpClient? Client { get; } + + public async ValueTask DisposeAsync() + { + Client?.Dispose(); + Factory?.Dispose(); + + foreach (var (key, value) in _originalEnvironment) + { + Environment.SetEnvironmentVariable(key, value); + } + + await DataSource.DisposeAsync(); + await Postgres.DisposeAsync(); + } + } +} diff --git a/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/Services/AuthorityIssuerDirectoryAdapterTests.cs b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/Services/AuthorityIssuerDirectoryAdapterTests.cs new file mode 100644 index 000000000..4f98e23cd --- /dev/null +++ b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/Services/AuthorityIssuerDirectoryAdapterTests.cs @@ -0,0 +1,381 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using StellaOps.Authority.Persistence.Postgres.Models; +using StellaOps.Authority.Persistence.Postgres.Repositories; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Determinism; +using StellaOps.IssuerDirectory.Core.Abstractions; +using StellaOps.IssuerDirectory.Core.Domain; +using StellaOps.IssuerDirectory.Core.Services; +using StellaOps.VexLens.Models; +using StellaOps.VexLens.Verification; +using StellaOps.VexLens.WebService.Services; +using AuthorityIssuerRecord = StellaOps.IssuerDirectory.Core.Domain.IssuerRecord; +using VerificationIssuerMetadata = StellaOps.VexLens.Verification.IssuerMetadata; + +namespace StellaOps.VexLens.WebService.Tests.Services; + +public sealed class AuthorityIssuerDirectoryAdapterTests +{ + [Fact] + public async Task RegisterIssuerAsync_PersistsIssuerAndKeyThroughAuthorityServices() + { + var fixture = new AuthorityFixture(); + var sut = fixture.CreateAdapter(); + var rawKeyBytes = Enumerable.Range(1, 32).Select(static value => (byte)value).ToArray(); + var encodedKey = Encoding.UTF8.GetBytes(Convert.ToBase64String(rawKeyBytes)); + var expectedFingerprint = Convert.ToHexString(SHA256.HashData(rawKeyBytes)).ToLowerInvariant(); + + var issuer = await sut.RegisterIssuerAsync( + new IssuerRegistration( + IssuerId: "vendor-ubuntu", + Name: "Ubuntu Security", + Category: IssuerCategory.Vendor, + TrustTier: TrustTier.Trusted, + InitialKeys: + [ + new KeyFingerprintRegistration( + Fingerprint: "requested-fingerprint", + KeyType: KeyType.Sigstore, + Algorithm: "ed25519", + ExpiresAt: fixture.Now.AddDays(30), + PublicKey: encodedKey, + PublicKeyFormat: "base64") + ], + Metadata: new VerificationIssuerMetadata( + Description: "Ubuntu vendor advisories", + Uri: "https://ubuntu.com/security/notices", + Email: "security@example.com", + LogoUri: null, + Tags: ["ubuntu", "vendor"], + Custom: new Dictionary { ["region"] = "global" })), + CancellationToken.None); + + issuer.Name.Should().Be("Ubuntu Security"); + issuer.Category.Should().Be(IssuerCategory.Vendor); + issuer.TrustTier.Should().Be(TrustTier.Trusted); + issuer.Status.Should().Be(IssuerStatus.Active); + issuer.KeyFingerprints.Should().ContainSingle(); + issuer.KeyFingerprints[0].Fingerprint.Should().Be(expectedFingerprint); + issuer.KeyFingerprints[0].Status.Should().Be(KeyFingerprintStatus.Active); + issuer.Metadata.Should().NotBeNull(); + issuer.Metadata!.Custom.Should().Contain(new KeyValuePair("vexlens.category", "Vendor")); + + var byFingerprint = await sut.GetIssuerByKeyFingerprintAsync(expectedFingerprint, CancellationToken.None); + byFingerprint.Should().NotBeNull(); + byFingerprint!.IssuerId.Should().Be("vendor-ubuntu"); + + var trust = await sut.ValidateTrustAsync("vendor-ubuntu", expectedFingerprint, CancellationToken.None); + trust.IsTrusted.Should().BeTrue(); + trust.EffectiveTrustTier.Should().Be(TrustTier.Trusted); + trust.IssuerStatus.Should().Be(IssuerTrustStatus.Trusted); + trust.KeyStatus.Should().Be(KeyTrustStatus.Valid); + } + + [Fact] + public async Task RevokeIssuerAsync_MapsStrongNegativeTrustOverrideToRevoked() + { + var fixture = new AuthorityFixture(); + var sut = fixture.CreateAdapter(); + + await sut.RegisterIssuerAsync( + new IssuerRegistration( + IssuerId: "vendor-redhat", + Name: "Red Hat", + Category: IssuerCategory.Vendor, + TrustTier: TrustTier.Trusted, + InitialKeys: null, + Metadata: null), + CancellationToken.None); + + var revoked = await sut.RevokeIssuerAsync("vendor-redhat", "compromised", CancellationToken.None); + revoked.Should().BeTrue(); + + var trust = await sut.ValidateTrustAsync("vendor-redhat", keyFingerprint: null, CancellationToken.None); + trust.IsTrusted.Should().BeFalse(); + trust.EffectiveTrustTier.Should().Be(TrustTier.Untrusted); + trust.IssuerStatus.Should().Be(IssuerTrustStatus.Revoked); + trust.Warnings.Should().ContainSingle(warning => warning.Contains("revoked", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task AddKeyFingerprintAsync_RejectsMissingKeyMaterial() + { + var sut = new AuthorityFixture().CreateAdapter(); + + var action = () => sut.AddKeyFingerprintAsync( + "issuer-1", + new KeyFingerprintRegistration( + Fingerprint: "fp-1", + KeyType: KeyType.Sigstore, + Algorithm: null, + ExpiresAt: null, + PublicKey: null, + PublicKeyFormat: "base64"), + CancellationToken.None); + + await action.Should() + .ThrowAsync() + .WithMessage("*requires key material*"); + } + + [Fact] + public async Task ListIssuersAsync_ResolvesTenantSlugAndReturnsPublicIssuerIdentifiers() + { + var fixture = new AuthorityFixture(); + fixture.SeedIssuer( + publicIssuerId: "vendor-suse", + displayName: "SUSE Security", + category: IssuerCategory.Vendor); + + var issuers = await fixture.CreateAdapter().ListIssuersAsync(cancellationToken: CancellationToken.None); + + issuers.Should().ContainSingle(); + issuers[0].IssuerId.Should().Be("vendor-suse"); + issuers[0].Name.Should().Be("SUSE Security"); + issuers[0].Category.Should().Be(IssuerCategory.Vendor); + } + + private sealed class AuthorityFixture + { + private static readonly Guid TenantGuid = Guid.Parse("10000000-0000-0000-0000-000000000001"); + private readonly FakeIssuerRepository _issuerRepository = new(); + private readonly FakeIssuerKeyRepository _keyRepository = new(); + private readonly FakeIssuerTrustRepository _trustRepository = new(); + private readonly FakeIssuerAuditSink _auditSink = new(); + private readonly FakeTenantRepository _tenantRepository = new(); + private readonly SequentialGuidProvider _guidProvider = new(Guid.Parse("20000000-0000-0000-0000-000000000000")); + private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2026-04-14T12:00:00Z")); + private readonly FakeTenantAccessor _tenantAccessor = new("tenant-a", "tester"); + + public DateTimeOffset Now => _timeProvider.GetUtcNow(); + + public void SeedIssuer(string publicIssuerId, string displayName, IssuerCategory category) + { + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["vexlens.category"] = category.ToString(), + ["vexlens.publicIssuerId"] = publicIssuerId + }; + + _issuerRepository.Seed( + AuthorityIssuerRecord.Create( + id: Guid.Parse("30000000-0000-0000-0000-000000000001").ToString(), + tenantId: TenantGuid.ToString(), + displayName: displayName, + slug: publicIssuerId, + description: null, + contact: new IssuerContact(null, null, null, null), + metadata: new StellaOps.IssuerDirectory.Core.Domain.IssuerMetadata(null, publicIssuerId, null, null, Array.Empty(), attributes), + endpoints: null, + tags: Array.Empty(), + timestampUtc: Now, + actor: "seed", + isSystemSeed: false)); + } + + public AuthorityIssuerDirectoryAdapter CreateAdapter() + { + return new AuthorityIssuerDirectoryAdapter( + _tenantAccessor, + new IssuerDirectoryService(_issuerRepository, _auditSink, _timeProvider, NullLogger.Instance), + new IssuerKeyService(_issuerRepository, _keyRepository, _auditSink, _timeProvider, NullLogger.Instance), + new IssuerTrustService(_issuerRepository, _trustRepository, _auditSink, _timeProvider), + _keyRepository, + _tenantRepository, + _guidProvider, + _timeProvider, + NullLogger.Instance); + } + } + + private sealed class FakeTenantAccessor : IStellaOpsTenantAccessor + { + public FakeTenantAccessor(string tenantId, string actorId) + { + TenantContext = new StellaOpsTenantContext + { + TenantId = tenantId, + ActorId = actorId + }; + } + + public StellaOpsTenantContext? TenantContext { get; set; } + } + + private sealed class FakeIssuerRepository : IIssuerRepository + { + private readonly ConcurrentDictionary<(string TenantId, string IssuerId), AuthorityIssuerRecord> _store = new(); + + public void Seed(AuthorityIssuerRecord record) + { + _store[(record.TenantId, record.Id)] = record; + } + + public Task GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken) + { + _store.TryGetValue((tenantId, issuerId), out var record); + return Task.FromResult(record); + } + + public Task> ListAsync(string tenantId, CancellationToken cancellationToken) + { + var records = _store.Values + .Where(record => string.Equals(record.TenantId, tenantId, StringComparison.Ordinal)) + .ToArray(); + return Task.FromResult>(records); + } + + public Task> ListGlobalAsync(CancellationToken cancellationToken) + { + var records = _store.Values + .Where(record => string.Equals(record.TenantId, IssuerTenants.Global, StringComparison.Ordinal)) + .ToArray(); + return Task.FromResult>(records); + } + + public Task UpsertAsync(AuthorityIssuerRecord record, CancellationToken cancellationToken) + { + _store[(record.TenantId, record.Id)] = record; + return Task.CompletedTask; + } + + public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken) + { + _store.TryRemove((tenantId, issuerId), out _); + return Task.CompletedTask; + } + } + + private sealed class FakeTenantRepository : ITenantRepository + { + private readonly TenantEntity _tenant = new() + { + Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), + Slug = "tenant-a", + Name = "Tenant A", + Description = "Tenant A", + ContactEmail = null, + Enabled = true, + Settings = "{}", + Metadata = "{}", + CreatedAt = DateTimeOffset.Parse("2026-04-14T12:00:00Z"), + UpdatedAt = DateTimeOffset.Parse("2026-04-14T12:00:00Z"), + CreatedBy = "seed" + }; + + public Task CreateAsync(TenantEntity tenant, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return Task.FromResult(id == _tenant.Id ? _tenant : null); + } + + public Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default) + { + return Task.FromResult(string.Equals(slug, _tenant.Slug, StringComparison.Ordinal) ? _tenant : null); + } + + public Task> GetAllAsync(bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default) + { + IReadOnlyList tenants = [_tenant]; + return Task.FromResult(tenants); + } + + public Task UpdateAsync(TenantEntity tenant, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task SlugExistsAsync(string slug, CancellationToken cancellationToken = default) + { + return Task.FromResult(string.Equals(slug, _tenant.Slug, StringComparison.Ordinal)); + } + } + + private sealed class FakeIssuerKeyRepository : IIssuerKeyRepository + { + private readonly ConcurrentDictionary<(string TenantId, string IssuerId, string KeyId), IssuerKeyRecord> _store = new(); + + public Task GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken) + { + _store.TryGetValue((tenantId, issuerId, keyId), out var record); + return Task.FromResult(record); + } + + public Task GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken) + { + var record = _store.Values.FirstOrDefault(candidate => + string.Equals(candidate.TenantId, tenantId, StringComparison.Ordinal) && + string.Equals(candidate.IssuerId, issuerId, StringComparison.Ordinal) && + string.Equals(candidate.Fingerprint, fingerprint, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(record); + } + + public Task> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken) + { + var records = _store.Values + .Where(candidate => + string.Equals(candidate.TenantId, tenantId, StringComparison.Ordinal) && + string.Equals(candidate.IssuerId, issuerId, StringComparison.Ordinal)) + .ToArray(); + return Task.FromResult>(records); + } + + public Task> ListGlobalAsync(string issuerId, CancellationToken cancellationToken) + { + var records = _store.Values + .Where(candidate => + string.Equals(candidate.TenantId, IssuerTenants.Global, StringComparison.Ordinal) && + string.Equals(candidate.IssuerId, issuerId, StringComparison.Ordinal)) + .ToArray(); + return Task.FromResult>(records); + } + + public Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken) + { + _store[(record.TenantId, record.IssuerId, record.Id)] = record; + return Task.CompletedTask; + } + } + + private sealed class FakeIssuerTrustRepository : IIssuerTrustRepository + { + private readonly ConcurrentDictionary<(string TenantId, string IssuerId), IssuerTrustOverrideRecord> _store = new(); + + public Task GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken) + { + _store.TryGetValue((tenantId, issuerId), out var record); + return Task.FromResult(record); + } + + public Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken) + { + _store[(record.TenantId, record.IssuerId)] = record; + return Task.CompletedTask; + } + + public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken) + { + _store.TryRemove((tenantId, issuerId), out _); + return Task.CompletedTask; + } + } + + private sealed class FakeIssuerAuditSink : IIssuerAuditSink + { + public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/Services/VexHubStatementProviderTests.cs b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/Services/VexHubStatementProviderTests.cs new file mode 100644 index 000000000..ba308fbeb --- /dev/null +++ b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/Services/VexHubStatementProviderTests.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using StellaOps.VexHub.Core; +using StellaOps.VexHub.Core.Models; +using StellaOps.VexLens.Models; +using StellaOps.VexLens.Verification; +using StellaOps.VexLens.WebService.Services; + +namespace StellaOps.VexLens.WebService.Tests.Services; + +public sealed class VexHubStatementProviderTests +{ + [Fact] + public async Task GetStatementsAsync_MapsHubStatementsAndCachesSourceLookup() + { + var source = new VexSource + { + SourceId = "ubuntu-security", + Name = "Ubuntu Security Notices", + SourceFormat = VexSourceFormat.OpenVex, + IssuerCategory = IssuerCategory.Vendor, + TrustTier = TrustTier.Trusted, + CreatedAt = DateTimeOffset.Parse("2026-04-14T10:00:00Z") + }; + + var repository = new FakeVexStatementRepository( + [ + CreateStatement( + id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + statementId: "stmt-older", + sourceId: source.SourceId, + documentId: "doc-1", + verificationStatus: VerificationStatus.Failed, + issuedAt: DateTimeOffset.Parse("2026-04-14T10:05:00Z"), + digest: "sha256:older"), + CreateStatement( + id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + statementId: "stmt-newer", + sourceId: source.SourceId, + documentId: "doc-2", + verificationStatus: VerificationStatus.Verified, + issuedAt: DateTimeOffset.Parse("2026-04-14T10:10:00Z"), + digest: "sha256:newer") + ]); + var sourceRepository = new FakeVexSourceRepository(source); + var sut = new VexHubStatementProvider(repository, sourceRepository); + + var result = await sut.GetStatementsAsync( + "CVE-2026-0001", + "pkg:deb/ubuntu/openssl@3.0.2", + tenantId: "tenant-a", + CancellationToken.None); + + result.Should().HaveCount(2); + result.Select(item => item.Statement.StatementId).Should().ContainInOrder("stmt-newer", "stmt-older"); + result[0].Issuer.Should().NotBeNull(); + result[0].Issuer!.Id.Should().Be(source.SourceId); + result[0].Issuer.Name.Should().Be(source.Name); + result[0].Issuer.Category.Should().Be(source.IssuerCategory); + result[0].Issuer.TrustTier.Should().Be(source.TrustTier); + result[0].SignatureVerification.Should().NotBeNull(); + result[0].SignatureVerification!.Status.Should().Be(SignatureVerificationStatus.Valid); + result[1].SignatureVerification.Should().NotBeNull(); + result[1].SignatureVerification!.Status.Should().Be(SignatureVerificationStatus.InvalidSignature); + repository.LastFilter.Should().NotBeNull(); + repository.LastFilter!.VulnerabilityId.Should().Be("CVE-2026-0001"); + repository.LastFilter.ProductKey.Should().Be("pkg:deb/ubuntu/openssl@3.0.2"); + sourceRepository.GetByIdCallCount.Should().Be(1); + } + + private static AggregatedVexStatement CreateStatement( + Guid id, + string statementId, + string sourceId, + string documentId, + VerificationStatus verificationStatus, + DateTimeOffset issuedAt, + string digest) + { + return new AggregatedVexStatement + { + Id = id, + SourceStatementId = statementId, + SourceId = sourceId, + SourceDocumentId = documentId, + VulnerabilityId = "CVE-2026-0001", + ProductKey = "pkg:deb/ubuntu/openssl@3.0.2", + Status = VexStatus.NotAffected, + Justification = VexJustification.VulnerableCodeNotPresent, + VerificationStatus = verificationStatus, + VerifiedAt = verificationStatus == VerificationStatus.Verified + ? issuedAt.AddMinutes(1) + : null, + SigningKeyFingerprint = "fingerprint-1", + IngestedAt = issuedAt.AddMinutes(2), + UpdatedAt = issuedAt.AddMinutes(3), + IssuedAt = issuedAt, + ContentDigest = digest + }; + } + + private sealed class FakeVexStatementRepository : IVexStatementRepository + { + private readonly IReadOnlyList _statements; + + public FakeVexStatementRepository(IReadOnlyList statements) + { + _statements = statements; + } + + public VexStatementFilter? LastFilter { get; private set; } + + public Task> SearchAsync( + VexStatementFilter filter, + int? limit = null, + int? offset = null, + CancellationToken cancellationToken = default) + { + LastFilter = filter; + return Task.FromResult(_statements); + } + + public Task UpsertAsync(AggregatedVexStatement statement, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkUpsertAsync(IEnumerable statements, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetByCveAsync(string cveId, int? limit = null, int? offset = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetByPackageAsync(string purl, int? limit = null, int? offset = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetBySourceAsync(string sourceId, int? limit = null, int? offset = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ExistsByDigestAsync(string contentDigest, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetCountAsync(VexStatementFilter? filter = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task FlagStatementAsync(Guid id, string reason, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DeleteBySourceAsync(string sourceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class FakeVexSourceRepository : IVexSourceRepository + { + private readonly VexSource _source; + + public FakeVexSourceRepository(VexSource source) + { + _source = source; + } + + public int GetByIdCallCount { get; private set; } + + public Task GetByIdAsync(string sourceId, CancellationToken cancellationToken = default) + { + GetByIdCallCount++; + return Task.FromResult(_source); + } + + public Task AddAsync(VexSource source, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task UpdateAsync(VexSource source, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetAllAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetDueForPollingAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task UpdateLastPolledAsync(string sourceId, DateTimeOffset timestamp, string? errorMessage = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task UpdateFailureTrackingAsync(string sourceId, int consecutiveFailures, DateTimeOffset? nextEligiblePollAt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DeleteAsync(string sourceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +} diff --git a/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/StellaOps.VexLens.WebService.Tests.csproj b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/StellaOps.VexLens.WebService.Tests.csproj new file mode 100644 index 000000000..f007ff94b --- /dev/null +++ b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/StellaOps.VexLens.WebService.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + preview + enable + enable + true + false + StellaOps.VexLens.WebService.Tests + + + + + + + + + + + + diff --git a/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/VexLensTestSecurity.cs b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/VexLensTestSecurity.cs new file mode 100644 index 000000000..7643bd2e8 --- /dev/null +++ b/src/VexLens/__Tests/StellaOps.VexLens.WebService.Tests/VexLensTestSecurity.cs @@ -0,0 +1,70 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.VexLens.WebService.Tests; + +internal static class VexLensTestSecurity +{ + public static void Configure(IServiceCollection services) + { + services.AddAuthentication(TestAuthHandler.SchemeName) + .AddScheme( + TestAuthHandler.SchemeName, + _ => { }); + + services.PostConfigureAll(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + options.DefaultScheme = TestAuthHandler.SchemeName; + }); + + services.RemoveAll(); + services.AddSingleton(); + } + + private sealed class TestAuthHandler : AuthenticationHandler + { + public const string SchemeName = "VexLensTestScheme"; + + public TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim("scope", "vexlens.read vexlens.write"), + new Claim("scp", "vexlens.read vexlens.write") + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName)); + var ticket = new AuthenticationTicket(principal, SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } + + private sealed class AllowAllAuthorizationHandler : IAuthorizationHandler + { + public Task HandleAsync(AuthorizationHandlerContext context) + { + foreach (var requirement in context.PendingRequirements.ToList()) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +}