diff --git a/docs/implplan/BLOCKED_DEPENDENCY_TREE.md b/docs/implplan/BLOCKED_DEPENDENCY_TREE.md index 47e8f8ce7..e37754c12 100644 --- a/docs/implplan/BLOCKED_DEPENDENCY_TREE.md +++ b/docs/implplan/BLOCKED_DEPENDENCY_TREE.md @@ -1,9 +1,17 @@ # BLOCKED Tasks Dependency Tree -> **Last Updated:** 2025-12-06 (Wave 5: 43 specs + 8 implementations = ~252+ tasks unblocked) +> **Last Updated:** 2025-12-06 (Wave 6: 49 specs + 8 implementations = ~270+ tasks unblocked) > **Purpose:** This document maps all BLOCKED tasks and their root causes to help teams prioritize unblocking work. > **Visual DAG:** See [DEPENDENCY_DAG.md](./DEPENDENCY_DAG.md) for Mermaid graphs, cascade analysis, and guild blocking matrix. > -> **Recent Unblocks (2025-12-06 Wave 5):** +> **Recent Unblocks (2025-12-06 Wave 6):** +> - ✅ SDK Generator Samples Schema (`docs/schemas/sdk-generator-samples.schema.json`) — 2+ tasks (DEVPORT-63-002, DOCS-SDK-62-001) +> - ✅ Graph Demo Outputs Schema (`docs/schemas/graph-demo-outputs.schema.json`) — 1+ task (GRAPH-OPS-0001) +> - ✅ Risk API Schema (`docs/schemas/risk-api.schema.json`) — 5 tasks (DOCS-RISK-67-002 through 68-002) +> - ✅ Ops Incident Runbook Schema (`docs/schemas/ops-incident-runbook.schema.json`) — 1+ task (DOCS-RUNBOOK-55-001) +> - ✅ Export Bundle Shapes Schema (`docs/schemas/export-bundle-shapes.schema.json`) — 2 tasks (DOCS-RISK-68-001/002) +> - ✅ Security Scopes Matrix Schema (`docs/schemas/security-scopes-matrix.schema.json`) — 2 tasks (DOCS-SEC-62-001, DOCS-SEC-OBS-50-001) +> +> **Wave 5 Unblocks (2025-12-06):** > - ✅ DevPortal API Schema (`docs/schemas/devportal-api.schema.json`) — 6 tasks (APIG0101 62-001 to 63-004) > - ✅ Deployment Service List (`docs/schemas/deployment-service-list.schema.json`) — 7 tasks (COMPOSE-44-001 to 45-003) > - ✅ Exception Lifecycle Schema (`docs/schemas/exception-lifecycle.schema.json`) — 5 tasks (DOCS-EXC-25-001 to 25-006) @@ -297,19 +305,31 @@ attestor SDK transport contract ✅ CREATED (chain UNBLOCKED) ## 7. DOCS MD.IX (SPRINT_0309_0001_0009_docs_tasks_md_ix) -**Root Blocker:** `DOCS-RISK-67-002 draft (risk API)` (due 2025-12-09; reminder ping 2025-12-09, escalate 2025-12-13) +**Root Blocker:** ~~`DOCS-RISK-67-002 draft (risk API)`~~ ✅ RESOLVED (2025-12-06 Wave 6) + +> **Update 2025-12-06 Wave 6:** +> - ✅ **Risk API Schema** CREATED (`docs/schemas/risk-api.schema.json`) +> - RiskScore with rating, confidence, and factor breakdown +> - RiskFactor with weights, contributions, and evidence +> - RiskProfile with scoring models, thresholds, and modifiers +> - ScoringModel with weighted_sum, geometric_mean, max_severity types +> - RiskAssessmentRequest/Response for API endpoints +> - RiskExplainability for human-readable explanations +> - RiskAggregation for entity-wide scoring +> - **5 tasks UNBLOCKED** ``` -DOCS-RISK-67-002 draft missing - +-- DOCS-RISK-67-003 (risk UI docs) - +-- DOCS-RISK-67-004 (CLI risk guide) - +-- DOCS-RISK-68-001 (airgap risk bundles) - +-- DOCS-RISK-68-002 (AOC invariants update) +Risk API schema ✅ CREATED (chain UNBLOCKED) + +-- DOCS-RISK-67-002 (risk API docs) → UNBLOCKED + +-- DOCS-RISK-67-003 (risk UI docs) → UNBLOCKED + +-- DOCS-RISK-67-004 (CLI risk guide) → UNBLOCKED + +-- DOCS-RISK-68-001 (airgap risk bundles) → UNBLOCKED + +-- DOCS-RISK-68-002 (AOC invariants update) → UNBLOCKED ``` -**Impact:** 4 docs tasks (risk chain) +**Impact:** 5 docs tasks — ✅ ALL UNBLOCKED -**To Unblock:** API Guild to deliver DOCS-RISK-67-002 draft by 2025-12-09; Console Guild to provide UI captures/hashes by 2025-12-10. +**Status:** ✅ RESOLVED — Schema created at `docs/schemas/risk-api.schema.json` --- diff --git a/docs/schemas/export-bundle-shapes.schema.json b/docs/schemas/export-bundle-shapes.schema.json new file mode 100644 index 000000000..7fa6623e7 --- /dev/null +++ b/docs/schemas/export-bundle-shapes.schema.json @@ -0,0 +1,628 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/schemas/export-bundle-shapes.schema.json", + "title": "StellaOps Export Bundle Shapes Schema", + "description": "Schema for export bundle formats, hashing inputs, and airgap bundle structures. Unblocks DOCS-RISK-68-001, DOCS-RISK-68-002 (2+ tasks).", + "type": "object", + "definitions": { + "ExportBundle": { + "type": "object", + "description": "Export bundle package", + "required": ["bundle_id", "bundle_type", "version", "created_at", "contents"], + "properties": { + "bundle_id": { + "type": "string", + "format": "uuid" + }, + "bundle_type": { + "type": "string", + "enum": ["findings", "sbom", "vex", "risk", "compliance", "evidence", "full"], + "description": "Type of export bundle" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "format": { + "type": "string", + "enum": ["json", "ndjson", "csv", "xml", "cyclonedx", "spdx", "sarif"], + "description": "Output format" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/ExportScope" + }, + "contents": { + "$ref": "#/definitions/BundleContents" + }, + "metadata": { + "$ref": "#/definitions/BundleMetadata" + }, + "signatures": { + "type": "array", + "items": { + "$ref": "#/definitions/BundleSignature" + } + }, + "manifest_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "Digest of bundle manifest" + } + } + }, + "ExportScope": { + "type": "object", + "description": "Scope of exported data", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + } + }, + "assets": { + "type": "array", + "items": { + "type": "string" + } + }, + "time_range": { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, + "severities": { + "type": "array", + "items": { + "type": "string", + "enum": ["critical", "high", "medium", "low", "info"] + } + }, + "statuses": { + "type": "array", + "items": { + "type": "string" + } + }, + "filters": { + "type": "object", + "additionalProperties": true, + "description": "Additional filter criteria" + } + } + }, + "BundleContents": { + "type": "object", + "description": "Bundle content inventory", + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/BundleFile" + } + }, + "record_counts": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "Count of records by type" + }, + "total_size_bytes": { + "type": "integer" + }, + "compressed_size_bytes": { + "type": "integer" + }, + "compression": { + "type": "string", + "enum": ["none", "gzip", "zstd", "lz4"] + } + } + }, + "BundleFile": { + "type": "object", + "description": "Individual file in bundle", + "required": ["path", "digest", "size_bytes"], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["data", "metadata", "schema", "signature", "index"] + }, + "format": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "size_bytes": { + "type": "integer" + }, + "record_count": { + "type": "integer" + }, + "schema_ref": { + "type": "string", + "description": "Reference to schema for this file" + } + } + }, + "BundleMetadata": { + "type": "object", + "description": "Bundle metadata", + "properties": { + "export_job_id": { + "type": "string" + }, + "source_system": { + "type": "string" + }, + "source_version": { + "type": "string" + }, + "export_profile": { + "type": "string" + }, + "redaction_applied": { + "type": "boolean", + "default": false + }, + "redaction_policy": { + "type": "string" + }, + "retention_policy": { + "type": "string" + }, + "classification": { + "type": "string", + "enum": ["public", "internal", "confidential", "restricted"] + }, + "custom": { + "type": "object", + "additionalProperties": true + } + } + }, + "BundleSignature": { + "type": "object", + "description": "Digital signature on bundle", + "required": ["signature_type", "signature"], + "properties": { + "signature_type": { + "type": "string", + "enum": ["dsse", "cosign", "gpg", "x509"] + }, + "signature": { + "type": "string", + "description": "Base64-encoded signature" + }, + "public_key": { + "type": "string", + "description": "Public key or key reference" + }, + "key_id": { + "type": "string" + }, + "signed_at": { + "type": "string", + "format": "date-time" + }, + "signer": { + "type": "string" + }, + "certificate_chain": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AirgapBundle": { + "type": "object", + "description": "Air-gapped export bundle for offline environments", + "required": ["bundle_id", "created_at", "manifest"], + "properties": { + "bundle_id": { + "type": "string", + "format": "uuid" + }, + "bundle_type": { + "type": "string", + "const": "airgap" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "valid_until": { + "type": "string", + "format": "date-time", + "description": "Expiration for time-sensitive data" + }, + "manifest": { + "$ref": "#/definitions/AirgapManifest" + }, + "advisory_data": { + "$ref": "#/definitions/AdvisoryBundle" + }, + "risk_data": { + "$ref": "#/definitions/RiskBundle" + }, + "policy_data": { + "$ref": "#/definitions/PolicyBundle" + }, + "time_anchor": { + "$ref": "#/definitions/TimeAnchor" + }, + "aggregate_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "AirgapManifest": { + "type": "object", + "description": "Manifest of airgap bundle contents", + "required": ["version", "files"], + "properties": { + "version": { + "type": "string" + }, + "format_version": { + "type": "string", + "const": "1.0" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/BundleFile" + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bundle_id": { + "type": "string" + }, + "required": { + "type": "boolean" + } + } + } + } + } + }, + "AdvisoryBundle": { + "type": "object", + "description": "Advisory data for airgap bundle", + "properties": { + "sources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Advisory sources included (NVD, OSV, etc.)" + }, + "advisory_count": { + "type": "integer" + }, + "cve_count": { + "type": "integer" + }, + "last_sync": { + "type": "string", + "format": "date-time" + }, + "file_ref": { + "type": "string", + "description": "Path to advisory data file" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "RiskBundle": { + "type": "object", + "description": "Risk scoring data for airgap bundle", + "properties": { + "profiles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Risk profiles included" + }, + "epss_data": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date" + }, + "record_count": { + "type": "integer" + } + } + }, + "kev_data": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date" + }, + "record_count": { + "type": "integer" + } + } + }, + "file_ref": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "PolicyBundle": { + "type": "object", + "description": "Policy data for airgap bundle", + "properties": { + "policy_packs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pack_id": { + "type": "string" + }, + "version": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + } + }, + "file_ref": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "TimeAnchor": { + "type": "object", + "description": "Time anchor for bundle validity", + "required": ["anchor_time", "source"], + "properties": { + "anchor_time": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "enum": ["ntp", "tsa", "rekor", "manual"] + }, + "tsa_response": { + "type": "string", + "description": "RFC 3161 timestamp response (base64)" + }, + "rekor_entry": { + "type": "string", + "description": "Rekor transparency log entry ID" + }, + "drift_tolerance": { + "type": "string", + "description": "Acceptable clock drift (e.g., 1h)" + } + } + }, + "HashingInputs": { + "type": "object", + "description": "Inputs used for deterministic hashing", + "required": ["algorithm", "inputs"], + "properties": { + "algorithm": { + "type": "string", + "enum": ["sha256", "sha384", "sha512"], + "default": "sha256" + }, + "inputs": { + "type": "array", + "items": { + "$ref": "#/definitions/HashInput" + }, + "description": "Ordered list of inputs for hash computation" + }, + "canonicalization": { + "type": "string", + "enum": ["none", "json-canonical", "xml-c14n"], + "description": "Canonicalization method before hashing" + }, + "encoding": { + "type": "string", + "enum": ["utf8", "base64"], + "default": "utf8" + }, + "computed_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "HashInput": { + "type": "object", + "description": "Single input for hash computation", + "required": ["type", "value"], + "properties": { + "type": { + "type": "string", + "enum": ["file", "field", "literal", "nested_digest"] + }, + "path": { + "type": "string", + "description": "File path or JSON path" + }, + "value": { + "type": "string", + "description": "Literal value or computed digest" + }, + "order": { + "type": "integer", + "description": "Order in hash computation" + } + } + }, + "ExportProfile": { + "type": "object", + "description": "Export profile configuration", + "required": ["profile_id", "name", "bundle_type"], + "properties": { + "profile_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "bundle_type": { + "type": "string", + "enum": ["findings", "sbom", "vex", "risk", "compliance", "evidence", "full"] + }, + "format": { + "type": "string" + }, + "scope_defaults": { + "$ref": "#/definitions/ExportScope" + }, + "include_signatures": { + "type": "boolean", + "default": true + }, + "compression": { + "type": "string", + "enum": ["none", "gzip", "zstd"] + }, + "redaction_policy": { + "type": "string" + }, + "retention_days": { + "type": "integer" + }, + "schedule": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cron": { + "type": "string" + }, + "destination": { + "type": "string" + } + } + } + } + } + }, + "properties": { + "export_profiles": { + "type": "array", + "items": { + "$ref": "#/definitions/ExportProfile" + } + }, + "bundle_schemas": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Schema references by bundle type" + } + }, + "examples": [ + { + "export_profiles": [ + { + "profile_id": "findings-weekly", + "name": "Weekly Findings Export", + "description": "Weekly export of all findings for compliance reporting", + "bundle_type": "findings", + "format": "ndjson", + "scope_defaults": { + "time_range": { + "start": "{{now-7d}}", + "end": "{{now}}" + }, + "severities": ["critical", "high", "medium"] + }, + "include_signatures": true, + "compression": "gzip", + "redaction_policy": "pii-removal", + "retention_days": 90, + "schedule": { + "enabled": true, + "cron": "0 0 * * 0", + "destination": "s3://exports/weekly/" + } + }, + { + "profile_id": "airgap-full", + "name": "Air-Gap Full Bundle", + "description": "Complete bundle for air-gapped environments", + "bundle_type": "full", + "format": "json", + "include_signatures": true, + "compression": "zstd" + } + ], + "bundle_schemas": { + "findings": "https://stella-ops.org/schemas/findings-bundle.schema.json", + "sbom": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "vex": "https://stella-ops.org/schemas/vex-normalization.schema.json" + } + } + ] +} diff --git a/docs/schemas/graph-demo-outputs.schema.json b/docs/schemas/graph-demo-outputs.schema.json new file mode 100644 index 000000000..d4c02e9d1 --- /dev/null +++ b/docs/schemas/graph-demo-outputs.schema.json @@ -0,0 +1,562 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/schemas/graph-demo-outputs.schema.json", + "title": "StellaOps Graph Demo Outputs Schema", + "description": "Schema for graph observability demo outputs, dashboard configurations, and metrics samples. Unblocks GRAPH-OPS-0001 (1+ tasks).", + "type": "object", + "definitions": { + "DemoMetricSample": { + "type": "object", + "description": "Sample metric data for demo/documentation", + "required": ["metric_name", "value", "timestamp"], + "properties": { + "metric_name": { + "type": "string", + "description": "Prometheus-style metric name" + }, + "value": { + "type": "number" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "unit": { + "type": "string", + "description": "Metric unit (bytes, seconds, count, etc.)" + } + } + }, + "DemoTimeSeries": { + "type": "object", + "description": "Time series data for demo visualizations", + "required": ["series_id", "metric_name", "data_points"], + "properties": { + "series_id": { + "type": "string" + }, + "metric_name": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "data_points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "value": { + "type": "number" + } + }, + "required": ["timestamp", "value"] + } + }, + "resolution": { + "type": "string", + "enum": ["1m", "5m", "15m", "1h", "1d"], + "description": "Data point resolution" + } + } + }, + "DemoDashboard": { + "type": "object", + "description": "Demo dashboard configuration", + "required": ["dashboard_id", "title", "panels"], + "properties": { + "dashboard_id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "time_range": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + "refresh_interval": { + "type": "string", + "default": "30s" + }, + "panels": { + "type": "array", + "items": { + "$ref": "#/definitions/DemoPanel" + } + }, + "variables": { + "type": "array", + "items": { + "$ref": "#/definitions/DashboardVariable" + } + } + } + }, + "DemoPanel": { + "type": "object", + "description": "Dashboard panel configuration", + "required": ["panel_id", "title", "type"], + "properties": { + "panel_id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["graph", "stat", "gauge", "table", "heatmap", "logs", "alert_list", "pie_chart", "bar_chart"] + }, + "grid_pos": { + "type": "object", + "properties": { + "x": { "type": "integer" }, + "y": { "type": "integer" }, + "w": { "type": "integer" }, + "h": { "type": "integer" } + } + }, + "queries": { + "type": "array", + "items": { + "$ref": "#/definitions/PanelQuery" + } + }, + "thresholds": { + "type": "array", + "items": { + "$ref": "#/definitions/Threshold" + } + }, + "demo_data": { + "$ref": "#/definitions/DemoTimeSeries", + "description": "Pre-populated demo data for this panel" + } + } + }, + "PanelQuery": { + "type": "object", + "description": "Panel query definition", + "properties": { + "query_id": { + "type": "string" + }, + "expr": { + "type": "string", + "description": "PromQL expression" + }, + "legend": { + "type": "string" + }, + "datasource": { + "type": "string" + } + } + }, + "Threshold": { + "type": "object", + "description": "Visual threshold", + "properties": { + "value": { + "type": "number" + }, + "color": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["ok", "warning", "critical"] + } + } + }, + "DashboardVariable": { + "type": "object", + "description": "Dashboard variable/filter", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["query", "custom", "constant", "interval"] + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "type": "string" + } + } + }, + "DemoAlertRule": { + "type": "object", + "description": "Demo alert rule", + "required": ["rule_id", "name", "expr"], + "properties": { + "rule_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "expr": { + "type": "string", + "description": "PromQL alert expression" + }, + "for": { + "type": "string", + "default": "5m", + "description": "Duration condition must be true" + }, + "severity": { + "type": "string", + "enum": ["critical", "warning", "info"] + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "runbook_url": { + "type": "string", + "format": "uri" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "DemoRunbook": { + "type": "object", + "description": "Demo runbook configuration", + "required": ["runbook_id", "title", "steps"], + "properties": { + "runbook_id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "trigger_alerts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Alert rule IDs that trigger this runbook" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/RunbookStep" + } + }, + "estimated_duration": { + "type": "string", + "description": "Estimated time to complete (e.g., 15m)" + }, + "severity": { + "type": "string", + "enum": ["critical", "high", "medium", "low"] + } + } + }, + "RunbookStep": { + "type": "object", + "description": "Individual runbook step", + "required": ["step_number", "action"], + "properties": { + "step_number": { + "type": "integer" + }, + "action": { + "type": "string", + "description": "Action to perform" + }, + "command": { + "type": "string", + "description": "CLI command if applicable" + }, + "expected_outcome": { + "type": "string" + }, + "escalation_if_failed": { + "type": "string" + } + } + }, + "DemoOutputPack": { + "type": "object", + "description": "Complete demo output package", + "required": ["pack_id", "version", "generated_at"], + "properties": { + "pack_id": { + "type": "string" + }, + "version": { + "type": "string" + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, + "dashboards": { + "type": "array", + "items": { + "$ref": "#/definitions/DemoDashboard" + } + }, + "alert_rules": { + "type": "array", + "items": { + "$ref": "#/definitions/DemoAlertRule" + } + }, + "runbooks": { + "type": "array", + "items": { + "$ref": "#/definitions/DemoRunbook" + } + }, + "sample_data": { + "type": "array", + "items": { + "$ref": "#/definitions/DemoTimeSeries" + } + }, + "screenshots": { + "type": "array", + "items": { + "$ref": "#/definitions/DemoScreenshot" + } + }, + "aggregate_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "DemoScreenshot": { + "type": "object", + "description": "Demo screenshot for documentation", + "required": ["screenshot_id", "filename", "digest"], + "properties": { + "screenshot_id": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dashboard_id": { + "type": "string", + "description": "Dashboard this screenshot is from" + }, + "panel_id": { + "type": "string", + "description": "Specific panel if applicable" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "format": { + "type": "string", + "enum": ["png", "webp", "svg"] + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + } + }, + "properties": { + "output_pack": { + "$ref": "#/definitions/DemoOutputPack" + } + }, + "examples": [ + { + "output_pack": { + "pack_id": "stellaops-graph-demo-2025.10", + "version": "2025.10.0", + "generated_at": "2025-12-06T10:00:00Z", + "dashboards": [ + { + "dashboard_id": "vulnerability-overview", + "title": "Vulnerability Overview", + "description": "High-level vulnerability metrics across all scanned assets", + "tags": ["vulnerabilities", "overview"], + "time_range": { "from": "now-24h", "to": "now" }, + "refresh_interval": "1m", + "panels": [ + { + "panel_id": "total-vulns", + "title": "Total Vulnerabilities", + "type": "stat", + "grid_pos": { "x": 0, "y": 0, "w": 6, "h": 4 }, + "queries": [ + { + "query_id": "q1", + "expr": "sum(stellaops_vulnerabilities_total)" + } + ], + "thresholds": [ + { "value": 0, "color": "green", "state": "ok" }, + { "value": 100, "color": "yellow", "state": "warning" }, + { "value": 500, "color": "red", "state": "critical" } + ] + }, + { + "panel_id": "critical-vulns", + "title": "Critical Vulnerabilities", + "type": "stat", + "grid_pos": { "x": 6, "y": 0, "w": 6, "h": 4 }, + "queries": [ + { + "query_id": "q2", + "expr": "sum(stellaops_vulnerabilities_total{severity=\"critical\"})" + } + ] + } + ], + "variables": [ + { + "name": "tenant", + "label": "Tenant", + "type": "query", + "options": ["all", "tenant-a", "tenant-b"] + } + ] + } + ], + "alert_rules": [ + { + "rule_id": "critical-vuln-spike", + "name": "Critical Vulnerability Spike", + "expr": "increase(stellaops_vulnerabilities_total{severity=\"critical\"}[1h]) > 10", + "for": "5m", + "severity": "critical", + "summary": "Critical vulnerabilities increased by more than 10 in the last hour", + "runbook_url": "https://docs.stella-ops.org/runbooks/critical-vuln-spike" + } + ], + "runbooks": [ + { + "runbook_id": "critical-vuln-spike-response", + "title": "Critical Vulnerability Spike Response", + "description": "Steps to investigate and respond to a spike in critical vulnerabilities", + "trigger_alerts": ["critical-vuln-spike"], + "steps": [ + { + "step_number": 1, + "action": "Identify the source of new vulnerabilities", + "command": "stella findings list --severity critical --since 1h", + "expected_outcome": "List of new critical findings with affected assets" + }, + { + "step_number": 2, + "action": "Check if vulnerabilities are from new scans or advisory updates", + "command": "stella scan jobs --status completed --since 1h" + }, + { + "step_number": 3, + "action": "Review VEX applicability for affected components", + "command": "stella vex check --vuln-id CVE-XXXX-YYYY" + } + ], + "estimated_duration": "15m", + "severity": "critical" + } + ], + "sample_data": [ + { + "series_id": "vulns-24h", + "metric_name": "stellaops_vulnerabilities_total", + "labels": { "severity": "critical" }, + "data_points": [ + { "timestamp": "2025-12-05T10:00:00Z", "value": 45 }, + { "timestamp": "2025-12-05T14:00:00Z", "value": 48 }, + { "timestamp": "2025-12-05T18:00:00Z", "value": 52 }, + { "timestamp": "2025-12-05T22:00:00Z", "value": 51 }, + { "timestamp": "2025-12-06T02:00:00Z", "value": 49 }, + { "timestamp": "2025-12-06T06:00:00Z", "value": 47 }, + { "timestamp": "2025-12-06T10:00:00Z", "value": 50 } + ], + "resolution": "1h" + } + ], + "screenshots": [ + { + "screenshot_id": "vuln-overview-full", + "filename": "vulnerability-overview-dashboard.png", + "description": "Full vulnerability overview dashboard", + "dashboard_id": "vulnerability-overview", + "width": 1920, + "height": 1080, + "format": "png", + "digest": "sha256:scr123def456789012345678901234567890123456789012345678901234scr" + } + ], + "aggregate_digest": "sha256:demo123def456789012345678901234567890123456789012345678901234demo" + } + } + ] +} diff --git a/docs/schemas/ops-incident-runbook.schema.json b/docs/schemas/ops-incident-runbook.schema.json new file mode 100644 index 000000000..48d307dc4 --- /dev/null +++ b/docs/schemas/ops-incident-runbook.schema.json @@ -0,0 +1,686 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/schemas/ops-incident-runbook.schema.json", + "title": "StellaOps Operations Incident Runbook Schema", + "description": "Schema for incident runbooks, escalation procedures, and operational checklists. Unblocks DOCS-RUNBOOK-55-001 (1+ tasks).", + "type": "object", + "definitions": { + "Runbook": { + "type": "object", + "description": "Complete incident runbook", + "required": ["runbook_id", "title", "severity", "steps"], + "properties": { + "runbook_id": { + "type": "string", + "pattern": "^RB-[A-Z]+-[0-9]+$", + "description": "Unique runbook identifier (e.g., RB-VULN-001)" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "severity": { + "type": "string", + "enum": ["critical", "high", "medium", "low"], + "description": "Severity level this runbook addresses" + }, + "category": { + "type": "string", + "enum": ["vulnerability", "outage", "security", "performance", "data", "compliance"], + "description": "Incident category" + }, + "trigger_conditions": { + "type": "array", + "items": { + "$ref": "#/definitions/TriggerCondition" + }, + "description": "Conditions that trigger this runbook" + }, + "prerequisites": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Required access/tools before starting" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/RunbookStep" + } + }, + "escalation": { + "$ref": "#/definitions/EscalationProcedure" + }, + "communication": { + "$ref": "#/definitions/CommunicationPlan" + }, + "rollback": { + "type": "array", + "items": { + "$ref": "#/definitions/RunbookStep" + }, + "description": "Rollback steps if resolution fails" + }, + "post_incident": { + "$ref": "#/definitions/PostIncidentChecklist" + }, + "estimated_duration": { + "type": "string", + "description": "Expected time to resolve (e.g., 30m, 2h)" + }, + "last_updated": { + "type": "string", + "format": "date-time" + }, + "owner": { + "type": "string", + "description": "Team/person responsible for maintaining this runbook" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "TriggerCondition": { + "type": "object", + "description": "Condition that triggers the runbook", + "required": ["condition_type", "description"], + "properties": { + "condition_type": { + "type": "string", + "enum": ["alert", "metric_threshold", "user_report", "scheduled", "manual"] + }, + "description": { + "type": "string" + }, + "alert_name": { + "type": "string", + "description": "Alert name if condition_type is 'alert'" + }, + "metric_expr": { + "type": "string", + "description": "PromQL expression if condition_type is 'metric_threshold'" + }, + "threshold": { + "type": "number" + } + } + }, + "RunbookStep": { + "type": "object", + "description": "Individual runbook step", + "required": ["step_number", "action"], + "properties": { + "step_number": { + "type": "integer", + "minimum": 1 + }, + "action": { + "type": "string", + "description": "What to do" + }, + "description": { + "type": "string", + "description": "Detailed explanation" + }, + "command": { + "type": "string", + "description": "CLI command to execute" + }, + "commands": { + "type": "array", + "items": { + "$ref": "#/definitions/CommandSpec" + }, + "description": "Multiple commands if needed" + }, + "expected_output": { + "type": "string", + "description": "What success looks like" + }, + "timeout": { + "type": "string", + "description": "Max time for this step (e.g., 5m)" + }, + "decision_point": { + "$ref": "#/definitions/DecisionPoint", + "description": "If step requires a decision" + }, + "verification": { + "type": "string", + "description": "How to verify step completed successfully" + }, + "notes": { + "type": "string", + "description": "Additional context or warnings" + }, + "skip_conditions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Conditions under which to skip this step" + } + } + }, + "CommandSpec": { + "type": "object", + "description": "Command specification", + "required": ["command"], + "properties": { + "command": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requires_sudo": { + "type": "boolean", + "default": false + }, + "environment": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "DecisionPoint": { + "type": "object", + "description": "Decision branch in runbook", + "required": ["question", "options"], + "properties": { + "question": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "condition": { + "type": "string" + }, + "next_step": { + "type": "integer" + }, + "action": { + "type": "string" + } + }, + "required": ["condition"] + } + } + } + }, + "EscalationProcedure": { + "type": "object", + "description": "Escalation procedure", + "properties": { + "levels": { + "type": "array", + "items": { + "$ref": "#/definitions/EscalationLevel" + } + }, + "auto_escalate_after": { + "type": "string", + "description": "Time after which to auto-escalate (e.g., 30m)" + }, + "escalation_criteria": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Conditions that trigger escalation" + } + } + }, + "EscalationLevel": { + "type": "object", + "description": "Single escalation level", + "required": ["level", "contacts"], + "properties": { + "level": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string" + }, + "contacts": { + "type": "array", + "items": { + "$ref": "#/definitions/Contact" + } + }, + "response_time_sla": { + "type": "string", + "description": "Expected response time (e.g., 15m)" + }, + "notification_channels": { + "type": "array", + "items": { + "type": "string", + "enum": ["pagerduty", "slack", "email", "phone", "sms", "teams"] + } + } + } + }, + "Contact": { + "type": "object", + "description": "Contact information", + "required": ["name", "role"], + "properties": { + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "phone": { + "type": "string" + }, + "slack_handle": { + "type": "string" + }, + "pagerduty_id": { + "type": "string" + } + } + }, + "CommunicationPlan": { + "type": "object", + "description": "Communication during incident", + "properties": { + "status_page": { + "type": "string", + "format": "uri", + "description": "Public status page URL" + }, + "internal_channel": { + "type": "string", + "description": "Internal communication channel (e.g., #incident-response)" + }, + "stakeholder_updates": { + "type": "object", + "properties": { + "frequency": { + "type": "string", + "description": "Update frequency (e.g., every 30m)" + }, + "recipients": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string", + "description": "Status update template" + } + } + }, + "customer_notification": { + "type": "object", + "properties": { + "required": { + "type": "boolean" + }, + "template": { + "type": "string" + }, + "approval_required": { + "type": "boolean" + } + } + } + } + }, + "PostIncidentChecklist": { + "type": "object", + "description": "Post-incident activities", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "task": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "due": { + "type": "string", + "description": "Due timeframe (e.g., within 24h, within 1 week)" + }, + "required": { + "type": "boolean", + "default": true + } + }, + "required": ["task"] + } + }, + "postmortem_required": { + "type": "boolean", + "default": true + }, + "postmortem_due": { + "type": "string", + "description": "Timeframe for postmortem (e.g., 5 business days)" + } + } + }, + "IncidentChecklist": { + "type": "object", + "description": "Pre-flight checklist for incident response", + "required": ["checklist_id", "name", "items"], + "properties": { + "checklist_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "item_id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string", + "enum": ["access", "tools", "documentation", "communication", "monitoring"] + }, + "verification": { + "type": "string" + } + }, + "required": ["description"] + } + } + } + }, + "RunbookCatalog": { + "type": "object", + "description": "Catalog of all runbooks", + "required": ["catalog_id", "version", "runbooks"], + "properties": { + "catalog_id": { + "type": "string" + }, + "version": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "runbooks": { + "type": "array", + "items": { + "$ref": "#/definitions/Runbook" + } + }, + "checklists": { + "type": "array", + "items": { + "$ref": "#/definitions/IncidentChecklist" + } + }, + "global_contacts": { + "type": "array", + "items": { + "$ref": "#/definitions/Contact" + } + } + } + } + }, + "properties": { + "catalog": { + "$ref": "#/definitions/RunbookCatalog" + } + }, + "examples": [ + { + "catalog": { + "catalog_id": "stellaops-runbooks", + "version": "2025.10.0", + "updated_at": "2025-12-06T10:00:00Z", + "runbooks": [ + { + "runbook_id": "RB-VULN-001", + "title": "Critical Vulnerability Spike Response", + "description": "Response procedure when critical vulnerabilities spike significantly", + "severity": "critical", + "category": "vulnerability", + "trigger_conditions": [ + { + "condition_type": "alert", + "description": "Critical vulnerability count increased by >10 in 1 hour", + "alert_name": "CriticalVulnerabilitySpike" + } + ], + "prerequisites": [ + "Access to StellaOps CLI (stella)", + "Read access to Findings Ledger", + "Access to #security-incidents Slack channel" + ], + "steps": [ + { + "step_number": 1, + "action": "Acknowledge the alert", + "description": "Acknowledge in PagerDuty/alerting system to stop escalation", + "timeout": "5m" + }, + { + "step_number": 2, + "action": "Identify scope of new vulnerabilities", + "command": "stella findings list --severity critical --since 1h --format table", + "expected_output": "List of new critical findings with CVE IDs and affected assets", + "verification": "Output shows findings with timestamps within last hour" + }, + { + "step_number": 3, + "action": "Determine if spike is from new scans or advisory updates", + "commands": [ + { + "command": "stella scan jobs --status completed --since 1h", + "description": "Check for recent scan completions" + }, + { + "command": "stella advisory updates --since 1h", + "description": "Check for recent advisory updates" + } + ], + "decision_point": { + "question": "What caused the spike?", + "options": [ + { + "condition": "New scans completed", + "next_step": 4, + "action": "Review scan results" + }, + { + "condition": "Advisory update", + "next_step": 5, + "action": "Review advisory impact" + }, + { + "condition": "Unknown/Both", + "next_step": 4, + "action": "Continue with full investigation" + } + ] + } + }, + { + "step_number": 4, + "action": "Review affected assets and determine business impact", + "command": "stella findings group-by asset --severity critical --since 1h", + "description": "Group findings by asset to understand impact scope" + }, + { + "step_number": 5, + "action": "Check VEX applicability", + "command": "stella vex check --vuln-ids $(stella findings list --severity critical --since 1h --format ids)", + "description": "Check if any vulnerabilities have VEX statements that reduce severity" + }, + { + "step_number": 6, + "action": "Update stakeholders", + "description": "Post status update to #security-incidents with findings summary", + "notes": "Use template: 'VULN SPIKE: [count] new critical vulns affecting [assets]. Investigation in progress.'" + }, + { + "step_number": 7, + "action": "Create remediation tickets if needed", + "command": "stella findings export --severity critical --since 1h --format jira", + "skip_conditions": [ + "All vulnerabilities covered by VEX not_affected", + "Vulnerabilities are duplicates from rescan" + ] + } + ], + "escalation": { + "levels": [ + { + "level": 1, + "name": "On-call Security Engineer", + "contacts": [ + { + "name": "Security On-Call", + "role": "Security Engineer", + "slack_handle": "@security-oncall" + } + ], + "response_time_sla": "15m", + "notification_channels": ["pagerduty", "slack"] + }, + { + "level": 2, + "name": "Security Team Lead", + "contacts": [ + { + "name": "Security Lead", + "role": "Security Team Lead", + "slack_handle": "@security-lead" + } + ], + "response_time_sla": "30m", + "notification_channels": ["pagerduty", "slack", "phone"] + } + ], + "auto_escalate_after": "30m", + "escalation_criteria": [ + "No acknowledgment within 15 minutes", + "More than 50 critical vulnerabilities", + "Production systems affected" + ] + }, + "communication": { + "internal_channel": "#security-incidents", + "stakeholder_updates": { + "frequency": "every 30m during active incident", + "recipients": ["security-team", "engineering-leads"], + "template": "VULN INCIDENT UPDATE: Status: [status]. Critical count: [count]. Affected systems: [systems]. Next update: [time]." + } + }, + "post_incident": { + "items": [ + { + "task": "Document incident timeline", + "owner": "Incident Commander", + "due": "within 24h", + "required": true + }, + { + "task": "Update vulnerability scanning schedules if needed", + "owner": "Security Team", + "due": "within 1 week", + "required": false + }, + { + "task": "Review and update this runbook", + "owner": "Runbook Owner", + "due": "within 1 week", + "required": true + } + ], + "postmortem_required": true, + "postmortem_due": "5 business days" + }, + "estimated_duration": "1h", + "last_updated": "2025-12-06T10:00:00Z", + "owner": "Security Operations Team", + "tags": ["vulnerability", "security", "critical"] + } + ], + "checklists": [ + { + "checklist_id": "incident-preflight", + "name": "Incident Response Pre-flight Checklist", + "description": "Verify access and tools before incident response", + "items": [ + { + "item_id": "cli-access", + "description": "StellaOps CLI is installed and authenticated", + "category": "tools", + "verification": "Run 'stella whoami' successfully" + }, + { + "item_id": "slack-access", + "description": "Access to #security-incidents channel", + "category": "communication", + "verification": "Can post messages to channel" + }, + { + "item_id": "pagerduty-access", + "description": "Can acknowledge alerts in PagerDuty", + "category": "tools", + "verification": "PagerDuty mobile app logged in" + }, + { + "item_id": "runbooks-access", + "description": "Can access runbook documentation", + "category": "documentation", + "verification": "docs.stella-ops.org/runbooks accessible" + } + ] + } + ], + "global_contacts": [ + { + "name": "Security Operations", + "role": "Primary Response Team", + "email": "security-ops@example.com", + "slack_handle": "@security-ops" + } + ] + } + } + ] +} diff --git a/docs/schemas/risk-api.schema.json b/docs/schemas/risk-api.schema.json new file mode 100644 index 000000000..59f71dddd --- /dev/null +++ b/docs/schemas/risk-api.schema.json @@ -0,0 +1,606 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/schemas/risk-api.schema.json", + "title": "StellaOps Risk API Schema", + "description": "Schema for Risk API endpoints, scoring models, and factor weights. Unblocks DOCS-RISK-67-002 through 68-002 (5+ tasks).", + "type": "object", + "definitions": { + "RiskScore": { + "type": "object", + "description": "Computed risk score", + "required": ["score", "rating", "computed_at"], + "properties": { + "score": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Numeric risk score (0-100)" + }, + "rating": { + "type": "string", + "enum": ["critical", "high", "medium", "low", "info", "none"], + "description": "Risk rating category" + }, + "computed_at": { + "type": "string", + "format": "date-time" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Confidence level in the score (0-1)" + }, + "factors": { + "type": "array", + "items": { + "$ref": "#/definitions/RiskFactor" + } + }, + "trend": { + "$ref": "#/definitions/RiskTrend" + } + } + }, + "RiskFactor": { + "type": "object", + "description": "Individual risk factor contribution", + "required": ["factor_id", "name", "value", "weight", "contribution"], + "properties": { + "factor_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "category": { + "type": "string", + "enum": ["vulnerability", "exposure", "asset", "context", "temporal", "environmental"] + }, + "value": { + "type": "number", + "description": "Raw factor value" + }, + "normalized_value": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Normalized value (0-1)" + }, + "weight": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Factor weight in scoring model" + }, + "contribution": { + "type": "number", + "description": "Contribution to final score" + }, + "source": { + "type": "string", + "description": "Data source for this factor" + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Evidence supporting this factor" + } + } + }, + "RiskTrend": { + "type": "object", + "description": "Risk score trend over time", + "properties": { + "direction": { + "type": "string", + "enum": ["improving", "stable", "degrading"] + }, + "change_percent": { + "type": "number", + "description": "Percent change from previous period" + }, + "period": { + "type": "string", + "enum": ["24h", "7d", "30d"] + }, + "previous_score": { + "type": "number" + } + } + }, + "RiskProfile": { + "type": "object", + "description": "Risk profile configuration", + "required": ["profile_id", "name"], + "properties": { + "profile_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "scoring_model": { + "$ref": "#/definitions/ScoringModel" + }, + "thresholds": { + "$ref": "#/definitions/RiskThresholds" + }, + "factor_weights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "enabled_factors": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + }, + "ScoringModel": { + "type": "object", + "description": "Risk scoring model configuration", + "required": ["model_id", "name"], + "properties": { + "model_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["weighted_sum", "geometric_mean", "max_severity", "custom"], + "description": "Scoring algorithm type" + }, + "base_score_source": { + "type": "string", + "enum": ["cvss_v3", "cvss_v4", "epss", "custom"], + "description": "Primary score source" + }, + "modifiers": { + "type": "array", + "items": { + "$ref": "#/definitions/ScoreModifier" + } + } + } + }, + "ScoreModifier": { + "type": "object", + "description": "Score modifier rule", + "required": ["modifier_id", "condition", "adjustment"], + "properties": { + "modifier_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "condition": { + "type": "string", + "description": "Condition expression (e.g., 'kev == true')" + }, + "adjustment": { + "type": "number", + "description": "Score adjustment (can be positive or negative)" + }, + "adjustment_type": { + "type": "string", + "enum": ["absolute", "percent", "multiply"], + "default": "absolute" + }, + "priority": { + "type": "integer", + "description": "Order of modifier application" + } + } + }, + "RiskThresholds": { + "type": "object", + "description": "Risk rating thresholds", + "properties": { + "critical": { + "type": "number", + "default": 90, + "description": "Score >= this is Critical" + }, + "high": { + "type": "number", + "default": 70, + "description": "Score >= this is High" + }, + "medium": { + "type": "number", + "default": 40, + "description": "Score >= this is Medium" + }, + "low": { + "type": "number", + "default": 10, + "description": "Score >= this is Low" + }, + "info": { + "type": "number", + "default": 0, + "description": "Score >= this is Info" + } + } + }, + "RiskAssessmentRequest": { + "type": "object", + "description": "Request to compute risk for an entity", + "required": ["entity_type", "entity_id"], + "properties": { + "entity_type": { + "type": "string", + "enum": ["vulnerability", "asset", "component", "project", "tenant"] + }, + "entity_id": { + "type": "string" + }, + "profile_id": { + "type": "string", + "description": "Risk profile to use (uses default if not specified)" + }, + "include_factors": { + "type": "boolean", + "default": true, + "description": "Include factor breakdown in response" + }, + "include_trend": { + "type": "boolean", + "default": false, + "description": "Include trend analysis" + }, + "context": { + "type": "object", + "additionalProperties": true, + "description": "Additional context for scoring" + } + } + }, + "RiskAssessmentResponse": { + "type": "object", + "description": "Risk assessment result", + "required": ["assessment_id", "entity_type", "entity_id", "score"], + "properties": { + "assessment_id": { + "type": "string", + "format": "uuid" + }, + "entity_type": { + "type": "string" + }, + "entity_id": { + "type": "string" + }, + "profile_id": { + "type": "string" + }, + "score": { + "$ref": "#/definitions/RiskScore" + }, + "explainability": { + "$ref": "#/definitions/RiskExplainability" + }, + "recommendations": { + "type": "array", + "items": { + "$ref": "#/definitions/RiskRecommendation" + } + } + } + }, + "RiskExplainability": { + "type": "object", + "description": "Human-readable explanation of risk score", + "properties": { + "summary": { + "type": "string", + "description": "One-line summary" + }, + "narrative": { + "type": "string", + "description": "Detailed explanation" + }, + "top_factors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "factor": { + "type": "string" + }, + "impact": { + "type": "string", + "enum": ["major", "moderate", "minor"] + }, + "explanation": { + "type": "string" + } + } + }, + "description": "Top contributing factors" + }, + "comparisons": { + "type": "object", + "properties": { + "org_percentile": { + "type": "number", + "description": "Percentile within organization" + }, + "industry_percentile": { + "type": "number", + "description": "Percentile within industry" + } + } + } + } + }, + "RiskRecommendation": { + "type": "object", + "description": "Risk mitigation recommendation", + "required": ["recommendation_id", "action"], + "properties": { + "recommendation_id": { + "type": "string" + }, + "action": { + "type": "string", + "description": "Recommended action" + }, + "priority": { + "type": "string", + "enum": ["critical", "high", "medium", "low"] + }, + "impact_estimate": { + "type": "number", + "description": "Estimated score reduction if implemented" + }, + "effort": { + "type": "string", + "enum": ["minimal", "moderate", "significant"] + }, + "related_factors": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "RiskAggregation": { + "type": "object", + "description": "Aggregated risk across multiple entities", + "required": ["aggregation_id", "entity_type", "count", "scores"], + "properties": { + "aggregation_id": { + "type": "string" + }, + "entity_type": { + "type": "string" + }, + "scope": { + "type": "string", + "description": "Aggregation scope (e.g., project, tenant)" + }, + "count": { + "type": "integer", + "description": "Number of entities aggregated" + }, + "scores": { + "type": "object", + "properties": { + "average": { + "type": "number" + }, + "median": { + "type": "number" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "p95": { + "type": "number" + } + } + }, + "distribution": { + "type": "object", + "properties": { + "critical": { "type": "integer" }, + "high": { "type": "integer" }, + "medium": { "type": "integer" }, + "low": { "type": "integer" }, + "info": { "type": "integer" } + } + }, + "trend": { + "$ref": "#/definitions/RiskTrend" + } + } + }, + "RiskApiEndpoint": { + "type": "object", + "description": "Risk API endpoint definition", + "required": ["path", "method", "operation_id"], + "properties": { + "path": { + "type": "string" + }, + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "DELETE"] + }, + "operation_id": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "request_schema": { + "type": "string", + "description": "Reference to request schema" + }, + "response_schema": { + "type": "string", + "description": "Reference to response schema" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Required OAuth scopes" + } + } + } + }, + "properties": { + "endpoints": { + "type": "array", + "items": { + "$ref": "#/definitions/RiskApiEndpoint" + } + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/definitions/RiskProfile" + } + } + }, + "examples": [ + { + "endpoints": [ + { + "path": "/api/v1/risk/assess", + "method": "POST", + "operation_id": "assessRisk", + "summary": "Compute risk score for an entity", + "request_schema": "RiskAssessmentRequest", + "response_schema": "RiskAssessmentResponse", + "scopes": ["risk:read"] + }, + { + "path": "/api/v1/risk/profiles", + "method": "GET", + "operation_id": "listRiskProfiles", + "summary": "List available risk profiles", + "response_schema": "RiskProfile[]", + "scopes": ["risk:read"] + }, + { + "path": "/api/v1/risk/aggregate", + "method": "POST", + "operation_id": "aggregateRisk", + "summary": "Compute aggregated risk across entities", + "response_schema": "RiskAggregation", + "scopes": ["risk:read"] + }, + { + "path": "/api/v1/risk/profiles/{profile_id}", + "method": "PUT", + "operation_id": "updateRiskProfile", + "summary": "Update a risk profile", + "request_schema": "RiskProfile", + "scopes": ["risk:write", "admin"] + }, + { + "path": "/api/v1/risk/explain/{assessment_id}", + "method": "GET", + "operation_id": "explainRisk", + "summary": "Get detailed explanation for a risk assessment", + "response_schema": "RiskExplainability", + "scopes": ["risk:read"] + } + ], + "profiles": [ + { + "profile_id": "default", + "name": "Default Risk Profile", + "description": "Standard risk scoring with balanced factor weights", + "scoring_model": { + "model_id": "weighted-sum-v1", + "name": "Weighted Sum Model", + "version": "1.0.0", + "type": "weighted_sum", + "base_score_source": "cvss_v3", + "modifiers": [ + { + "modifier_id": "kev-boost", + "name": "KEV Exploit Known", + "condition": "kev == true", + "adjustment": 15, + "adjustment_type": "absolute", + "priority": 1 + }, + { + "modifier_id": "reachability-reduction", + "name": "Not Reachable", + "condition": "reachability == 'not_reachable'", + "adjustment": -30, + "adjustment_type": "absolute", + "priority": 2 + }, + { + "modifier_id": "vex-not-affected", + "name": "VEX Not Affected", + "condition": "vex_status == 'not_affected'", + "adjustment": -50, + "adjustment_type": "absolute", + "priority": 3 + } + ] + }, + "thresholds": { + "critical": 90, + "high": 70, + "medium": 40, + "low": 10, + "info": 0 + }, + "factor_weights": { + "cvss_base": 0.35, + "exploitability": 0.25, + "reachability": 0.20, + "asset_criticality": 0.10, + "exposure": 0.10 + }, + "enabled_factors": [ + "cvss_base", + "exploitability", + "kev", + "epss", + "reachability", + "vex_status", + "asset_criticality", + "exposure", + "age" + ] + } + ] + } + ] +} diff --git a/docs/schemas/sdk-generator-samples.schema.json b/docs/schemas/sdk-generator-samples.schema.json new file mode 100644 index 000000000..d78091940 --- /dev/null +++ b/docs/schemas/sdk-generator-samples.schema.json @@ -0,0 +1,498 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/schemas/sdk-generator-samples.schema.json", + "title": "StellaOps SDK Generator Samples Schema", + "description": "Schema for SDK generator output samples, snippet packs, and language bindings. Unblocks DEVPORT-63-002, DOCS-SDK-62-001 (2+ tasks).", + "type": "object", + "definitions": { + "SdkLanguage": { + "type": "string", + "enum": ["typescript", "python", "go", "java", "csharp", "ruby", "php", "rust"], + "description": "Supported SDK target languages" + }, + "SdkSample": { + "type": "object", + "description": "Individual SDK code sample", + "required": ["sample_id", "language", "operation", "code"], + "properties": { + "sample_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique sample identifier" + }, + "language": { + "$ref": "#/definitions/SdkLanguage" + }, + "operation": { + "type": "string", + "description": "API operation this sample demonstrates" + }, + "title": { + "type": "string", + "description": "Human-readable title" + }, + "description": { + "type": "string", + "description": "Explanation of what the sample demonstrates" + }, + "code": { + "type": "string", + "description": "The actual code sample" + }, + "imports": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Required imports/dependencies" + }, + "prerequisites": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Setup steps before running" + }, + "expected_output": { + "type": "string", + "description": "Expected console output or result" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Categorization tags (auth, scanning, vex, etc.)" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "SHA-256 hash of code content for verification" + } + } + }, + "SnippetPack": { + "type": "object", + "description": "Collection of SDK samples for a specific language", + "required": ["pack_id", "language", "version", "samples"], + "properties": { + "pack_id": { + "type": "string", + "description": "Unique pack identifier" + }, + "language": { + "$ref": "#/definitions/SdkLanguage" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "Pack version (semver)" + }, + "sdk_version": { + "type": "string", + "description": "Target SDK version these samples work with" + }, + "api_version": { + "type": "string", + "description": "API version (e.g., v1, v2)" + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, + "samples": { + "type": "array", + "items": { + "$ref": "#/definitions/SdkSample" + } + }, + "aggregate_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "Hash of all sample digests combined" + }, + "package_info": { + "$ref": "#/definitions/PackageInfo" + } + } + }, + "PackageInfo": { + "type": "object", + "description": "Language-specific package information", + "properties": { + "package_name": { + "type": "string", + "description": "Package name (e.g., @stellaops/sdk, stellaops-sdk)" + }, + "registry_url": { + "type": "string", + "format": "uri", + "description": "Package registry URL" + }, + "install_command": { + "type": "string", + "description": "Command to install the SDK" + }, + "min_runtime_version": { + "type": "string", + "description": "Minimum runtime version required" + }, + "dependencies": { + "type": "array", + "items": { + "$ref": "#/definitions/Dependency" + } + } + } + }, + "Dependency": { + "type": "object", + "description": "SDK dependency", + "required": ["name", "version"], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "optional": { + "type": "boolean", + "default": false + } + } + }, + "SdkGeneratorConfig": { + "type": "object", + "description": "SDK generator configuration", + "required": ["generator_id", "openapi_source"], + "properties": { + "generator_id": { + "type": "string" + }, + "openapi_source": { + "type": "string", + "format": "uri", + "description": "OpenAPI spec URL or path" + }, + "target_languages": { + "type": "array", + "items": { + "$ref": "#/definitions/SdkLanguage" + } + }, + "output_dir": { + "type": "string" + }, + "templates": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom templates per language" + }, + "options": { + "$ref": "#/definitions/GeneratorOptions" + } + } + }, + "GeneratorOptions": { + "type": "object", + "description": "SDK generation options", + "properties": { + "include_samples": { + "type": "boolean", + "default": true, + "description": "Generate usage samples" + }, + "include_tests": { + "type": "boolean", + "default": true, + "description": "Generate test stubs" + }, + "include_docs": { + "type": "boolean", + "default": true, + "description": "Generate API documentation" + }, + "async_style": { + "type": "string", + "enum": ["async_await", "promises", "callbacks", "sync"], + "default": "async_await" + }, + "error_handling": { + "type": "string", + "enum": ["exceptions", "result_types", "error_codes"], + "default": "exceptions" + }, + "naming_convention": { + "type": "string", + "enum": ["camelCase", "snake_case", "PascalCase"], + "description": "Override default for language" + } + } + }, + "SdkGeneratorOutput": { + "type": "object", + "description": "Output from SDK generation", + "required": ["output_id", "generator_id", "generated_at", "files"], + "properties": { + "output_id": { + "type": "string", + "format": "uuid" + }, + "generator_id": { + "type": "string" + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, + "language": { + "$ref": "#/definitions/SdkLanguage" + }, + "sdk_version": { + "type": "string" + }, + "api_version": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/GeneratedFile" + } + }, + "stats": { + "$ref": "#/definitions/GenerationStats" + }, + "manifest_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "GeneratedFile": { + "type": "object", + "description": "Generated SDK file", + "required": ["path", "digest"], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["source", "test", "sample", "docs", "config"] + }, + "size_bytes": { + "type": "integer" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "GenerationStats": { + "type": "object", + "description": "SDK generation statistics", + "properties": { + "total_files": { + "type": "integer" + }, + "source_files": { + "type": "integer" + }, + "test_files": { + "type": "integer" + }, + "sample_files": { + "type": "integer" + }, + "total_lines": { + "type": "integer" + }, + "endpoints_covered": { + "type": "integer" + }, + "models_generated": { + "type": "integer" + } + } + }, + "SampleCategory": { + "type": "object", + "description": "Category grouping for samples", + "required": ["category_id", "name"], + "properties": { + "category_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "samples": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample IDs in this category" + }, + "order": { + "type": "integer", + "description": "Display order" + } + } + }, + "SdkDocumentation": { + "type": "object", + "description": "SDK documentation structure", + "properties": { + "overview": { + "type": "string", + "description": "SDK overview markdown" + }, + "quickstart": { + "type": "string", + "description": "Quickstart guide markdown" + }, + "authentication": { + "type": "string", + "description": "Authentication guide" + }, + "error_handling": { + "type": "string", + "description": "Error handling guide" + }, + "changelog": { + "type": "string", + "description": "SDK changelog" + }, + "api_reference_url": { + "type": "string", + "format": "uri" + } + } + } + }, + "properties": { + "snippet_packs": { + "type": "array", + "items": { + "$ref": "#/definitions/SnippetPack" + } + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/SampleCategory" + } + }, + "generator_outputs": { + "type": "array", + "items": { + "$ref": "#/definitions/SdkGeneratorOutput" + } + } + }, + "examples": [ + { + "snippet_packs": [ + { + "pack_id": "stellaops-typescript-samples", + "language": "typescript", + "version": "1.0.0", + "sdk_version": "2025.10.0", + "api_version": "v1", + "generated_at": "2025-12-06T10:00:00Z", + "samples": [ + { + "sample_id": "auth-token-exchange", + "language": "typescript", + "operation": "POST /oauth/token", + "title": "Token Exchange", + "description": "Exchange client credentials for an access token", + "code": "import { StellaOpsClient } from '@stellaops/sdk';\n\nconst client = new StellaOpsClient({\n clientId: process.env.STELLAOPS_CLIENT_ID,\n clientSecret: process.env.STELLAOPS_CLIENT_SECRET,\n});\n\nconst token = await client.auth.getToken();\nconsole.log('Token expires at:', token.expiresAt);", + "imports": ["@stellaops/sdk"], + "prerequisites": [ + "Set STELLAOPS_CLIENT_ID environment variable", + "Set STELLAOPS_CLIENT_SECRET environment variable" + ], + "expected_output": "Token expires at: 2025-12-06T11:00:00Z", + "tags": ["authentication", "oauth"], + "digest": "sha256:abc123def456789012345678901234567890123456789012345678901234abcd" + }, + { + "sample_id": "scan-container-image", + "language": "typescript", + "operation": "POST /api/v1/scanner/scan", + "title": "Scan Container Image", + "description": "Scan a container image for vulnerabilities", + "code": "import { StellaOpsClient } from '@stellaops/sdk';\n\nconst client = new StellaOpsClient();\nconst result = await client.scanner.scanImage({\n image: 'nginx:1.25',\n generateSbom: true,\n format: 'cyclonedx',\n});\n\nconsole.log(`Found ${result.vulnerabilities.length} vulnerabilities`);", + "imports": ["@stellaops/sdk"], + "tags": ["scanning", "sbom", "vulnerabilities"], + "digest": "sha256:def456abc789012345678901234567890123456789012345678901234defabc" + } + ], + "aggregate_digest": "sha256:agg123def456789012345678901234567890123456789012345678901234agg", + "package_info": { + "package_name": "@stellaops/sdk", + "registry_url": "https://registry.npmjs.org", + "install_command": "npm install @stellaops/sdk", + "min_runtime_version": "18.0.0", + "dependencies": [ + { "name": "axios", "version": "^1.6.0" }, + { "name": "jose", "version": "^5.0.0" } + ] + } + }, + { + "pack_id": "stellaops-python-samples", + "language": "python", + "version": "1.0.0", + "sdk_version": "2025.10.0", + "api_version": "v1", + "generated_at": "2025-12-06T10:00:00Z", + "samples": [ + { + "sample_id": "auth-token-exchange-py", + "language": "python", + "operation": "POST /oauth/token", + "title": "Token Exchange", + "description": "Exchange client credentials for an access token", + "code": "from stellaops import StellaOpsClient\nimport os\n\nclient = StellaOpsClient(\n client_id=os.environ['STELLAOPS_CLIENT_ID'],\n client_secret=os.environ['STELLAOPS_CLIENT_SECRET'],\n)\n\ntoken = client.auth.get_token()\nprint(f'Token expires at: {token.expires_at}')", + "imports": ["stellaops"], + "tags": ["authentication", "oauth"], + "digest": "sha256:py123def456789012345678901234567890123456789012345678901234pyth" + } + ], + "package_info": { + "package_name": "stellaops-sdk", + "registry_url": "https://pypi.org/project/stellaops-sdk/", + "install_command": "pip install stellaops-sdk", + "min_runtime_version": "3.10" + } + } + ], + "categories": [ + { + "category_id": "authentication", + "name": "Authentication", + "description": "Token exchange and authentication samples", + "samples": ["auth-token-exchange", "auth-token-exchange-py"], + "order": 1 + }, + { + "category_id": "scanning", + "name": "Container Scanning", + "description": "Container image scanning and SBOM generation", + "samples": ["scan-container-image"], + "order": 2 + } + ] + } + ] +} diff --git a/docs/schemas/security-scopes-matrix.schema.json b/docs/schemas/security-scopes-matrix.schema.json new file mode 100644 index 000000000..71a7d6a4e --- /dev/null +++ b/docs/schemas/security-scopes-matrix.schema.json @@ -0,0 +1,641 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/schemas/security-scopes-matrix.schema.json", + "title": "StellaOps Security Scopes Matrix Schema", + "description": "Schema for security scopes, roles, permissions, and privacy controls. Unblocks DOCS-SEC-62-001, DOCS-SEC-OBS-50-001 (2+ tasks).", + "type": "object", + "definitions": { + "Scope": { + "type": "object", + "description": "OAuth2/OIDC scope definition", + "required": ["scope_id", "name"], + "properties": { + "scope_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_:]+$", + "description": "Scope identifier (e.g., findings:read, admin:write)" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string", + "enum": ["read", "write", "admin", "system"], + "description": "Scope category" + }, + "resource": { + "type": "string", + "description": "Resource this scope applies to" + }, + "actions": { + "type": "array", + "items": { + "type": "string", + "enum": ["create", "read", "update", "delete", "list", "execute", "export", "import"] + } + }, + "requires_mfa": { + "type": "boolean", + "default": false, + "description": "Whether MFA is required for this scope" + }, + "sensitive": { + "type": "boolean", + "default": false, + "description": "Whether this scope accesses sensitive data" + }, + "audit_level": { + "type": "string", + "enum": ["none", "basic", "detailed", "full"], + "default": "basic" + }, + "parent_scope": { + "type": "string", + "description": "Parent scope that implies this scope" + } + } + }, + "Role": { + "type": "object", + "description": "Role definition with assigned scopes", + "required": ["role_id", "name", "scopes"], + "properties": { + "role_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["system", "tenant", "project", "custom"], + "description": "Role type" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scopes assigned to this role" + }, + "inherits_from": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Roles this role inherits from" + }, + "restrictions": { + "$ref": "#/definitions/RoleRestrictions" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + }, + "RoleRestrictions": { + "type": "object", + "description": "Restrictions on role usage", + "properties": { + "max_sessions": { + "type": "integer", + "description": "Maximum concurrent sessions" + }, + "ip_allowlist": { + "type": "array", + "items": { + "type": "string" + } + }, + "time_restrictions": { + "type": "object", + "properties": { + "allowed_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "timezone": { + "type": "string" + } + } + }, + "allowed_days": { + "type": "array", + "items": { + "type": "string", + "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] + } + } + } + }, + "require_approval": { + "type": "boolean", + "description": "Require approval for role activation" + } + } + }, + "Permission": { + "type": "object", + "description": "Fine-grained permission", + "required": ["permission_id", "resource", "action"], + "properties": { + "permission_id": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "action": { + "type": "string", + "enum": ["create", "read", "update", "delete", "list", "execute", "export", "import"] + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/definitions/PermissionCondition" + } + }, + "effect": { + "type": "string", + "enum": ["allow", "deny"], + "default": "allow" + } + } + }, + "PermissionCondition": { + "type": "object", + "description": "Condition for permission evaluation", + "required": ["type", "value"], + "properties": { + "type": { + "type": "string", + "enum": ["attribute", "context", "time", "resource_owner", "tenant"] + }, + "attribute": { + "type": "string" + }, + "operator": { + "type": "string", + "enum": ["eq", "neq", "in", "not_in", "contains", "gt", "lt", "gte", "lte"] + }, + "value": {} + } + }, + "TenancyHeader": { + "type": "object", + "description": "Multi-tenancy header specification", + "required": ["header_name", "required"], + "properties": { + "header_name": { + "type": "string", + "default": "X-Tenant-ID" + }, + "required": { + "type": "boolean", + "default": true + }, + "validation": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": ["uuid", "slug", "custom"] + }, + "pattern": { + "type": "string" + }, + "max_length": { + "type": "integer" + } + } + }, + "default_value": { + "type": "string", + "description": "Default tenant if header not provided" + }, + "extract_from_token": { + "type": "boolean", + "default": true, + "description": "Allow extraction from JWT token" + }, + "token_claim": { + "type": "string", + "default": "tenant_id" + } + } + }, + "PrivacyControl": { + "type": "object", + "description": "Privacy control configuration", + "required": ["control_id", "name"], + "properties": { + "control_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "data_classification": { + "type": "string", + "enum": ["public", "internal", "confidential", "restricted", "pii", "phi"] + }, + "redaction_policy": { + "$ref": "#/definitions/RedactionPolicy" + }, + "retention_policy": { + "$ref": "#/definitions/RetentionPolicy" + }, + "consent_required": { + "type": "boolean", + "default": false + }, + "audit_access": { + "type": "boolean", + "default": true + } + } + }, + "RedactionPolicy": { + "type": "object", + "description": "Data redaction policy", + "properties": { + "policy_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/RedactionRule" + } + }, + "default_action": { + "type": "string", + "enum": ["pass", "mask", "hash", "remove"], + "default": "pass" + } + } + }, + "RedactionRule": { + "type": "object", + "description": "Individual redaction rule", + "required": ["field_pattern", "action"], + "properties": { + "rule_id": { + "type": "string" + }, + "field_pattern": { + "type": "string", + "description": "JSON path or field name pattern" + }, + "data_type": { + "type": "string", + "enum": ["email", "phone", "ssn", "ip_address", "credit_card", "name", "address", "custom"] + }, + "action": { + "type": "string", + "enum": ["mask", "hash", "remove", "tokenize", "truncate"] + }, + "mask_char": { + "type": "string", + "default": "*" + }, + "preserve_chars": { + "type": "integer", + "description": "Number of chars to preserve (e.g., last 4 of phone)" + }, + "hash_algorithm": { + "type": "string", + "enum": ["sha256", "sha512", "hmac-sha256"] + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/definitions/PermissionCondition" + }, + "description": "Conditions when to apply redaction" + } + } + }, + "RetentionPolicy": { + "type": "object", + "description": "Data retention policy", + "properties": { + "policy_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "default_retention_days": { + "type": "integer" + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "data_type": { + "type": "string" + }, + "retention_days": { + "type": "integer" + }, + "action_on_expiry": { + "type": "string", + "enum": ["delete", "archive", "anonymize"] + } + } + } + }, + "legal_hold_enabled": { + "type": "boolean", + "default": false + } + } + }, + "DebugOptIn": { + "type": "object", + "description": "Debug/diagnostic opt-in configuration", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "opt_in_required": { + "type": "boolean", + "default": true + }, + "scopes_required": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scopes required to access debug data" + }, + "data_collected": { + "type": "array", + "items": { + "type": "object", + "properties": { + "data_type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "retention_hours": { + "type": "integer" + } + } + } + }, + "redaction_applied": { + "type": "boolean", + "default": true + } + } + }, + "ScopeMatrix": { + "type": "object", + "description": "Complete scope matrix", + "required": ["version", "scopes"], + "properties": { + "version": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/Scope" + } + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/Role" + } + }, + "tenancy_config": { + "$ref": "#/definitions/TenancyHeader" + }, + "privacy_controls": { + "type": "array", + "items": { + "$ref": "#/definitions/PrivacyControl" + } + }, + "debug_config": { + "$ref": "#/definitions/DebugOptIn" + } + } + } + }, + "properties": { + "matrix": { + "$ref": "#/definitions/ScopeMatrix" + } + }, + "examples": [ + { + "matrix": { + "version": "2025.10.0", + "updated_at": "2025-12-06T10:00:00Z", + "scopes": [ + { + "scope_id": "findings:read", + "name": "Read Findings", + "description": "Read vulnerability findings", + "category": "read", + "resource": "findings", + "actions": ["read", "list"], + "audit_level": "basic" + }, + { + "scope_id": "findings:write", + "name": "Write Findings", + "description": "Create and update findings", + "category": "write", + "resource": "findings", + "actions": ["create", "update"], + "audit_level": "detailed", + "parent_scope": "findings:read" + }, + { + "scope_id": "findings:delete", + "name": "Delete Findings", + "description": "Delete findings (requires approval)", + "category": "admin", + "resource": "findings", + "actions": ["delete"], + "requires_mfa": true, + "audit_level": "full", + "parent_scope": "findings:write" + }, + { + "scope_id": "scanner:execute", + "name": "Execute Scans", + "description": "Initiate container scans", + "category": "write", + "resource": "scanner", + "actions": ["execute"], + "audit_level": "detailed" + }, + { + "scope_id": "risk:read", + "name": "Read Risk Scores", + "description": "Access risk scoring data", + "category": "read", + "resource": "risk", + "actions": ["read", "list"], + "audit_level": "basic" + }, + { + "scope_id": "admin:*", + "name": "Full Admin Access", + "description": "Full administrative access", + "category": "admin", + "resource": "*", + "actions": ["create", "read", "update", "delete", "list", "execute"], + "requires_mfa": true, + "sensitive": true, + "audit_level": "full" + } + ], + "roles": [ + { + "role_id": "viewer", + "name": "Viewer", + "description": "Read-only access to findings and risk data", + "type": "tenant", + "scopes": ["findings:read", "risk:read"] + }, + { + "role_id": "analyst", + "name": "Security Analyst", + "description": "Can view and update findings, execute scans", + "type": "tenant", + "scopes": ["findings:read", "findings:write", "scanner:execute", "risk:read"], + "inherits_from": ["viewer"] + }, + { + "role_id": "admin", + "name": "Tenant Admin", + "description": "Full tenant administrative access", + "type": "tenant", + "scopes": ["findings:read", "findings:write", "findings:delete", "scanner:execute", "risk:read", "risk:write"], + "inherits_from": ["analyst"], + "restrictions": { + "max_sessions": 3, + "require_approval": false + } + }, + { + "role_id": "super_admin", + "name": "Super Admin", + "description": "System-wide administrative access", + "type": "system", + "scopes": ["admin:*"], + "restrictions": { + "max_sessions": 1, + "require_approval": true + } + } + ], + "tenancy_config": { + "header_name": "X-Tenant-ID", + "required": true, + "validation": { + "format": "uuid" + }, + "extract_from_token": true, + "token_claim": "tenant_id" + }, + "privacy_controls": [ + { + "control_id": "pii-protection", + "name": "PII Protection", + "description": "Protection for personally identifiable information", + "data_classification": "pii", + "redaction_policy": { + "policy_id": "pii-redaction", + "name": "PII Redaction", + "rules": [ + { + "rule_id": "email-mask", + "field_pattern": "$.**.email", + "data_type": "email", + "action": "mask", + "preserve_chars": 3 + }, + { + "rule_id": "ip-hash", + "field_pattern": "$.**.ip_address", + "data_type": "ip_address", + "action": "hash", + "hash_algorithm": "sha256" + } + ], + "default_action": "pass" + }, + "retention_policy": { + "policy_id": "pii-retention", + "name": "PII Retention", + "default_retention_days": 90, + "rules": [ + { + "data_type": "audit_logs", + "retention_days": 365, + "action_on_expiry": "archive" + } + ] + }, + "consent_required": true, + "audit_access": true + } + ], + "debug_config": { + "enabled": true, + "opt_in_required": true, + "scopes_required": ["admin:*"], + "data_collected": [ + { + "data_type": "request_traces", + "description": "HTTP request/response traces for debugging", + "retention_hours": 24 + }, + { + "data_type": "performance_metrics", + "description": "Detailed performance timing", + "retention_hours": 72 + } + ], + "redaction_applied": true + } + } + } + ] +} diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs b/src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs new file mode 100644 index 000000000..2469c6e3a --- /dev/null +++ b/src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs @@ -0,0 +1,232 @@ +using StellaOps.Policy.Registry.Contracts; + +namespace StellaOps.Policy.Registry.Storage; + +/// +/// Storage entity for policy pack. +/// +public sealed record PolicyPackEntity +{ + public required Guid TenantId { get; init; } + public required Guid PackId { get; init; } + public required string Name { get; init; } + public required string Version { get; init; } + public string? Description { get; init; } + public required PolicyPackStatus Status { get; init; } + public IReadOnlyList? Rules { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required DateTimeOffset UpdatedAt { get; init; } + public DateTimeOffset? PublishedAt { get; init; } + public string? Digest { get; init; } + public string? CreatedBy { get; init; } + public string? UpdatedBy { get; init; } + + /// + /// Converts to API contract. + /// + public PolicyPack ToContract() => new() + { + PackId = PackId, + Name = Name, + Version = Version, + Description = Description, + Status = Status, + Rules = Rules, + Metadata = Metadata, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + PublishedAt = PublishedAt, + Digest = Digest + }; +} + +/// +/// Storage entity for verification policy. +/// +public sealed record VerificationPolicyEntity +{ + public required Guid TenantId { get; init; } + public required string PolicyId { get; init; } + public required string Version { get; init; } + public string? Description { get; init; } + public required string TenantScope { get; init; } + public required IReadOnlyList PredicateTypes { get; init; } + public required SignerRequirements SignerRequirements { get; init; } + public ValidityWindow? ValidityWindow { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required DateTimeOffset UpdatedAt { get; init; } + public string? CreatedBy { get; init; } + public string? UpdatedBy { get; init; } + + /// + /// Converts to API contract. + /// + public VerificationPolicy ToContract() => new() + { + PolicyId = PolicyId, + Version = Version, + Description = Description, + TenantScope = TenantScope, + PredicateTypes = PredicateTypes, + SignerRequirements = SignerRequirements, + ValidityWindow = ValidityWindow, + Metadata = Metadata, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt + }; +} + +/// +/// Storage entity for snapshot. +/// +public sealed record SnapshotEntity +{ + public required Guid TenantId { get; init; } + public required Guid SnapshotId { get; init; } + public required string Digest { get; init; } + public string? Description { get; init; } + public IReadOnlyList? PackIds { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public string? CreatedBy { get; init; } + + /// + /// Converts to API contract. + /// + public Snapshot ToContract() => new() + { + SnapshotId = SnapshotId, + Digest = Digest, + Description = Description, + PackIds = PackIds, + Metadata = Metadata, + CreatedAt = CreatedAt, + CreatedBy = CreatedBy + }; +} + +/// +/// Storage entity for violation. +/// +public sealed record ViolationEntity +{ + public required Guid TenantId { get; init; } + public required Guid ViolationId { get; init; } + public string? PolicyId { get; init; } + public required string RuleId { get; init; } + public required Severity Severity { get; init; } + public required string Message { get; init; } + public string? Purl { get; init; } + public string? CveId { get; init; } + public IReadOnlyDictionary? Context { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// Converts to API contract. + /// + public Violation ToContract() => new() + { + ViolationId = ViolationId, + PolicyId = PolicyId, + RuleId = RuleId, + Severity = Severity, + Message = Message, + Purl = Purl, + CveId = CveId, + Context = Context, + CreatedAt = CreatedAt + }; +} + +/// +/// Storage entity for override. +/// +public sealed record OverrideEntity +{ + public required Guid TenantId { get; init; } + public required Guid OverrideId { get; init; } + public Guid? ProfileId { get; init; } + public required string RuleId { get; init; } + public required OverrideStatus Status { get; init; } + public string? Reason { get; init; } + public OverrideScope? Scope { get; init; } + public DateTimeOffset? ExpiresAt { get; init; } + public string? ApprovedBy { get; init; } + public DateTimeOffset? ApprovedAt { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public string? CreatedBy { get; init; } + + /// + /// Converts to API contract. + /// + public Override ToContract() => new() + { + OverrideId = OverrideId, + ProfileId = ProfileId, + RuleId = RuleId, + Status = Status, + Reason = Reason, + Scope = Scope, + ExpiresAt = ExpiresAt, + ApprovedBy = ApprovedBy, + ApprovedAt = ApprovedAt, + CreatedAt = CreatedAt, + CreatedBy = CreatedBy + }; +} + +/// +/// History entry for policy pack changes. +/// +public sealed record PolicyPackHistoryEntry +{ + public required Guid PackId { get; init; } + public required string Action { get; init; } + public required DateTimeOffset Timestamp { get; init; } + public string? PerformedBy { get; init; } + public PolicyPackStatus? PreviousStatus { get; init; } + public PolicyPackStatus? NewStatus { get; init; } + public string? Description { get; init; } +} + +/// +/// Result for paginated policy pack list. +/// +public sealed record PolicyPackListResult +{ + public required IReadOnlyList Items { get; init; } + public string? NextPageToken { get; init; } + public int TotalCount { get; init; } +} + +/// +/// Result for paginated verification policy list. +/// +public sealed record VerificationPolicyListResult +{ + public required IReadOnlyList Items { get; init; } + public string? NextPageToken { get; init; } + public int TotalCount { get; init; } +} + +/// +/// Result for paginated snapshot list. +/// +public sealed record SnapshotListResult +{ + public required IReadOnlyList Items { get; init; } + public string? NextPageToken { get; init; } + public int TotalCount { get; init; } +} + +/// +/// Result for paginated violation list. +/// +public sealed record ViolationListResult +{ + public required IReadOnlyList Items { get; init; } + public string? NextPageToken { get; init; } + public int TotalCount { get; init; } +} diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs new file mode 100644 index 000000000..49c0b47ad --- /dev/null +++ b/src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs @@ -0,0 +1,212 @@ +using StellaOps.Policy.Registry.Contracts; + +namespace StellaOps.Policy.Registry.Storage; + +/// +/// Store interface for policy pack workspace operations with history tracking. +/// Implements REGISTRY-API-27-002: Workspace storage with CRUD + history. +/// +public interface IPolicyPackStore +{ + /// + /// Creates a new policy pack. + /// + Task CreateAsync( + Guid tenantId, + CreatePolicyPackRequest request, + string? createdBy = null, + CancellationToken cancellationToken = default); + + /// + /// Gets a policy pack by ID. + /// + Task GetByIdAsync( + Guid tenantId, + Guid packId, + CancellationToken cancellationToken = default); + + /// + /// Gets a policy pack by name. + /// + Task GetByNameAsync( + Guid tenantId, + string name, + CancellationToken cancellationToken = default); + + /// + /// Lists policy packs with optional filtering. + /// + Task ListAsync( + Guid tenantId, + PolicyPackStatus? status = null, + int pageSize = 20, + string? pageToken = null, + CancellationToken cancellationToken = default); + + /// + /// Updates a policy pack. + /// + Task UpdateAsync( + Guid tenantId, + Guid packId, + UpdatePolicyPackRequest request, + string? updatedBy = null, + CancellationToken cancellationToken = default); + + /// + /// Deletes a policy pack (only drafts can be deleted). + /// + Task DeleteAsync( + Guid tenantId, + Guid packId, + CancellationToken cancellationToken = default); + + /// + /// Updates the status of a policy pack. + /// + Task UpdateStatusAsync( + Guid tenantId, + Guid packId, + PolicyPackStatus newStatus, + string? updatedBy = null, + CancellationToken cancellationToken = default); + + /// + /// Gets the history of changes for a policy pack. + /// + Task> GetHistoryAsync( + Guid tenantId, + Guid packId, + int limit = 50, + CancellationToken cancellationToken = default); +} + +/// +/// Store interface for verification policy operations. +/// +public interface IVerificationPolicyStore +{ + Task CreateAsync( + Guid tenantId, + CreateVerificationPolicyRequest request, + string? createdBy = null, + CancellationToken cancellationToken = default); + + Task GetByIdAsync( + Guid tenantId, + string policyId, + CancellationToken cancellationToken = default); + + Task ListAsync( + Guid tenantId, + int pageSize = 20, + string? pageToken = null, + CancellationToken cancellationToken = default); + + Task UpdateAsync( + Guid tenantId, + string policyId, + UpdateVerificationPolicyRequest request, + string? updatedBy = null, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + Guid tenantId, + string policyId, + CancellationToken cancellationToken = default); +} + +/// +/// Store interface for policy snapshot operations. +/// +public interface ISnapshotStore +{ + Task CreateAsync( + Guid tenantId, + CreateSnapshotRequest request, + string? createdBy = null, + CancellationToken cancellationToken = default); + + Task GetByIdAsync( + Guid tenantId, + Guid snapshotId, + CancellationToken cancellationToken = default); + + Task GetByDigestAsync( + Guid tenantId, + string digest, + CancellationToken cancellationToken = default); + + Task ListAsync( + Guid tenantId, + int pageSize = 20, + string? pageToken = null, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + Guid tenantId, + Guid snapshotId, + CancellationToken cancellationToken = default); +} + +/// +/// Store interface for violation operations. +/// +public interface IViolationStore +{ + Task AppendAsync( + Guid tenantId, + CreateViolationRequest request, + CancellationToken cancellationToken = default); + + Task AppendBatchAsync( + Guid tenantId, + IReadOnlyList requests, + CancellationToken cancellationToken = default); + + Task GetByIdAsync( + Guid tenantId, + Guid violationId, + CancellationToken cancellationToken = default); + + Task ListAsync( + Guid tenantId, + Severity? severity = null, + int pageSize = 20, + string? pageToken = null, + CancellationToken cancellationToken = default); +} + +/// +/// Store interface for override operations. +/// +public interface IOverrideStore +{ + Task CreateAsync( + Guid tenantId, + CreateOverrideRequest request, + string? createdBy = null, + CancellationToken cancellationToken = default); + + Task GetByIdAsync( + Guid tenantId, + Guid overrideId, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + Guid tenantId, + Guid overrideId, + CancellationToken cancellationToken = default); + + Task ApproveAsync( + Guid tenantId, + Guid overrideId, + string? approvedBy = null, + string? comment = null, + CancellationToken cancellationToken = default); + + Task DisableAsync( + Guid tenantId, + Guid overrideId, + CancellationToken cancellationToken = default); +} diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryOverrideStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryOverrideStore.cs new file mode 100644 index 000000000..5a18d6593 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryOverrideStore.cs @@ -0,0 +1,108 @@ +using System.Collections.Concurrent; +using StellaOps.Policy.Registry.Contracts; + +namespace StellaOps.Policy.Registry.Storage; + +/// +/// In-memory implementation of IOverrideStore for testing and development. +/// +public sealed class InMemoryOverrideStore : IOverrideStore +{ + private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new(); + + public Task CreateAsync( + Guid tenantId, + CreateOverrideRequest request, + string? createdBy = null, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var overrideId = Guid.NewGuid(); + + var entity = new OverrideEntity + { + TenantId = tenantId, + OverrideId = overrideId, + ProfileId = request.ProfileId, + RuleId = request.RuleId, + Status = OverrideStatus.Pending, + Reason = request.Reason, + Scope = request.Scope, + ExpiresAt = request.ExpiresAt, + CreatedAt = now, + CreatedBy = createdBy + }; + + _overrides[(tenantId, overrideId)] = entity; + + return Task.FromResult(entity); + } + + public Task GetByIdAsync( + Guid tenantId, + Guid overrideId, + CancellationToken cancellationToken = default) + { + _overrides.TryGetValue((tenantId, overrideId), out var entity); + return Task.FromResult(entity); + } + + public Task DeleteAsync( + Guid tenantId, + Guid overrideId, + CancellationToken cancellationToken = default) + { + return Task.FromResult(_overrides.TryRemove((tenantId, overrideId), out _)); + } + + public Task ApproveAsync( + Guid tenantId, + Guid overrideId, + string? approvedBy = null, + string? comment = null, + CancellationToken cancellationToken = default) + { + if (!_overrides.TryGetValue((tenantId, overrideId), out var existing)) + { + return Task.FromResult(null); + } + + // Only pending overrides can be approved + if (existing.Status != OverrideStatus.Pending) + { + return Task.FromResult(null); + } + + var now = DateTimeOffset.UtcNow; + var updated = existing with + { + Status = OverrideStatus.Approved, + ApprovedBy = approvedBy, + ApprovedAt = now + }; + + _overrides[(tenantId, overrideId)] = updated; + + return Task.FromResult(updated); + } + + public Task DisableAsync( + Guid tenantId, + Guid overrideId, + CancellationToken cancellationToken = default) + { + if (!_overrides.TryGetValue((tenantId, overrideId), out var existing)) + { + return Task.FromResult(null); + } + + var updated = existing with + { + Status = OverrideStatus.Disabled + }; + + _overrides[(tenantId, overrideId)] = updated; + + return Task.FromResult(updated); + } +} diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryPolicyPackStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryPolicyPackStore.cs new file mode 100644 index 000000000..010f6121a --- /dev/null +++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryPolicyPackStore.cs @@ -0,0 +1,260 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using StellaOps.Policy.Registry.Contracts; + +namespace StellaOps.Policy.Registry.Storage; + +/// +/// In-memory implementation of IPolicyPackStore for testing and development. +/// +public sealed class InMemoryPolicyPackStore : IPolicyPackStore +{ + private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new(); + private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List> _history = new(); + + public Task CreateAsync( + Guid tenantId, + CreatePolicyPackRequest request, + string? createdBy = null, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var packId = Guid.NewGuid(); + + var entity = new PolicyPackEntity + { + TenantId = tenantId, + PackId = packId, + Name = request.Name, + Version = request.Version, + Description = request.Description, + Status = PolicyPackStatus.Draft, + Rules = request.Rules, + Metadata = request.Metadata, + CreatedAt = now, + UpdatedAt = now, + CreatedBy = createdBy + }; + + _packs[(tenantId, packId)] = entity; + + // Add history entry + AddHistoryEntry(tenantId, packId, "created", createdBy, null, PolicyPackStatus.Draft, "Policy pack created"); + + return Task.FromResult(entity); + } + + public Task GetByIdAsync( + Guid tenantId, + Guid packId, + CancellationToken cancellationToken = default) + { + _packs.TryGetValue((tenantId, packId), out var entity); + return Task.FromResult(entity); + } + + public Task GetByNameAsync( + Guid tenantId, + string name, + CancellationToken cancellationToken = default) + { + var entity = _packs.Values + .Where(p => p.TenantId == tenantId && p.Name == name) + .OrderByDescending(p => p.UpdatedAt) + .FirstOrDefault(); + + return Task.FromResult(entity); + } + + public Task ListAsync( + Guid tenantId, + PolicyPackStatus? status = null, + int pageSize = 20, + string? pageToken = null, + CancellationToken cancellationToken = default) + { + var query = _packs.Values + .Where(p => p.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(p => p.Status == status.Value); + } + + var items = query + .OrderByDescending(p => p.UpdatedAt) + .ToList(); + + int skip = 0; + if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset)) + { + skip = offset; + } + + var pagedItems = items.Skip(skip).Take(pageSize).ToList(); + string? nextToken = skip + pagedItems.Count < items.Count + ? (skip + pagedItems.Count).ToString() + : null; + + return Task.FromResult(new PolicyPackListResult + { + Items = pagedItems, + NextPageToken = nextToken, + TotalCount = items.Count + }); + } + + public Task UpdateAsync( + Guid tenantId, + Guid packId, + UpdatePolicyPackRequest request, + string? updatedBy = null, + CancellationToken cancellationToken = default) + { + if (!_packs.TryGetValue((tenantId, packId), out var existing)) + { + return Task.FromResult(null); + } + + // Only allow updates to drafts + if (existing.Status != PolicyPackStatus.Draft) + { + return Task.FromResult(null); + } + + var updated = existing with + { + Name = request.Name ?? existing.Name, + Description = request.Description ?? existing.Description, + Rules = request.Rules ?? existing.Rules, + Metadata = request.Metadata ?? existing.Metadata, + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = updatedBy + }; + + _packs[(tenantId, packId)] = updated; + + AddHistoryEntry(tenantId, packId, "updated", updatedBy, existing.Status, updated.Status, "Policy pack updated"); + + return Task.FromResult(updated); + } + + public Task DeleteAsync( + Guid tenantId, + Guid packId, + CancellationToken cancellationToken = default) + { + if (!_packs.TryGetValue((tenantId, packId), out var existing)) + { + return Task.FromResult(false); + } + + // Only allow deletion of drafts + if (existing.Status != PolicyPackStatus.Draft) + { + return Task.FromResult(false); + } + + var removed = _packs.TryRemove((tenantId, packId), out _); + if (removed) + { + _history.TryRemove((tenantId, packId), out _); + } + + return Task.FromResult(removed); + } + + public Task UpdateStatusAsync( + Guid tenantId, + Guid packId, + PolicyPackStatus newStatus, + string? updatedBy = null, + CancellationToken cancellationToken = default) + { + if (!_packs.TryGetValue((tenantId, packId), out var existing)) + { + return Task.FromResult(null); + } + + var now = DateTimeOffset.UtcNow; + var updated = existing with + { + Status = newStatus, + UpdatedAt = now, + UpdatedBy = updatedBy, + PublishedAt = newStatus == PolicyPackStatus.Published ? now : existing.PublishedAt, + Digest = newStatus == PolicyPackStatus.Published ? ComputeDigest(existing) : existing.Digest + }; + + _packs[(tenantId, packId)] = updated; + + AddHistoryEntry(tenantId, packId, $"status_changed_to_{newStatus}", updatedBy, existing.Status, newStatus, + $"Status changed from {existing.Status} to {newStatus}"); + + return Task.FromResult(updated); + } + + public Task> GetHistoryAsync( + Guid tenantId, + Guid packId, + int limit = 50, + CancellationToken cancellationToken = default) + { + if (!_history.TryGetValue((tenantId, packId), out var history)) + { + return Task.FromResult>(Array.Empty()); + } + + var entries = history + .OrderByDescending(h => h.Timestamp) + .Take(limit) + .ToList(); + + return Task.FromResult>(entries); + } + + private void AddHistoryEntry( + Guid tenantId, + Guid packId, + string action, + string? performedBy, + PolicyPackStatus? previousStatus, + PolicyPackStatus? newStatus, + string? description) + { + var entry = new PolicyPackHistoryEntry + { + PackId = packId, + Action = action, + Timestamp = DateTimeOffset.UtcNow, + PerformedBy = performedBy, + PreviousStatus = previousStatus, + NewStatus = newStatus, + Description = description + }; + + _history.AddOrUpdate( + (tenantId, packId), + _ => [entry], + (_, list) => + { + list.Add(entry); + return list; + }); + } + + private static string ComputeDigest(PolicyPackEntity pack) + { + var content = JsonSerializer.Serialize(new + { + pack.Name, + pack.Version, + pack.Rules + }); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content)); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } +} diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemorySnapshotStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemorySnapshotStore.cs new file mode 100644 index 000000000..9dfe1af7b --- /dev/null +++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemorySnapshotStore.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using StellaOps.Policy.Registry.Contracts; + +namespace StellaOps.Policy.Registry.Storage; + +/// +/// In-memory implementation of ISnapshotStore for testing and development. +/// +public sealed class InMemorySnapshotStore : ISnapshotStore +{ + private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new(); + + public Task CreateAsync( + Guid tenantId, + CreateSnapshotRequest request, + string? createdBy = null, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var snapshotId = Guid.NewGuid(); + + // Compute digest from pack IDs and timestamp for uniqueness + var digest = ComputeDigest(request.PackIds, now); + + var entity = new SnapshotEntity + { + TenantId = tenantId, + SnapshotId = snapshotId, + Digest = digest, + Description = request.Description, + PackIds = request.PackIds, + Metadata = request.Metadata, + CreatedAt = now, + CreatedBy = createdBy + }; + + _snapshots[(tenantId, snapshotId)] = entity; + + return Task.FromResult(entity); + } + + public Task GetByIdAsync( + Guid tenantId, + Guid snapshotId, + CancellationToken cancellationToken = default) + { + _snapshots.TryGetValue((tenantId, snapshotId), out var entity); + return Task.FromResult(entity); + } + + public Task GetByDigestAsync( + Guid tenantId, + string digest, + CancellationToken cancellationToken = default) + { + var entity = _snapshots.Values + .Where(s => s.TenantId == tenantId && s.Digest == digest) + .FirstOrDefault(); + + return Task.FromResult(entity); + } + + public Task ListAsync( + Guid tenantId, + int pageSize = 20, + string? pageToken = null, + CancellationToken cancellationToken = default) + { + var items = _snapshots.Values + .Where(s => s.TenantId == tenantId) + .OrderByDescending(s => s.CreatedAt) + .ToList(); + + int skip = 0; + if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset)) + { + skip = offset; + } + + var pagedItems = items.Skip(skip).Take(pageSize).ToList(); + string? nextToken = skip + pagedItems.Count < items.Count + ? (skip + pagedItems.Count).ToString() + : null; + + return Task.FromResult(new SnapshotListResult + { + Items = pagedItems, + NextPageToken = nextToken, + TotalCount = items.Count + }); + } + + public Task DeleteAsync( + Guid tenantId, + Guid snapshotId, + CancellationToken cancellationToken = default) + { + return Task.FromResult(_snapshots.TryRemove((tenantId, snapshotId), out _)); + } + + private static string ComputeDigest(IReadOnlyList packIds, DateTimeOffset timestamp) + { + var content = JsonSerializer.Serialize(new + { + PackIds = packIds.OrderBy(id => id).ToList(), + Timestamp = timestamp.ToUnixTimeMilliseconds() + }); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content)); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } +} diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryVerificationPolicyStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryVerificationPolicyStore.cs new file mode 100644 index 000000000..da26a4bab --- /dev/null +++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryVerificationPolicyStore.cs @@ -0,0 +1,121 @@ +using System.Collections.Concurrent; +using StellaOps.Policy.Registry.Contracts; + +namespace StellaOps.Policy.Registry.Storage; + +/// +/// In-memory implementation of IVerificationPolicyStore for testing and development. +/// +public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore +{ + private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new(); + + public Task CreateAsync( + Guid tenantId, + CreateVerificationPolicyRequest request, + string? createdBy = null, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + + var entity = new VerificationPolicyEntity + { + TenantId = tenantId, + PolicyId = request.PolicyId, + Version = request.Version, + Description = request.Description, + TenantScope = request.TenantScope ?? tenantId.ToString(), + PredicateTypes = request.PredicateTypes, + SignerRequirements = request.SignerRequirements ?? new SignerRequirements + { + MinimumSignatures = 1, + TrustedKeyFingerprints = Array.Empty() + }, + ValidityWindow = request.ValidityWindow, + Metadata = request.Metadata, + CreatedAt = now, + UpdatedAt = now, + CreatedBy = createdBy + }; + + _policies[(tenantId, request.PolicyId)] = entity; + + return Task.FromResult(entity); + } + + public Task GetByIdAsync( + Guid tenantId, + string policyId, + CancellationToken cancellationToken = default) + { + _policies.TryGetValue((tenantId, policyId), out var entity); + return Task.FromResult(entity); + } + + public Task ListAsync( + Guid tenantId, + int pageSize = 20, + string? pageToken = null, + CancellationToken cancellationToken = default) + { + var items = _policies.Values + .Where(p => p.TenantId == tenantId) + .OrderByDescending(p => p.UpdatedAt) + .ToList(); + + int skip = 0; + if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset)) + { + skip = offset; + } + + var pagedItems = items.Skip(skip).Take(pageSize).ToList(); + string? nextToken = skip + pagedItems.Count < items.Count + ? (skip + pagedItems.Count).ToString() + : null; + + return Task.FromResult(new VerificationPolicyListResult + { + Items = pagedItems, + NextPageToken = nextToken, + TotalCount = items.Count + }); + } + + public Task UpdateAsync( + Guid tenantId, + string policyId, + UpdateVerificationPolicyRequest request, + string? updatedBy = null, + CancellationToken cancellationToken = default) + { + if (!_policies.TryGetValue((tenantId, policyId), out var existing)) + { + return Task.FromResult(null); + } + + var updated = existing with + { + Version = request.Version ?? existing.Version, + Description = request.Description ?? existing.Description, + PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes, + SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements, + ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow, + Metadata = request.Metadata ?? existing.Metadata, + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = updatedBy + }; + + _policies[(tenantId, policyId)] = updated; + + return Task.FromResult(updated); + } + + public Task DeleteAsync( + Guid tenantId, + string policyId, + CancellationToken cancellationToken = default) + { + return Task.FromResult(_policies.TryRemove((tenantId, policyId), out _)); + } +} diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryViolationStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryViolationStore.cs new file mode 100644 index 000000000..45e576e10 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryViolationStore.cs @@ -0,0 +1,139 @@ +using System.Collections.Concurrent; +using StellaOps.Policy.Registry.Contracts; + +namespace StellaOps.Policy.Registry.Storage; + +/// +/// In-memory implementation of IViolationStore for testing and development. +/// +public sealed class InMemoryViolationStore : IViolationStore +{ + private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new(); + + public Task AppendAsync( + Guid tenantId, + CreateViolationRequest request, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var violationId = Guid.NewGuid(); + + var entity = new ViolationEntity + { + TenantId = tenantId, + ViolationId = violationId, + PolicyId = request.PolicyId, + RuleId = request.RuleId, + Severity = request.Severity, + Message = request.Message, + Purl = request.Purl, + CveId = request.CveId, + Context = request.Context, + CreatedAt = now + }; + + _violations[(tenantId, violationId)] = entity; + + return Task.FromResult(entity); + } + + public Task AppendBatchAsync( + Guid tenantId, + IReadOnlyList requests, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + int created = 0; + int failed = 0; + var errors = new List(); + + for (int i = 0; i < requests.Count; i++) + { + try + { + var request = requests[i]; + var violationId = Guid.NewGuid(); + + var entity = new ViolationEntity + { + TenantId = tenantId, + ViolationId = violationId, + PolicyId = request.PolicyId, + RuleId = request.RuleId, + Severity = request.Severity, + Message = request.Message, + Purl = request.Purl, + CveId = request.CveId, + Context = request.Context, + CreatedAt = now + }; + + _violations[(tenantId, violationId)] = entity; + created++; + } + catch (Exception ex) + { + failed++; + errors.Add(new BatchError + { + Index = i, + Error = ex.Message + }); + } + } + + return Task.FromResult(new ViolationBatchResult + { + Created = created, + Failed = failed, + Errors = errors.Count > 0 ? errors : null + }); + } + + public Task GetByIdAsync( + Guid tenantId, + Guid violationId, + CancellationToken cancellationToken = default) + { + _violations.TryGetValue((tenantId, violationId), out var entity); + return Task.FromResult(entity); + } + + public Task ListAsync( + Guid tenantId, + Severity? severity = null, + int pageSize = 20, + string? pageToken = null, + CancellationToken cancellationToken = default) + { + var query = _violations.Values + .Where(v => v.TenantId == tenantId); + + if (severity.HasValue) + { + query = query.Where(v => v.Severity == severity.Value); + } + + var items = query + .OrderByDescending(v => v.CreatedAt) + .ToList(); + + int skip = 0; + if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset)) + { + skip = offset; + } + + var pagedItems = items.Skip(skip).Take(pageSize).ToList(); + string? nextToken = skip + pagedItems.Count < items.Count + ? (skip + pagedItems.Count).ToString() + : null; + + return Task.FromResult(new ViolationListResult + { + Items = pagedItems, + NextPageToken = nextToken, + TotalCount = items.Count + }); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/JavaPropertyResolverTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/JavaPropertyResolverTests.cs new file mode 100644 index 000000000..298ca248e --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/JavaPropertyResolverTests.cs @@ -0,0 +1,325 @@ +using System.Collections.Immutable; +using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata; +using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution; + +namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers; + +public sealed class JavaPropertyResolverTests +{ + [Fact] + public void ResolvesSimpleProperty() + { + var properties = new Dictionary + { + ["version"] = "1.0.0" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(properties); + var result = resolver.Resolve("${version}"); + + Assert.Equal("1.0.0", result.ResolvedValue); + Assert.True(result.IsFullyResolved); + Assert.Empty(result.UnresolvedProperties); + } + + [Fact] + public void ResolvesMultipleProperties() + { + var properties = new Dictionary + { + ["groupId"] = "com.example", + ["artifactId"] = "demo", + ["version"] = "2.0.0" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(properties); + var result = resolver.Resolve("${groupId}:${artifactId}:${version}"); + + Assert.Equal("com.example:demo:2.0.0", result.ResolvedValue); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void ResolvesNestedProperties() + { + var properties = new Dictionary + { + ["slf4j.version"] = "2.0.7", + ["logging.version"] = "${slf4j.version}" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(properties); + var result = resolver.Resolve("${logging.version}"); + + Assert.Equal("2.0.7", result.ResolvedValue); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void HandlesCircularReference() + { + // Circular: a → b → a (should stop at max depth) + var properties = new Dictionary + { + ["a"] = "${b}", + ["b"] = "${a}" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(properties); + var result = resolver.Resolve("${a}"); + + // Should stop recursing and return whatever state it reaches + Assert.False(result.IsFullyResolved); + } + + [Fact] + public void HandlesMaxRecursionDepth() + { + // Create a chain of 15 nested properties (exceeds max depth of 10) + var properties = new Dictionary(); + for (int i = 0; i < 15; i++) + { + properties[$"prop{i}"] = $"${{prop{i + 1}}}"; + } + properties["prop15"] = "final-value"; + + var resolver = new JavaPropertyResolver(properties.ToImmutableDictionary()); + var result = resolver.Resolve("${prop0}"); + + // Should not reach final-value due to depth limit + Assert.Contains("${", result.ResolvedValue); + } + + [Fact] + public void PreservesUnresolvedPlaceholder() + { + var properties = new Dictionary + { + ["known"] = "value" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(properties); + var result = resolver.Resolve("${unknown}"); + + Assert.Equal("${unknown}", result.ResolvedValue); + Assert.False(result.IsFullyResolved); + Assert.Single(result.UnresolvedProperties); + Assert.Contains("unknown", result.UnresolvedProperties); + } + + [Fact] + public void ResolvesMavenStandardProperties() + { + var resolver = new JavaPropertyResolver(); + + var basedir = resolver.Resolve("${project.basedir}"); + Assert.Equal(".", basedir.ResolvedValue); + + var buildDir = resolver.Resolve("${project.build.directory}"); + Assert.Equal("target", buildDir.ResolvedValue); + + var sourceDir = resolver.Resolve("${project.build.sourceDirectory}"); + Assert.Equal("src/main/java", sourceDir.ResolvedValue); + } + + [Fact] + public void HandlesEmptyInput() + { + var resolver = new JavaPropertyResolver(); + + var nullResult = resolver.Resolve(null); + Assert.Equal(PropertyResolutionResult.Empty, nullResult); + + var emptyResult = resolver.Resolve(string.Empty); + Assert.Equal(PropertyResolutionResult.Empty, emptyResult); + } + + [Fact] + public void HandlesInputWithoutPlaceholders() + { + var resolver = new JavaPropertyResolver(); + var result = resolver.Resolve("plain-text-value"); + + Assert.Equal("plain-text-value", result.ResolvedValue); + Assert.True(result.IsFullyResolved); + Assert.Empty(result.UnresolvedProperties); + } + + [Fact] + public void ResolvesFromParentChain() + { + var childProps = new Dictionary + { + ["child.version"] = "1.0.0" + }.ToImmutableDictionary(); + + var parentProps = new Dictionary + { + ["parent.version"] = "2.0.0", + ["shared.version"] = "parent-value" + }.ToImmutableDictionary(); + + var grandparentProps = new Dictionary + { + ["grandparent.version"] = "3.0.0" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(childProps, [parentProps, grandparentProps]); + + // Child property + Assert.Equal("1.0.0", resolver.Resolve("${child.version}").ResolvedValue); + + // Parent property + Assert.Equal("2.0.0", resolver.Resolve("${parent.version}").ResolvedValue); + + // Grandparent property + Assert.Equal("3.0.0", resolver.Resolve("${grandparent.version}").ResolvedValue); + } + + [Fact] + public void ChildPropertyOverridesParent() + { + var childProps = new Dictionary + { + ["version"] = "child-value" + }.ToImmutableDictionary(); + + var parentProps = new Dictionary + { + ["version"] = "parent-value" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(childProps, [parentProps]); + var result = resolver.Resolve("${version}"); + + Assert.Equal("child-value", result.ResolvedValue); + } + + [Fact] + public void ResolvesProjectCoordinateProperties() + { + var builder = new JavaPropertyBuilder() + .AddProjectCoordinates("com.example", "demo", "1.0.0"); + + var resolver = new JavaPropertyResolver(builder.Build()); + + Assert.Equal("com.example", resolver.Resolve("${project.groupId}").ResolvedValue); + Assert.Equal("com.example", resolver.Resolve("${groupId}").ResolvedValue); + Assert.Equal("demo", resolver.Resolve("${project.artifactId}").ResolvedValue); + Assert.Equal("1.0.0", resolver.Resolve("${project.version}").ResolvedValue); + } + + [Fact] + public void ResolvesDependencyVersion() + { + var properties = new Dictionary + { + ["slf4j.version"] = "2.0.7" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(properties); + + var dependency = new JavaDependencyDeclaration + { + GroupId = "org.slf4j", + ArtifactId = "slf4j-api", + Version = "${slf4j.version}", + Source = "pom.xml" + }; + + var resolved = resolver.ResolveDependency(dependency); + + Assert.Equal("2.0.7", resolved.Version); + Assert.Equal(JavaVersionSource.Property, resolved.VersionSource); + Assert.Equal("slf4j.version", resolved.VersionProperty); + } + + [Fact] + public void ResolvesDependencyWithUnresolvedVersion() + { + var resolver = new JavaPropertyResolver(); + + var dependency = new JavaDependencyDeclaration + { + GroupId = "org.unknown", + ArtifactId = "unknown", + Version = "${unknown.version}", + Source = "pom.xml" + }; + + var resolved = resolver.ResolveDependency(dependency); + + Assert.Equal("${unknown.version}", resolved.Version); + Assert.Equal(JavaVersionSource.Unresolved, resolved.VersionSource); + } + + [Fact] + public void PropertyBuilderAddRange() + { + var existing = new Dictionary + { + ["existing"] = "value1", + ["override"] = "original" + }; + + var builder = new JavaPropertyBuilder() + .Add("override", "new-value") // Added first + .AddRange(existing); // Won't override + + var props = builder.Build(); + + Assert.Equal("new-value", props["override"]); + Assert.Equal("value1", props["existing"]); + } + + [Fact] + public void PropertyBuilderAddParentCoordinates() + { + var parent = new JavaParentReference + { + GroupId = "org.springframework.boot", + ArtifactId = "spring-boot-starter-parent", + Version = "3.1.0" + }; + + var builder = new JavaPropertyBuilder().AddParentCoordinates(parent); + var props = builder.Build(); + + Assert.Equal("org.springframework.boot", props["project.parent.groupId"]); + Assert.Equal("spring-boot-starter-parent", props["project.parent.artifactId"]); + Assert.Equal("3.1.0", props["project.parent.version"]); + } + + [Fact] + public void ResolvesComplexMavenExpression() + { + var properties = new Dictionary + { + ["spring.version"] = "6.0.0", + ["project.version"] = "1.0.0-SNAPSHOT" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(properties); + var result = resolver.Resolve("spring-${spring.version}-app-${project.version}"); + + Assert.Equal("spring-6.0.0-app-1.0.0-SNAPSHOT", result.ResolvedValue); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void HandlesMixedResolvedAndUnresolved() + { + var properties = new Dictionary + { + ["known"] = "resolved" + }.ToImmutableDictionary(); + + var resolver = new JavaPropertyResolver(properties); + var result = resolver.Resolve("${known}-${unknown}"); + + Assert.Equal("resolved-${unknown}", result.ResolvedValue); + Assert.False(result.IsFullyResolved); + Assert.Single(result.UnresolvedProperties); + Assert.Contains("unknown", result.UnresolvedProperties); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenEffectivePomBuilderTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenEffectivePomBuilderTests.cs new file mode 100644 index 000000000..9789a1f4f --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenEffectivePomBuilderTests.cs @@ -0,0 +1,547 @@ +using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata; +using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven; +using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; + +namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers; + +public sealed class MavenEffectivePomBuilderTests +{ + [Fact] + public async Task BuildsEffectivePomWithParentPropertiesAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with properties + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + 17 + 31.1-jre + + + """, cancellationToken); + + // Child using parent properties + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + + com.google.guava + guava + ${guava.version} + + + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(childPom, cancellationToken); + + Assert.True(result.IsFullyResolved); + Assert.Equal("17", result.EffectiveProperties["java.version"]); + Assert.Single(result.ResolvedDependencies); + + var dep = result.ResolvedDependencies[0]; + Assert.Equal("31.1-jre", dep.Version); + Assert.Equal(JavaVersionSource.Property, dep.VersionSource); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task MergesParentDependencyManagementAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with dependencyManagement + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + + + org.slf4j + slf4j-api + 2.0.7 + + + junit + junit + 4.13.2 + test + + + + + """, cancellationToken); + + // Child with version-less dependencies + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + + org.slf4j + slf4j-api + + + junit + junit + + + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(childPom, cancellationToken); + + Assert.Equal(2, result.ResolvedDependencies.Length); + + var slf4j = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api"); + Assert.Equal("2.0.7", slf4j.Version); + + var junit = result.ResolvedDependencies.First(d => d.ArtifactId == "junit"); + Assert.Equal("4.13.2", junit.Version); + Assert.Equal("test", junit.Scope); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task ChildDependencyManagementOverridesParentAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with version + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + + + org.slf4j + slf4j-api + 1.7.36 + + + + + """, cancellationToken); + + // Child overriding version + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + + + org.slf4j + slf4j-api + 2.0.9 + + + + + + org.slf4j + slf4j-api + + + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(childPom, cancellationToken); + + var dep = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api"); + Assert.Equal("2.0.9", dep.Version); // Child's version wins + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task HandlesStandalonePomAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + var pomPath = Path.Combine(root, "pom.xml"); + await File.WriteAllTextAsync(pomPath, """ + + + com.example + standalone + 1.0.0 + + UTF-8 + + + + org.slf4j + slf4j-api + 2.0.7 + + + + """, cancellationToken); + + var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(pom, cancellationToken); + + Assert.True(result.IsFullyResolved); + Assert.Single(result.ParentChain); // Only the POM itself + Assert.Equal("UTF-8", result.EffectiveProperties["encoding"]); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task ResolvesPropertyInDependencyManagementVersionAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + var pomPath = Path.Combine(root, "pom.xml"); + await File.WriteAllTextAsync(pomPath, """ + + + com.example + app + 1.0.0 + + 3.12.0 + + + + + org.apache.commons + commons-lang3 + ${commons.version} + + + + + + org.apache.commons + commons-lang3 + + + + """, cancellationToken); + + var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(pom, cancellationToken); + + var dep = result.ResolvedDependencies.First(d => d.ArtifactId == "commons-lang3"); + Assert.Equal("3.12.0", dep.Version); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task TracksVersionSourceCorrectlyAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with dependencyManagement + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + + + org.slf4j + slf4j-api + 2.0.7 + + + + + """, cancellationToken); + + // Child with various version sources + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + 31.1-jre + + + + + junit + junit + 4.13.2 + + + + com.google.guava + guava + ${guava.version} + + + + org.slf4j + slf4j-api + + + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(childPom, cancellationToken); + + var junit = result.ResolvedDependencies.First(d => d.ArtifactId == "junit"); + Assert.Equal("4.13.2", junit.Version); + Assert.Equal(JavaVersionSource.Direct, junit.VersionSource); + + var guava = result.ResolvedDependencies.First(d => d.ArtifactId == "guava"); + Assert.Equal("31.1-jre", guava.Version); + Assert.Equal(JavaVersionSource.Property, guava.VersionSource); + Assert.Equal("guava.version", guava.VersionProperty); + + var slf4j = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api"); + Assert.Equal("2.0.7", slf4j.Version); + // From parent's dependencyManagement + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task CollectsAllLicensesAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with Apache license + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + + Apache License 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + """, cancellationToken); + + // Child (inherits license) + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(childPom, cancellationToken); + + Assert.Single(result.Licenses); + Assert.Equal("Apache-2.0", result.Licenses[0].SpdxId); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task GetsUnresolvedDependenciesAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + var pomPath = Path.Combine(root, "pom.xml"); + await File.WriteAllTextAsync(pomPath, """ + + + com.example + app + 1.0.0 + + + + org.slf4j + slf4j-api + ${undefined.version} + + + + com.example + missing + + + + """, cancellationToken); + + var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(pom, cancellationToken); + + var unresolved = result.GetUnresolvedDependencies().ToList(); + Assert.Equal(2, unresolved.Count); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task PopulatesManagedVersionsIndexAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + var pomPath = Path.Combine(root, "pom.xml"); + await File.WriteAllTextAsync(pomPath, """ + + + com.example + app + 1.0.0 + + + + org.slf4j + slf4j-api + 2.0.7 + + + com.google.guava + guava + 31.1-jre + + + + + """, cancellationToken); + + var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken); + var builder = new MavenEffectivePomBuilder(root); + var result = await builder.BuildAsync(pom, cancellationToken); + + Assert.Equal(2, result.ManagedVersions.Count); + Assert.True(result.ManagedVersions.ContainsKey("org.slf4j:slf4j-api")); + Assert.True(result.ManagedVersions.ContainsKey("com.google.guava:guava")); + } + finally + { + TestPaths.SafeDelete(root); + } + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenParentResolverTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenParentResolverTests.cs new file mode 100644 index 000000000..d3160f1ce --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenParentResolverTests.cs @@ -0,0 +1,556 @@ +using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven; +using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; + +namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers; + +public sealed class MavenParentResolverTests +{ + [Fact] + public async Task ResolvesRelativePathParentAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Create parent/pom.xml + var parentDir = Path.Combine(root, "parent"); + Directory.CreateDirectory(parentDir); + await File.WriteAllTextAsync(Path.Combine(parentDir, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + 17 + + + """, cancellationToken); + + // Create child/pom.xml with relativePath to parent + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + ../parent/pom.xml + + child + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + Assert.True(result.IsFullyResolved); + Assert.Equal(2, result.ParentChain.Length); // child + parent + Assert.Equal("17", result.EffectiveProperties["java.version"]); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task ResolvesDefaultRelativePathAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Create parent pom.xml in parent directory (default ../pom.xml) + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 2.0.0 + pom + + """, cancellationToken); + + // Create child in subdirectory with no relativePath (defaults to ../pom.xml) + var childDir = Path.Combine(root, "module"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 2.0.0 + + module + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + Assert.True(result.IsFullyResolved); + Assert.Equal("2.0.0", result.EffectiveVersion); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task ResolvesMultiLevelParentChainAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Grandparent + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + grandparent + 1.0.0 + pom + + gp-value + + + """, cancellationToken); + + // Parent + var parentDir = Path.Combine(root, "parent"); + Directory.CreateDirectory(parentDir); + await File.WriteAllTextAsync(Path.Combine(parentDir, "pom.xml"), """ + + + + com.example + grandparent + 1.0.0 + + parent + + parent-value + + + """, cancellationToken); + + // Child + var childDir = Path.Combine(parentDir, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + Assert.True(result.IsFullyResolved); + Assert.Equal(3, result.ParentChain.Length); // child, parent, grandparent + Assert.Equal("gp-value", result.EffectiveProperties["grandparent.prop"]); + Assert.Equal("parent-value", result.EffectiveProperties["parent.prop"]); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task ReturnsUnresolvedForMissingParentAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + var childPomPath = Path.Combine(root, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + missing-parent + 1.0.0 + + orphan + 1.0.0 + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + Assert.False(result.IsFullyResolved); + Assert.Single(result.UnresolvedParents); + Assert.Contains("com.example:missing-parent:1.0.0", result.UnresolvedParents); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task HandlesNoParentAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + var pomPath = Path.Combine(root, "pom.xml"); + await File.WriteAllTextAsync(pomPath, """ + + + com.example + standalone + 1.0.0 + + """, cancellationToken); + + var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(pom, cancellationToken); + + Assert.True(result.IsFullyResolved); + Assert.Single(result.ParentChain); // Only the original POM + Assert.Equal("com.example", result.EffectiveGroupId); + Assert.Equal("1.0.0", result.EffectiveVersion); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task InheritsGroupIdFromParentAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with groupId + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + org.parent + parent + 1.0.0 + pom + + """, cancellationToken); + + // Child without explicit groupId + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + org.parent + parent + 1.0.0 + + child + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + Assert.Equal("org.parent", result.EffectiveGroupId); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task InheritsVersionFromParentAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 2.5.0 + pom + + """, cancellationToken); + + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 2.5.0 + + child + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + Assert.Equal("2.5.0", result.EffectiveVersion); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task ResolvesDependencyVersionFromManagementAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with dependencyManagement + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + + + org.slf4j + slf4j-api + 2.0.7 + + + + + """, cancellationToken); + + // Child with dependency without version + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + + org.slf4j + slf4j-api + + + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + Assert.Single(result.ResolvedDependencies); + var dep = result.ResolvedDependencies[0]; + Assert.Equal("2.0.7", dep.Version); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task ResolvesPropertyInDependencyVersionAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + var pomPath = Path.Combine(root, "pom.xml"); + await File.WriteAllTextAsync(pomPath, """ + + + com.example + app + 1.0.0 + + 31.1-jre + + + + com.google.guava + guava + ${guava.version} + + + + """, cancellationToken); + + var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(pom, cancellationToken); + + Assert.Single(result.ResolvedDependencies); + var dep = result.ResolvedDependencies[0]; + Assert.Equal("31.1-jre", dep.Version); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task CollectsLicensesFromChainAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with license + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + + Apache License 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + """, cancellationToken); + + // Child + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + Assert.Single(result.AllLicenses); + Assert.Equal("Apache-2.0", result.AllLicenses[0].SpdxId); + } + finally + { + TestPaths.SafeDelete(root); + } + } + + [Fact] + public async Task ChildPropertyOverridesParentPropertyAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var root = TestPaths.CreateTemporaryDirectory(); + + try + { + // Parent with property + await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """ + + + com.example + parent + 1.0.0 + pom + + 11 + + + """, cancellationToken); + + // Child overriding property + var childDir = Path.Combine(root, "child"); + Directory.CreateDirectory(childDir); + var childPomPath = Path.Combine(childDir, "pom.xml"); + await File.WriteAllTextAsync(childPomPath, """ + + + + com.example + parent + 1.0.0 + + child + + 17 + + + """, cancellationToken); + + var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken); + var resolver = new MavenParentResolver(root); + var result = await resolver.ResolveAsync(childPom, cancellationToken); + + // Child property should win + Assert.Equal("17", result.EffectiveProperties["java.version"]); + } + finally + { + TestPaths.SafeDelete(root); + } + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs index 65ea74b9b..715ed4340 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs @@ -328,6 +328,18 @@ public static class PackRunEventTypes /// Attestation was revoked. public const string AttestationRevoked = "pack.attestation.revoked"; + /// Incident mode activated (per TASKRUN-OBS-55-001). + public const string IncidentModeActivated = "pack.incident.activated"; + + /// Incident mode deactivated. + public const string IncidentModeDeactivated = "pack.incident.deactivated"; + + /// Incident mode escalated to higher level. + public const string IncidentModeEscalated = "pack.incident.escalated"; + + /// SLO breach detected triggering incident mode. + public const string SloBreachDetected = "pack.incident.slo_breach"; + /// Checks if the event type is a pack run event. public static bool IsPackRunEvent(string eventType) => eventType.StartsWith(Prefix, StringComparison.Ordinal); diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs new file mode 100644 index 000000000..ad934d991 --- /dev/null +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs @@ -0,0 +1,534 @@ +using Microsoft.Extensions.Logging; +using StellaOps.TaskRunner.Core.Events; + +namespace StellaOps.TaskRunner.Core.IncidentMode; + +/// +/// Service for managing pack run incident mode. +/// Per TASKRUN-OBS-55-001. +/// +public interface IPackRunIncidentModeService +{ + /// + /// Activates incident mode for a run. + /// + Task ActivateAsync( + IncidentModeActivationRequest request, + CancellationToken cancellationToken = default); + + /// + /// Deactivates incident mode for a run. + /// + Task DeactivateAsync( + string runId, + string? reason = null, + CancellationToken cancellationToken = default); + + /// + /// Gets the current incident mode status for a run. + /// + Task GetStatusAsync( + string runId, + CancellationToken cancellationToken = default); + + /// + /// Handles an SLO breach notification. + /// + Task HandleSloBreachAsync( + SloBreachNotification notification, + CancellationToken cancellationToken = default); + + /// + /// Escalates incident mode to a higher level. + /// + Task EscalateAsync( + string runId, + IncidentEscalationLevel newLevel, + string? reason = null, + CancellationToken cancellationToken = default); + + /// + /// Gets settings for the current incident mode level. + /// + IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level); +} + +/// +/// Store for incident mode state. +/// +public interface IPackRunIncidentModeStore +{ + /// + /// Stores incident mode status. + /// + Task StoreAsync( + string runId, + PackRunIncidentModeStatus status, + CancellationToken cancellationToken = default); + + /// + /// Gets incident mode status. + /// + Task GetAsync( + string runId, + CancellationToken cancellationToken = default); + + /// + /// Lists all runs in incident mode. + /// + Task> ListActiveRunsAsync( + CancellationToken cancellationToken = default); + + /// + /// Removes incident mode status. + /// + Task RemoveAsync( + string runId, + CancellationToken cancellationToken = default); +} + +/// +/// Settings for incident mode levels. +/// +public sealed record IncidentModeSettings( + /// Escalation level. + IncidentEscalationLevel Level, + + /// Retention policy. + IncidentRetentionPolicy RetentionPolicy, + + /// Telemetry settings. + IncidentTelemetrySettings TelemetrySettings, + + /// Debug capture settings. + IncidentDebugCaptureSettings DebugCaptureSettings); + +/// +/// Default implementation of pack run incident mode service. +/// +public sealed class PackRunIncidentModeService : IPackRunIncidentModeService +{ + private readonly IPackRunIncidentModeStore _store; + private readonly IPackRunTimelineEventEmitter? _timelineEmitter; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public PackRunIncidentModeService( + IPackRunIncidentModeStore store, + ILogger logger, + TimeProvider? timeProvider = null, + IPackRunTimelineEventEmitter? timelineEmitter = null) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; + _timelineEmitter = timelineEmitter; + } + + /// + public async Task ActivateAsync( + IncidentModeActivationRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var now = _timeProvider.GetUtcNow(); + var settings = GetSettingsForLevel(request.Level); + + var expiresAt = request.DurationMinutes.HasValue + ? now.AddMinutes(request.DurationMinutes.Value) + : (DateTimeOffset?)null; + + var status = new PackRunIncidentModeStatus( + Active: true, + Level: request.Level, + ActivatedAt: now, + ActivationReason: request.Reason, + Source: request.Source, + ExpiresAt: expiresAt, + RetentionPolicy: settings.RetentionPolicy, + TelemetrySettings: settings.TelemetrySettings, + DebugCaptureSettings: settings.DebugCaptureSettings); + + await _store.StoreAsync(request.RunId, status, cancellationToken); + + // Emit timeline event + await EmitTimelineEventAsync( + request.TenantId, + request.RunId, + PackRunIncidentEventTypes.IncidentModeActivated, + new Dictionary + { + ["level"] = request.Level.ToString(), + ["source"] = request.Source.ToString(), + ["reason"] = request.Reason, + ["requestedBy"] = request.RequestedBy ?? "system" + }, + cancellationToken); + + _logger.LogWarning( + "Incident mode activated for run {RunId} at level {Level} due to: {Reason}", + request.RunId, + request.Level, + request.Reason); + + return new IncidentModeActivationResult( + Success: true, + Status: status, + Error: null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to activate incident mode for run {RunId}", request.RunId); + + return new IncidentModeActivationResult( + Success: false, + Status: PackRunIncidentModeStatus.Inactive(), + Error: ex.Message); + } + } + + /// + public async Task DeactivateAsync( + string runId, + string? reason = null, + CancellationToken cancellationToken = default) + { + try + { + var current = await _store.GetAsync(runId, cancellationToken); + if (current is null || !current.Active) + { + return new IncidentModeActivationResult( + Success: true, + Status: PackRunIncidentModeStatus.Inactive(), + Error: null); + } + + await _store.RemoveAsync(runId, cancellationToken); + var inactive = PackRunIncidentModeStatus.Inactive(); + + // Emit timeline event (using default tenant since we don't have it) + await EmitTimelineEventAsync( + "default", + runId, + PackRunIncidentEventTypes.IncidentModeDeactivated, + new Dictionary + { + ["previousLevel"] = current.Level.ToString(), + ["reason"] = reason ?? "Manual deactivation", + ["activeDuration"] = current.ActivatedAt.HasValue + ? (_timeProvider.GetUtcNow() - current.ActivatedAt.Value).ToString() + : "unknown" + }, + cancellationToken); + + _logger.LogInformation( + "Incident mode deactivated for run {RunId}. Reason: {Reason}", + runId, + reason ?? "Manual deactivation"); + + return new IncidentModeActivationResult( + Success: true, + Status: inactive, + Error: null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deactivate incident mode for run {RunId}", runId); + + return new IncidentModeActivationResult( + Success: false, + Status: PackRunIncidentModeStatus.Inactive(), + Error: ex.Message); + } + } + + /// + public async Task GetStatusAsync( + string runId, + CancellationToken cancellationToken = default) + { + var status = await _store.GetAsync(runId, cancellationToken); + + if (status is null) + { + return PackRunIncidentModeStatus.Inactive(); + } + + // Check if expired + if (status.ExpiresAt.HasValue && status.ExpiresAt.Value <= _timeProvider.GetUtcNow()) + { + await _store.RemoveAsync(runId, cancellationToken); + return PackRunIncidentModeStatus.Inactive(); + } + + return status; + } + + /// + public async Task HandleSloBreachAsync( + SloBreachNotification notification, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + + if (string.IsNullOrWhiteSpace(notification.ResourceId)) + { + _logger.LogWarning( + "Received SLO breach notification {BreachId} without resource ID, skipping incident activation", + notification.BreachId); + + return new IncidentModeActivationResult( + Success: false, + Status: PackRunIncidentModeStatus.Inactive(), + Error: "No resource ID in SLO breach notification"); + } + + // Map severity to escalation level + var level = notification.Severity?.ToUpperInvariant() switch + { + "CRITICAL" => IncidentEscalationLevel.Critical, + "HIGH" => IncidentEscalationLevel.High, + "MEDIUM" => IncidentEscalationLevel.Medium, + "LOW" => IncidentEscalationLevel.Low, + _ => IncidentEscalationLevel.Medium + }; + + var request = new IncidentModeActivationRequest( + RunId: notification.ResourceId, + TenantId: notification.TenantId ?? "default", + Level: level, + Source: IncidentModeSource.SloBreach, + Reason: $"SLO breach: {notification.SloName} ({notification.CurrentValue:F2} vs threshold {notification.Threshold:F2})", + DurationMinutes: 60, // Auto-expire after 1 hour + RequestedBy: "slo-monitor"); + + _logger.LogWarning( + "Processing SLO breach {BreachId} for {SloName} on resource {ResourceId}", + notification.BreachId, + notification.SloName, + notification.ResourceId); + + return await ActivateAsync(request, cancellationToken); + } + + /// + public async Task EscalateAsync( + string runId, + IncidentEscalationLevel newLevel, + string? reason = null, + CancellationToken cancellationToken = default) + { + var current = await _store.GetAsync(runId, cancellationToken); + + if (current is null || !current.Active) + { + return new IncidentModeActivationResult( + Success: false, + Status: PackRunIncidentModeStatus.Inactive(), + Error: "Incident mode is not active for this run"); + } + + if (newLevel <= current.Level) + { + return new IncidentModeActivationResult( + Success: false, + Status: current, + Error: $"Cannot escalate to {newLevel} - current level is {current.Level}"); + } + + var settings = GetSettingsForLevel(newLevel); + var now = _timeProvider.GetUtcNow(); + + var escalated = current with + { + Level = newLevel, + ActivationReason = $"{current.ActivationReason} [Escalated: {reason ?? "Manual escalation"}]", + RetentionPolicy = settings.RetentionPolicy, + TelemetrySettings = settings.TelemetrySettings, + DebugCaptureSettings = settings.DebugCaptureSettings + }; + + await _store.StoreAsync(runId, escalated, cancellationToken); + + // Emit timeline event + await EmitTimelineEventAsync( + "default", + runId, + PackRunIncidentEventTypes.IncidentModeEscalated, + new Dictionary + { + ["previousLevel"] = current.Level.ToString(), + ["newLevel"] = newLevel.ToString(), + ["reason"] = reason ?? "Manual escalation" + }, + cancellationToken); + + _logger.LogWarning( + "Incident mode escalated for run {RunId} from {OldLevel} to {NewLevel}. Reason: {Reason}", + runId, + current.Level, + newLevel, + reason ?? "Manual escalation"); + + return new IncidentModeActivationResult( + Success: true, + Status: escalated, + Error: null); + } + + /// + public IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level) => level switch + { + IncidentEscalationLevel.None => new IncidentModeSettings( + level, + IncidentRetentionPolicy.Default(), + IncidentTelemetrySettings.Default(), + IncidentDebugCaptureSettings.Default()), + + IncidentEscalationLevel.Low => new IncidentModeSettings( + level, + IncidentRetentionPolicy.Default() with { LogRetentionDays = 30 }, + IncidentTelemetrySettings.Default() with + { + EnhancedTelemetryActive = true, + LogVerbosity = IncidentLogVerbosity.Verbose, + TraceSamplingRate = 0.5 + }, + IncidentDebugCaptureSettings.Default()), + + IncidentEscalationLevel.Medium => new IncidentModeSettings( + level, + IncidentRetentionPolicy.Extended(), + IncidentTelemetrySettings.Enhanced(), + IncidentDebugCaptureSettings.Basic()), + + IncidentEscalationLevel.High => new IncidentModeSettings( + level, + IncidentRetentionPolicy.Extended() with { LogRetentionDays = 180, ArtifactRetentionDays = 365 }, + IncidentTelemetrySettings.Enhanced() with { LogVerbosity = IncidentLogVerbosity.Debug }, + IncidentDebugCaptureSettings.Full()), + + IncidentEscalationLevel.Critical => new IncidentModeSettings( + level, + IncidentRetentionPolicy.Maximum(), + IncidentTelemetrySettings.Maximum(), + IncidentDebugCaptureSettings.Full() with { MaxCaptureSizeMb = 1000 }), + + _ => throw new ArgumentOutOfRangeException(nameof(level)) + }; + + private async Task EmitTimelineEventAsync( + string tenantId, + string runId, + string eventType, + IReadOnlyDictionary attributes, + CancellationToken cancellationToken) + { + if (_timelineEmitter is null) return; + + await _timelineEmitter.EmitAsync( + PackRunTimelineEvent.Create( + tenantId: tenantId, + eventType: eventType, + source: "taskrunner-incident-mode", + occurredAt: _timeProvider.GetUtcNow(), + runId: runId, + severity: PackRunEventSeverity.Warning, + attributes: attributes), + cancellationToken); + } +} + +/// +/// Incident mode timeline event types. +/// +public static class PackRunIncidentEventTypes +{ + /// Incident mode activated. + public const string IncidentModeActivated = "pack.incident.activated"; + + /// Incident mode deactivated. + public const string IncidentModeDeactivated = "pack.incident.deactivated"; + + /// Incident mode escalated. + public const string IncidentModeEscalated = "pack.incident.escalated"; + + /// SLO breach detected. + public const string SloBreachDetected = "pack.incident.slo_breach"; +} + +/// +/// In-memory incident mode store for testing. +/// +public sealed class InMemoryPackRunIncidentModeStore : IPackRunIncidentModeStore +{ + private readonly Dictionary _statuses = new(); + private readonly object _lock = new(); + + /// + public Task StoreAsync( + string runId, + PackRunIncidentModeStatus status, + CancellationToken cancellationToken = default) + { + lock (_lock) + { + _statuses[runId] = status; + } + return Task.CompletedTask; + } + + /// + public Task GetAsync( + string runId, + CancellationToken cancellationToken = default) + { + lock (_lock) + { + _statuses.TryGetValue(runId, out var status); + return Task.FromResult(status); + } + } + + /// + public Task> ListActiveRunsAsync( + CancellationToken cancellationToken = default) + { + lock (_lock) + { + var active = _statuses + .Where(kvp => kvp.Value.Active) + .Select(kvp => kvp.Key) + .ToList(); + return Task.FromResult>(active); + } + } + + /// + public Task RemoveAsync( + string runId, + CancellationToken cancellationToken = default) + { + lock (_lock) + { + _statuses.Remove(runId); + } + return Task.CompletedTask; + } + + /// Gets count of stored statuses. + public int Count + { + get { lock (_lock) { return _statuses.Count; } } + } + + /// Clears all statuses. + public void Clear() + { + lock (_lock) { _statuses.Clear(); } + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs new file mode 100644 index 000000000..b9aaf0310 --- /dev/null +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs @@ -0,0 +1,363 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.TaskRunner.Core.IncidentMode; + +/// +/// Incident mode status for a pack run. +/// Per TASKRUN-OBS-55-001. +/// +public sealed record PackRunIncidentModeStatus( + /// Whether incident mode is active. + bool Active, + + /// Current escalation level. + IncidentEscalationLevel Level, + + /// When incident mode was activated. + DateTimeOffset? ActivatedAt, + + /// Reason for activation. + string? ActivationReason, + + /// Source of activation (SLO breach, manual, etc.). + IncidentModeSource Source, + + /// When incident mode will auto-deactivate (if set). + DateTimeOffset? ExpiresAt, + + /// Current retention policy in effect. + IncidentRetentionPolicy RetentionPolicy, + + /// Active telemetry escalation settings. + IncidentTelemetrySettings TelemetrySettings, + + /// Debug artifact capture settings. + IncidentDebugCaptureSettings DebugCaptureSettings) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + /// + /// Creates a default inactive status. + /// + public static PackRunIncidentModeStatus Inactive() => new( + Active: false, + Level: IncidentEscalationLevel.None, + ActivatedAt: null, + ActivationReason: null, + Source: IncidentModeSource.None, + ExpiresAt: null, + RetentionPolicy: IncidentRetentionPolicy.Default(), + TelemetrySettings: IncidentTelemetrySettings.Default(), + DebugCaptureSettings: IncidentDebugCaptureSettings.Default()); + + /// + /// Serializes to JSON. + /// + public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); +} + +/// +/// Incident escalation levels. +/// +public enum IncidentEscalationLevel +{ + /// No incident mode. + None = 0, + + /// Low severity - enhanced logging. + Low = 1, + + /// Medium severity - debug capture enabled. + Medium = 2, + + /// High severity - full debug + extended retention. + High = 3, + + /// Critical - maximum telemetry + indefinite retention. + Critical = 4 +} + +/// +/// Source of incident mode activation. +/// +public enum IncidentModeSource +{ + /// No incident mode. + None, + + /// Activated manually by operator. + Manual, + + /// Activated by SLO breach webhook. + SloBreach, + + /// Activated by error rate threshold. + ErrorRate, + + /// Activated by policy evaluation. + PolicyTrigger, + + /// Activated by external system. + External +} + +/// +/// Retention policy during incident mode. +/// +public sealed record IncidentRetentionPolicy( + /// Whether extended retention is active. + bool ExtendedRetentionActive, + + /// Log retention in days. + int LogRetentionDays, + + /// Artifact retention in days. + int ArtifactRetentionDays, + + /// Debug capture retention in days. + int DebugCaptureRetentionDays, + + /// Trace retention in days. + int TraceRetentionDays) +{ + /// Default retention policy. + public static IncidentRetentionPolicy Default() => new( + ExtendedRetentionActive: false, + LogRetentionDays: 7, + ArtifactRetentionDays: 30, + DebugCaptureRetentionDays: 3, + TraceRetentionDays: 7); + + /// Extended retention for incident mode. + public static IncidentRetentionPolicy Extended() => new( + ExtendedRetentionActive: true, + LogRetentionDays: 90, + ArtifactRetentionDays: 180, + DebugCaptureRetentionDays: 30, + TraceRetentionDays: 90); + + /// Maximum retention for critical incidents. + public static IncidentRetentionPolicy Maximum() => new( + ExtendedRetentionActive: true, + LogRetentionDays: 365, + ArtifactRetentionDays: 365, + DebugCaptureRetentionDays: 90, + TraceRetentionDays: 365); +} + +/// +/// Telemetry settings during incident mode. +/// +public sealed record IncidentTelemetrySettings( + /// Whether enhanced telemetry is active. + bool EnhancedTelemetryActive, + + /// Log verbosity level. + IncidentLogVerbosity LogVerbosity, + + /// Trace sampling rate (0.0 to 1.0). + double TraceSamplingRate, + + /// Whether to capture environment variables. + bool CaptureEnvironment, + + /// Whether to capture step inputs/outputs. + bool CaptureStepIo, + + /// Whether to capture network calls. + bool CaptureNetworkCalls, + + /// Maximum trace spans per step. + int MaxTraceSpansPerStep) +{ + /// Default telemetry settings. + public static IncidentTelemetrySettings Default() => new( + EnhancedTelemetryActive: false, + LogVerbosity: IncidentLogVerbosity.Normal, + TraceSamplingRate: 0.1, + CaptureEnvironment: false, + CaptureStepIo: false, + CaptureNetworkCalls: false, + MaxTraceSpansPerStep: 100); + + /// Enhanced telemetry for incident mode. + public static IncidentTelemetrySettings Enhanced() => new( + EnhancedTelemetryActive: true, + LogVerbosity: IncidentLogVerbosity.Verbose, + TraceSamplingRate: 1.0, + CaptureEnvironment: true, + CaptureStepIo: true, + CaptureNetworkCalls: true, + MaxTraceSpansPerStep: 1000); + + /// Maximum telemetry for debugging. + public static IncidentTelemetrySettings Maximum() => new( + EnhancedTelemetryActive: true, + LogVerbosity: IncidentLogVerbosity.Debug, + TraceSamplingRate: 1.0, + CaptureEnvironment: true, + CaptureStepIo: true, + CaptureNetworkCalls: true, + MaxTraceSpansPerStep: 10000); +} + +/// +/// Log verbosity levels for incident mode. +/// +public enum IncidentLogVerbosity +{ + /// Minimal logging (errors only). + Minimal, + + /// Normal logging. + Normal, + + /// Verbose logging. + Verbose, + + /// Debug logging (maximum detail). + Debug +} + +/// +/// Debug artifact capture settings. +/// +public sealed record IncidentDebugCaptureSettings( + /// Whether debug capture is active. + bool CaptureActive, + + /// Whether to capture heap dumps. + bool CaptureHeapDumps, + + /// Whether to capture thread dumps. + bool CaptureThreadDumps, + + /// Whether to capture profiling data. + bool CaptureProfilingData, + + /// Whether to capture system metrics. + bool CaptureSystemMetrics, + + /// Maximum capture size in MB. + int MaxCaptureSizeMb, + + /// Capture interval in seconds. + int CaptureIntervalSeconds) +{ + /// Default capture settings (disabled). + public static IncidentDebugCaptureSettings Default() => new( + CaptureActive: false, + CaptureHeapDumps: false, + CaptureThreadDumps: false, + CaptureProfilingData: false, + CaptureSystemMetrics: false, + MaxCaptureSizeMb: 0, + CaptureIntervalSeconds: 0); + + /// Basic debug capture. + public static IncidentDebugCaptureSettings Basic() => new( + CaptureActive: true, + CaptureHeapDumps: false, + CaptureThreadDumps: true, + CaptureProfilingData: false, + CaptureSystemMetrics: true, + MaxCaptureSizeMb: 100, + CaptureIntervalSeconds: 60); + + /// Full debug capture. + public static IncidentDebugCaptureSettings Full() => new( + CaptureActive: true, + CaptureHeapDumps: true, + CaptureThreadDumps: true, + CaptureProfilingData: true, + CaptureSystemMetrics: true, + MaxCaptureSizeMb: 500, + CaptureIntervalSeconds: 30); +} + +/// +/// SLO breach notification payload. +/// +public sealed record SloBreachNotification( + /// Breach identifier. + [property: JsonPropertyName("breachId")] + string BreachId, + + /// SLO that was breached. + [property: JsonPropertyName("sloName")] + string SloName, + + /// Breach severity. + [property: JsonPropertyName("severity")] + string Severity, + + /// When the breach occurred. + [property: JsonPropertyName("occurredAt")] + DateTimeOffset OccurredAt, + + /// Current metric value. + [property: JsonPropertyName("currentValue")] + double CurrentValue, + + /// Threshold that was breached. + [property: JsonPropertyName("threshold")] + double Threshold, + + /// Target metric value. + [property: JsonPropertyName("target")] + double Target, + + /// Affected resource (run ID, step ID, etc.). + [property: JsonPropertyName("resourceId")] + string? ResourceId, + + /// Affected tenant. + [property: JsonPropertyName("tenantId")] + string? TenantId, + + /// Additional context. + [property: JsonPropertyName("context")] + IReadOnlyDictionary? Context); + +/// +/// Request to activate incident mode. +/// +public sealed record IncidentModeActivationRequest( + /// Run ID to activate incident mode for. + string RunId, + + /// Tenant ID. + string TenantId, + + /// Escalation level to activate. + IncidentEscalationLevel Level, + + /// Activation source. + IncidentModeSource Source, + + /// Reason for activation. + string Reason, + + /// Duration in minutes (null for indefinite). + int? DurationMinutes, + + /// Operator or system that requested activation. + string? RequestedBy); + +/// +/// Result of incident mode activation. +/// +public sealed record IncidentModeActivationResult( + /// Whether activation succeeded. + bool Success, + + /// Current incident mode status. + PackRunIncidentModeStatus Status, + + /// Error message if activation failed. + string? Error); diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs new file mode 100644 index 000000000..16b803b73 --- /dev/null +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs @@ -0,0 +1,396 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using StellaOps.TaskRunner.Core.Events; +using StellaOps.TaskRunner.Core.IncidentMode; + +namespace StellaOps.TaskRunner.Tests; + +public sealed class PackRunIncidentModeTests +{ + [Fact] + public async Task ActivateAsync_ActivatesIncidentModeSuccessfully() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + var request = new IncidentModeActivationRequest( + RunId: "run-001", + TenantId: "tenant-1", + Level: IncidentEscalationLevel.Medium, + Source: IncidentModeSource.Manual, + Reason: "Debugging production issue", + DurationMinutes: 60, + RequestedBy: "admin@example.com"); + + var result = await service.ActivateAsync(request, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.True(result.Status.Active); + Assert.Equal(IncidentEscalationLevel.Medium, result.Status.Level); + Assert.Equal(IncidentModeSource.Manual, result.Status.Source); + Assert.NotNull(result.Status.ActivatedAt); + Assert.NotNull(result.Status.ExpiresAt); + } + + [Fact] + public async Task ActivateAsync_WithoutDuration_CreatesIndefiniteIncidentMode() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + var request = new IncidentModeActivationRequest( + RunId: "run-002", + TenantId: "tenant-1", + Level: IncidentEscalationLevel.High, + Source: IncidentModeSource.Manual, + Reason: "Critical investigation", + DurationMinutes: null, + RequestedBy: null); + + var result = await service.ActivateAsync(request, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Null(result.Status.ExpiresAt); + } + + [Fact] + public async Task ActivateAsync_EmitsTimelineEvent() + { + var store = new InMemoryPackRunIncidentModeStore(); + var timelineSink = new InMemoryPackRunTimelineEventSink(); + var emitter = new PackRunTimelineEventEmitter( + timelineSink, + TimeProvider.System, + NullLogger.Instance); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance, + null, + emitter); + + var request = new IncidentModeActivationRequest( + RunId: "run-003", + TenantId: "tenant-1", + Level: IncidentEscalationLevel.Low, + Source: IncidentModeSource.Manual, + Reason: "Test", + DurationMinutes: 30, + RequestedBy: null); + + await service.ActivateAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(1, timelineSink.Count); + var evt = timelineSink.GetEvents()[0]; + Assert.Equal(PackRunIncidentEventTypes.IncidentModeActivated, evt.EventType); + } + + [Fact] + public async Task DeactivateAsync_DeactivatesIncidentMode() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + // First activate + var activateRequest = new IncidentModeActivationRequest( + RunId: "run-004", + TenantId: "tenant-1", + Level: IncidentEscalationLevel.Medium, + Source: IncidentModeSource.Manual, + Reason: "Test", + DurationMinutes: null, + RequestedBy: null); + + await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken); + + // Then deactivate + var result = await service.DeactivateAsync("run-004", "Issue resolved", TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.False(result.Status.Active); + + var status = await service.GetStatusAsync("run-004", TestContext.Current.CancellationToken); + Assert.False(status.Active); + } + + [Fact] + public async Task GetStatusAsync_ReturnsInactiveForUnknownRun() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + var status = await service.GetStatusAsync("unknown-run", TestContext.Current.CancellationToken); + + Assert.False(status.Active); + Assert.Equal(IncidentEscalationLevel.None, status.Level); + } + + [Fact] + public async Task GetStatusAsync_AutoDeactivatesExpiredIncidentMode() + { + var store = new InMemoryPackRunIncidentModeStore(); + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance, + fakeTime); + + var request = new IncidentModeActivationRequest( + RunId: "run-005", + TenantId: "tenant-1", + Level: IncidentEscalationLevel.Medium, + Source: IncidentModeSource.Manual, + Reason: "Test", + DurationMinutes: 30, + RequestedBy: null); + + await service.ActivateAsync(request, TestContext.Current.CancellationToken); + + // Advance time past expiration + fakeTime.Advance(TimeSpan.FromMinutes(31)); + + var status = await service.GetStatusAsync("run-005", TestContext.Current.CancellationToken); + + Assert.False(status.Active); + } + + [Fact] + public async Task HandleSloBreachAsync_ActivatesIncidentModeFromBreach() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + var breach = new SloBreachNotification( + BreachId: "breach-001", + SloName: "error_rate_5m", + Severity: "HIGH", + OccurredAt: DateTimeOffset.UtcNow, + CurrentValue: 15.5, + Threshold: 5.0, + Target: 1.0, + ResourceId: "run-006", + TenantId: "tenant-1", + Context: new Dictionary { ["step"] = "scan" }); + + var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.True(result.Status.Active); + Assert.Equal(IncidentEscalationLevel.High, result.Status.Level); + Assert.Equal(IncidentModeSource.SloBreach, result.Status.Source); + Assert.Contains("error_rate_5m", result.Status.ActivationReason!); + } + + [Fact] + public async Task HandleSloBreachAsync_MapsSeverityToLevel() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + var severityToLevel = new Dictionary + { + ["CRITICAL"] = IncidentEscalationLevel.Critical, + ["HIGH"] = IncidentEscalationLevel.High, + ["MEDIUM"] = IncidentEscalationLevel.Medium, + ["LOW"] = IncidentEscalationLevel.Low + }; + + var runIndex = 0; + foreach (var (severity, expectedLevel) in severityToLevel) + { + var breach = new SloBreachNotification( + BreachId: $"breach-{runIndex}", + SloName: "test_slo", + Severity: severity, + OccurredAt: DateTimeOffset.UtcNow, + CurrentValue: 10.0, + Threshold: 5.0, + Target: 1.0, + ResourceId: $"run-severity-{runIndex++}", + TenantId: "tenant-1", + Context: null); + + var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(expectedLevel, result.Status.Level); + } + } + + [Fact] + public async Task HandleSloBreachAsync_ReturnsErrorForMissingResourceId() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + var breach = new SloBreachNotification( + BreachId: "breach-no-resource", + SloName: "test_slo", + Severity: "HIGH", + OccurredAt: DateTimeOffset.UtcNow, + CurrentValue: 10.0, + Threshold: 5.0, + Target: 1.0, + ResourceId: null, + TenantId: "tenant-1", + Context: null); + + var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains("No resource ID", result.Error); + } + + [Fact] + public async Task EscalateAsync_IncreasesEscalationLevel() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + // First activate at Low level + var activateRequest = new IncidentModeActivationRequest( + RunId: "run-escalate", + TenantId: "tenant-1", + Level: IncidentEscalationLevel.Low, + Source: IncidentModeSource.Manual, + Reason: "Initial activation", + DurationMinutes: null, + RequestedBy: null); + + await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken); + + // Escalate to High + var result = await service.EscalateAsync( + "run-escalate", + IncidentEscalationLevel.High, + "Issue is more severe than expected", + TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(IncidentEscalationLevel.High, result.Status.Level); + Assert.Contains("Escalated", result.Status.ActivationReason); + } + + [Fact] + public async Task EscalateAsync_FailsWhenNotInIncidentMode() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + var result = await service.EscalateAsync( + "unknown-run", + IncidentEscalationLevel.High, + null, + TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains("not active", result.Error); + } + + [Fact] + public async Task EscalateAsync_FailsWhenNewLevelIsLowerOrEqual() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + var activateRequest = new IncidentModeActivationRequest( + RunId: "run-no-deescalate", + TenantId: "tenant-1", + Level: IncidentEscalationLevel.High, + Source: IncidentModeSource.Manual, + Reason: "Test", + DurationMinutes: null, + RequestedBy: null); + + await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken); + + var result = await service.EscalateAsync( + "run-no-deescalate", + IncidentEscalationLevel.Medium, // Lower than High + null, + TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains("Cannot escalate", result.Error); + } + + [Fact] + public void GetSettingsForLevel_ReturnsCorrectSettings() + { + var store = new InMemoryPackRunIncidentModeStore(); + var service = new PackRunIncidentModeService( + store, + NullLogger.Instance); + + // Test None level + var noneSettings = service.GetSettingsForLevel(IncidentEscalationLevel.None); + Assert.False(noneSettings.TelemetrySettings.EnhancedTelemetryActive); + Assert.False(noneSettings.DebugCaptureSettings.CaptureActive); + + // Test Critical level + var criticalSettings = service.GetSettingsForLevel(IncidentEscalationLevel.Critical); + Assert.True(criticalSettings.TelemetrySettings.EnhancedTelemetryActive); + Assert.Equal(IncidentLogVerbosity.Debug, criticalSettings.TelemetrySettings.LogVerbosity); + Assert.Equal(1.0, criticalSettings.TelemetrySettings.TraceSamplingRate); + Assert.True(criticalSettings.DebugCaptureSettings.CaptureActive); + Assert.True(criticalSettings.DebugCaptureSettings.CaptureHeapDumps); + Assert.Equal(365, criticalSettings.RetentionPolicy.LogRetentionDays); + } + + [Fact] + public void PackRunIncidentModeStatus_Inactive_ReturnsDefaultValues() + { + var inactive = PackRunIncidentModeStatus.Inactive(); + + Assert.False(inactive.Active); + Assert.Equal(IncidentEscalationLevel.None, inactive.Level); + Assert.Null(inactive.ActivatedAt); + Assert.Null(inactive.ActivationReason); + Assert.Equal(IncidentModeSource.None, inactive.Source); + Assert.False(inactive.RetentionPolicy.ExtendedRetentionActive); + Assert.False(inactive.TelemetrySettings.EnhancedTelemetryActive); + Assert.False(inactive.DebugCaptureSettings.CaptureActive); + } + + [Fact] + public void IncidentRetentionPolicy_Extended_HasLongerRetention() + { + var defaultPolicy = IncidentRetentionPolicy.Default(); + var extendedPolicy = IncidentRetentionPolicy.Extended(); + + Assert.True(extendedPolicy.ExtendedRetentionActive); + Assert.True(extendedPolicy.LogRetentionDays > defaultPolicy.LogRetentionDays); + Assert.True(extendedPolicy.ArtifactRetentionDays > defaultPolicy.ArtifactRetentionDays); + } + + [Fact] + public void IncidentTelemetrySettings_Enhanced_HasHigherSampling() + { + var defaultSettings = IncidentTelemetrySettings.Default(); + var enhancedSettings = IncidentTelemetrySettings.Enhanced(); + + Assert.True(enhancedSettings.EnhancedTelemetryActive); + Assert.True(enhancedSettings.TraceSamplingRate > defaultSettings.TraceSamplingRate); + Assert.True(enhancedSettings.CaptureEnvironment); + Assert.True(enhancedSettings.CaptureStepIo); + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs index 894381d52..df5f0eb63 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs @@ -16,6 +16,7 @@ using StellaOps.AirGap.Policy; using StellaOps.TaskRunner.Core.AirGap; using StellaOps.TaskRunner.Core.Attestation; using StellaOps.TaskRunner.Core.Configuration; +using StellaOps.TaskRunner.Core.IncidentMode; using StellaOps.TaskRunner.Core.Events; using StellaOps.TaskRunner.Core.Execution; using StellaOps.TaskRunner.Core.Execution.Simulation; @@ -127,6 +128,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Pack run incident mode (TASKRUN-OBS-55-001) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services.AddOpenApi(); var app = builder.Build(); @@ -230,6 +235,22 @@ app.MapGet("/api/attestations/{attestationId}/envelope", HandleGetAttestationEnv app.MapPost("/v1/task-runner/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestation"); app.MapPost("/api/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestationApi"); +// Incident mode endpoints (TASKRUN-OBS-55-001) +app.MapGet("/v1/task-runner/runs/{runId}/incident-mode", HandleGetIncidentModeStatus).WithName("GetIncidentModeStatus"); +app.MapGet("/api/runs/{runId}/incident-mode", HandleGetIncidentModeStatus).WithName("GetIncidentModeStatusApi"); + +app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode).WithName("ActivateIncidentMode"); +app.MapPost("/api/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode).WithName("ActivateIncidentModeApi"); + +app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode).WithName("DeactivateIncidentMode"); +app.MapPost("/api/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode).WithName("DeactivateIncidentModeApi"); + +app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode).WithName("EscalateIncidentMode"); +app.MapPost("/api/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode).WithName("EscalateIncidentModeApi"); + +app.MapPost("/v1/task-runner/webhooks/slo-breach", HandleSloBreachWebhook).WithName("SloBreachWebhook"); +app.MapPost("/api/webhooks/slo-breach", HandleSloBreachWebhook).WithName("SloBreachWebhookApi"); + app.MapGet("/.well-known/openapi", (HttpResponse response) => { var metadata = OpenApiMetadataFactory.Create("/openapi"); @@ -681,6 +702,175 @@ async Task HandleVerifyAttestation( }, statusCode: statusCode); } +// Incident mode handlers (TASKRUN-OBS-55-001) +async Task HandleGetIncidentModeStatus( + string runId, + IPackRunIncidentModeService incidentModeService, + CancellationToken cancellationToken) +{ + if (string.IsNullOrWhiteSpace(runId)) + { + return Results.BadRequest(new { error = "runId is required." }); + } + + var status = await incidentModeService.GetStatusAsync(runId, cancellationToken).ConfigureAwait(false); + + return Results.Ok(new + { + runId, + active = status.Active, + level = status.Level.ToString().ToLowerInvariant(), + activatedAt = status.ActivatedAt?.ToString("O"), + activationReason = status.ActivationReason, + source = status.Source.ToString().ToLowerInvariant(), + expiresAt = status.ExpiresAt?.ToString("O"), + retentionPolicy = new + { + extendedRetentionActive = status.RetentionPolicy.ExtendedRetentionActive, + logRetentionDays = status.RetentionPolicy.LogRetentionDays, + artifactRetentionDays = status.RetentionPolicy.ArtifactRetentionDays + }, + telemetrySettings = new + { + enhancedTelemetryActive = status.TelemetrySettings.EnhancedTelemetryActive, + logVerbosity = status.TelemetrySettings.LogVerbosity.ToString().ToLowerInvariant(), + traceSamplingRate = status.TelemetrySettings.TraceSamplingRate + }, + debugCaptureSettings = new + { + captureActive = status.DebugCaptureSettings.CaptureActive, + captureHeapDumps = status.DebugCaptureSettings.CaptureHeapDumps, + captureThreadDumps = status.DebugCaptureSettings.CaptureThreadDumps + } + }); +} + +async Task HandleActivateIncidentMode( + string runId, + [FromBody] ActivateIncidentModeRequest? request, + [FromHeader(Name = "X-Tenant-ID")] string? tenantId, + IPackRunIncidentModeService incidentModeService, + CancellationToken cancellationToken) +{ + if (string.IsNullOrWhiteSpace(runId)) + { + return Results.BadRequest(new { error = "runId is required." }); + } + + var level = Enum.TryParse(request?.Level, ignoreCase: true, out var parsedLevel) + ? parsedLevel + : IncidentEscalationLevel.Medium; + + var activationRequest = new IncidentModeActivationRequest( + RunId: runId, + TenantId: tenantId ?? "default", + Level: level, + Source: StellaOps.TaskRunner.Core.IncidentMode.IncidentModeSource.Manual, + Reason: request?.Reason ?? "Manual activation via API", + DurationMinutes: request?.DurationMinutes, + RequestedBy: request?.RequestedBy); + + var result = await incidentModeService.ActivateAsync(activationRequest, cancellationToken).ConfigureAwait(false); + + if (!result.Success) + { + return Results.BadRequest(new { error = result.Error }); + } + + return Results.Ok(new + { + success = result.Success, + active = result.Status.Active, + level = result.Status.Level.ToString().ToLowerInvariant(), + activatedAt = result.Status.ActivatedAt?.ToString("O"), + expiresAt = result.Status.ExpiresAt?.ToString("O") + }); +} + +async Task HandleDeactivateIncidentMode( + string runId, + [FromBody] DeactivateIncidentModeRequest? request, + IPackRunIncidentModeService incidentModeService, + CancellationToken cancellationToken) +{ + if (string.IsNullOrWhiteSpace(runId)) + { + return Results.BadRequest(new { error = "runId is required." }); + } + + var result = await incidentModeService.DeactivateAsync(runId, request?.Reason, cancellationToken) + .ConfigureAwait(false); + + return Results.Ok(new + { + success = result.Success, + active = result.Status.Active + }); +} + +async Task HandleEscalateIncidentMode( + string runId, + [FromBody] EscalateIncidentModeRequest? request, + IPackRunIncidentModeService incidentModeService, + CancellationToken cancellationToken) +{ + if (string.IsNullOrWhiteSpace(runId)) + { + return Results.BadRequest(new { error = "runId is required." }); + } + + if (request is null || string.IsNullOrWhiteSpace(request.Level)) + { + return Results.BadRequest(new { error = "Level is required for escalation." }); + } + + if (!Enum.TryParse(request.Level, ignoreCase: true, out var newLevel)) + { + return Results.BadRequest(new { error = $"Invalid escalation level: {request.Level}" }); + } + + var result = await incidentModeService.EscalateAsync(runId, newLevel, request.Reason, cancellationToken) + .ConfigureAwait(false); + + if (!result.Success) + { + return Results.BadRequest(new { error = result.Error }); + } + + return Results.Ok(new + { + success = result.Success, + level = result.Status.Level.ToString().ToLowerInvariant() + }); +} + +async Task HandleSloBreachWebhook( + [FromBody] SloBreachNotification notification, + IPackRunIncidentModeService incidentModeService, + CancellationToken cancellationToken) +{ + if (notification is null) + { + return Results.BadRequest(new { error = "Notification body is required." }); + } + + var result = await incidentModeService.HandleSloBreachAsync(notification, cancellationToken) + .ConfigureAwait(false); + + if (!result.Success) + { + return Results.BadRequest(new { error = result.Error }); + } + + return Results.Ok(new + { + success = result.Success, + runId = notification.ResourceId, + level = result.Status.Level.ToString().ToLowerInvariant(), + activatedAt = result.Status.ActivatedAt?.ToString("O") + }); +} + app.Run(); static IDictionary? ConvertInputs(JsonObject? node) @@ -712,6 +902,17 @@ internal sealed record VerifyAttestationRequest( internal sealed record VerifyAttestationSubject(string Name, IReadOnlyDictionary? Digest); +// Incident mode API request models (TASKRUN-OBS-55-001) +internal sealed record ActivateIncidentModeRequest( + string? Level, + string? Reason, + int? DurationMinutes, + string? RequestedBy); + +internal sealed record DeactivateIncidentModeRequest(string? Reason); + +internal sealed record EscalateIncidentModeRequest(string Level, string? Reason); + internal sealed record SimulationResponse( string PlanHash, FailurePolicyResponse FailurePolicy, diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/editor/policy-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/editor/policy-editor.component.ts index 21da480c1..c20411431 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-studio/editor/policy-editor.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/editor/policy-editor.component.ts @@ -40,7 +40,7 @@ interface ChecklistItem { imports: [CommonModule, RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+

Policy Studio · Authoring

@@ -640,7 +640,12 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy { ariaLabel: 'Policy DSL editor', }); - const contentDisposable = this.editor.onDidChangeModelContent(() => { + const editor = this.editor; + if (!editor) { + return; + } + + const contentDisposable = editor.onDidChangeModelContent(() => { const value = this.model?.getValue() ?? ''; this.content$.next(value); });