diff --git a/deploy/compose/README.md b/deploy/compose/README.md index 2b66290a2..384478d4b 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -81,6 +81,16 @@ in the `.env` samples match the options bound by `AddSchedulerWorker`: Helm deployments inherit the same defaults from `services.scheduler-worker.env` in `values.yaml`; override them per environment as needed. +### Advisory AI configuration + +`advisory-ai-web` hosts the API/plan cache while `advisory-ai-worker` executes queued tasks. Both containers mount the shared volumes (`advisory-ai-queue`, `advisory-ai-plans`, `advisory-ai-outputs`) so they always read/write the same deterministic state. New environment knobs: + +- `ADVISORY_AI_SBOM_BASEADDRESS` – endpoint the SBOM context client hits (defaults to the in-cluster Scanner URL). +- `ADVISORY_AI_INFERENCE_MODE` – `Local` (default) keeps inference on-prem; `Remote` posts sanitized prompts to the URL supplied via `ADVISORY_AI_REMOTE_BASEADDRESS`. Optional `ADVISORY_AI_REMOTE_APIKEY` carries the bearer token when remote inference is enabled. +- `ADVISORY_AI_WEB_PORT` – host port for `advisory-ai-web`. + +The Helm chart mirrors these settings under `services.advisory-ai-web` / `advisory-ai-worker` and expects a PVC named `stellaops-advisory-ai-data` so both deployments can mount the same RWX volume. + ### Front-door network hand-off `docker-compose.prod.yaml` adds a `frontdoor` network so operators can attach Traefik, Envoy, or an on-prem load balancer that terminates TLS. Override `FRONTDOOR_NETWORK` in `prod.env` if your reverse proxy uses a different bridge name. Attach only the externally reachable services (Authority, Signer, Attestor, Concelier, Scanner Web, Notify Web, UI) to that network—internal infrastructure (Mongo, MinIO, RustFS, NATS) stays on the private `stellaops` network. diff --git a/deploy/compose/docker-compose.airgap.yaml b/deploy/compose/docker-compose.airgap.yaml index 4ef54dfaa..89673149e 100644 --- a/deploy/compose/docker-compose.airgap.yaml +++ b/deploy/compose/docker-compose.airgap.yaml @@ -243,18 +243,62 @@ services: - stellaops labels: *release-labels - excititor: - image: registry.stella-ops.org/stellaops/excititor@sha256:65c0ee13f773efe920d7181512349a09d363ab3f3e177d276136bd2742325a68 - restart: unless-stopped - depends_on: - - concelier - environment: - EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" - EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" - networks: - - stellaops - labels: *release-labels - + excititor: + image: registry.stella-ops.org/stellaops/excititor@sha256:65c0ee13f773efe920d7181512349a09d363ab3f3e177d276136bd2742325a68 + restart: unless-stopped + depends_on: + - concelier + environment: + EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" + EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" + networks: + - stellaops + labels: *release-labels + + advisory-ai-web: + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.09.2-airgap + restart: unless-stopped + depends_on: + - scanner-web + environment: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: "${ADVISORY_AI_SBOM_BASEADDRESS:-http://scanner-web:8444}" + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: "/var/lib/advisory-ai/queue" + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: "/var/lib/advisory-ai/plans" + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: "/var/lib/advisory-ai/outputs" + ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + ports: + - "${ADVISORY_AI_WEB_PORT:-8448}:8448" + volumes: + - advisory-ai-queue:/var/lib/advisory-ai/queue + - advisory-ai-plans:/var/lib/advisory-ai/plans + - advisory-ai-outputs:/var/lib/advisory-ai/outputs + networks: + - stellaops + labels: *release-labels + + advisory-ai-worker: + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.09.2-airgap + restart: unless-stopped + depends_on: + - advisory-ai-web + environment: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: "${ADVISORY_AI_SBOM_BASEADDRESS:-http://scanner-web:8444}" + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: "/var/lib/advisory-ai/queue" + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: "/var/lib/advisory-ai/plans" + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: "/var/lib/advisory-ai/outputs" + ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + volumes: + - advisory-ai-queue:/var/lib/advisory-ai/queue + - advisory-ai-plans:/var/lib/advisory-ai/plans + - advisory-ai-outputs:/var/lib/advisory-ai/outputs + networks: + - stellaops + labels: *release-labels + web-ui: image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d restart: unless-stopped diff --git a/deploy/compose/docker-compose.dev.yaml b/deploy/compose/docker-compose.dev.yaml index 5f08726b2..0df5848e9 100644 --- a/deploy/compose/docker-compose.dev.yaml +++ b/deploy/compose/docker-compose.dev.yaml @@ -13,6 +13,9 @@ volumes: rustfs-data: concelier-jobs: nats-data: + advisory-ai-queue: + advisory-ai-plans: + advisory-ai-outputs: services: mongo: @@ -241,23 +244,67 @@ services: - stellaops labels: *release-labels - excititor: - image: registry.stella-ops.org/stellaops/excititor@sha256:d9bd5cadf1eab427447ce3df7302c30ded837239771cc6433b9befb895054285 - restart: unless-stopped - depends_on: - - concelier - environment: - EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" - EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" - networks: - - stellaops - labels: *release-labels - - web-ui: - image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf - restart: unless-stopped - depends_on: - - scanner-web + excititor: + image: registry.stella-ops.org/stellaops/excititor@sha256:d9bd5cadf1eab427447ce3df7302c30ded837239771cc6433b9befb895054285 + restart: unless-stopped + depends_on: + - concelier + environment: + EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" + EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" + networks: + - stellaops + labels: *release-labels + + advisory-ai-web: + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.10.0-edge + restart: unless-stopped + depends_on: + - scanner-web + environment: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: "${ADVISORY_AI_SBOM_BASEADDRESS:-http://scanner-web:8444}" + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: "/var/lib/advisory-ai/queue" + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: "/var/lib/advisory-ai/plans" + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: "/var/lib/advisory-ai/outputs" + ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + ports: + - "${ADVISORY_AI_WEB_PORT:-8448}:8448" + volumes: + - advisory-ai-queue:/var/lib/advisory-ai/queue + - advisory-ai-plans:/var/lib/advisory-ai/plans + - advisory-ai-outputs:/var/lib/advisory-ai/outputs + networks: + - stellaops + labels: *release-labels + + advisory-ai-worker: + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.10.0-edge + restart: unless-stopped + depends_on: + - advisory-ai-web + environment: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: "${ADVISORY_AI_SBOM_BASEADDRESS:-http://scanner-web:8444}" + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: "/var/lib/advisory-ai/queue" + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: "/var/lib/advisory-ai/plans" + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: "/var/lib/advisory-ai/outputs" + ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + volumes: + - advisory-ai-queue:/var/lib/advisory-ai/queue + - advisory-ai-plans:/var/lib/advisory-ai/plans + - advisory-ai-outputs:/var/lib/advisory-ai/outputs + networks: + - stellaops + labels: *release-labels + + web-ui: + image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf + restart: unless-stopped + depends_on: + - scanner-web environment: STELLAOPS_UI__BACKEND__BASEURL: "https://scanner-web:8444" ports: diff --git a/deploy/compose/docker-compose.prod.yaml b/deploy/compose/docker-compose.prod.yaml index 70ed57ca9..a4b2ff989 100644 --- a/deploy/compose/docker-compose.prod.yaml +++ b/deploy/compose/docker-compose.prod.yaml @@ -10,12 +10,15 @@ networks: external: true name: ${FRONTDOOR_NETWORK:-stellaops_frontdoor} -volumes: - mongo-data: - minio-data: - rustfs-data: - concelier-jobs: - nats-data: +volumes: + mongo-data: + minio-data: + rustfs-data: + concelier-jobs: + nats-data: + advisory-ai-queue: + advisory-ai-plans: + advisory-ai-outputs: services: mongo: @@ -250,19 +253,64 @@ services: - frontdoor labels: *release-labels - excititor: - image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa - restart: unless-stopped - depends_on: - - concelier - environment: - EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" - EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" - networks: - - stellaops - labels: *release-labels - - web-ui: + excititor: + image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa + restart: unless-stopped + depends_on: + - concelier + environment: + EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" + EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" + networks: + - stellaops + labels: *release-labels + + advisory-ai-web: + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.09.2 + restart: unless-stopped + depends_on: + - scanner-web + environment: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: "${ADVISORY_AI_SBOM_BASEADDRESS:-http://scanner-web:8444}" + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: "/var/lib/advisory-ai/queue" + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: "/var/lib/advisory-ai/plans" + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: "/var/lib/advisory-ai/outputs" + ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + ports: + - "${ADVISORY_AI_WEB_PORT:-8448}:8448" + volumes: + - advisory-ai-queue:/var/lib/advisory-ai/queue + - advisory-ai-plans:/var/lib/advisory-ai/plans + - advisory-ai-outputs:/var/lib/advisory-ai/outputs + networks: + - stellaops + - frontdoor + labels: *release-labels + + advisory-ai-worker: + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.09.2 + restart: unless-stopped + depends_on: + - advisory-ai-web + environment: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: "${ADVISORY_AI_SBOM_BASEADDRESS:-http://scanner-web:8444}" + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: "/var/lib/advisory-ai/queue" + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: "/var/lib/advisory-ai/plans" + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: "/var/lib/advisory-ai/outputs" + ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + volumes: + - advisory-ai-queue:/var/lib/advisory-ai/queue + - advisory-ai-plans:/var/lib/advisory-ai/plans + - advisory-ai-outputs:/var/lib/advisory-ai/outputs + networks: + - stellaops + labels: *release-labels + + web-ui: image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23 restart: unless-stopped depends_on: diff --git a/deploy/compose/docker-compose.stage.yaml b/deploy/compose/docker-compose.stage.yaml index 112dd93dc..f81b18069 100644 --- a/deploy/compose/docker-compose.stage.yaml +++ b/deploy/compose/docker-compose.stage.yaml @@ -13,6 +13,9 @@ volumes: rustfs-data: concelier-jobs: nats-data: + advisory-ai-queue: + advisory-ai-plans: + advisory-ai-outputs: services: mongo: @@ -241,19 +244,63 @@ services: - stellaops labels: *release-labels - excititor: - image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa - restart: unless-stopped - depends_on: - - concelier + excititor: + image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa + restart: unless-stopped + depends_on: + - concelier environment: EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" networks: - stellaops - labels: *release-labels - - web-ui: + labels: *release-labels + + advisory-ai-web: + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.09.2 + restart: unless-stopped + depends_on: + - scanner-web + environment: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: "${ADVISORY_AI_SBOM_BASEADDRESS:-http://scanner-web:8444}" + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: "/var/lib/advisory-ai/queue" + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: "/var/lib/advisory-ai/plans" + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: "/var/lib/advisory-ai/outputs" + ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + ports: + - "${ADVISORY_AI_WEB_PORT:-8448}:8448" + volumes: + - advisory-ai-queue:/var/lib/advisory-ai/queue + - advisory-ai-plans:/var/lib/advisory-ai/plans + - advisory-ai-outputs:/var/lib/advisory-ai/outputs + networks: + - stellaops + labels: *release-labels + + advisory-ai-worker: + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.09.2 + restart: unless-stopped + depends_on: + - advisory-ai-web + environment: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: "${ADVISORY_AI_SBOM_BASEADDRESS:-http://scanner-web:8444}" + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: "/var/lib/advisory-ai/queue" + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: "/var/lib/advisory-ai/plans" + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: "/var/lib/advisory-ai/outputs" + ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + volumes: + - advisory-ai-queue:/var/lib/advisory-ai/queue + - advisory-ai-plans:/var/lib/advisory-ai/plans + - advisory-ai-outputs:/var/lib/advisory-ai/outputs + networks: + - stellaops + labels: *release-labels + + web-ui: image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23 restart: unless-stopped depends_on: diff --git a/deploy/compose/env/airgap.env.example b/deploy/compose/env/airgap.env.example index 2a3581a77..27c46cd4a 100644 --- a/deploy/compose/env/airgap.env.example +++ b/deploy/compose/env/airgap.env.example @@ -35,3 +35,8 @@ SCHEDULER_QUEUE_KIND=Nats SCHEDULER_QUEUE_NATS_URL=nats://nats:4222 SCHEDULER_STORAGE_DATABASE=stellaops_scheduler SCHEDULER_SCANNER_BASEADDRESS=http://scanner-web:8444 +ADVISORY_AI_WEB_PORT=8448 +ADVISORY_AI_SBOM_BASEADDRESS=http://scanner-web:8444 +ADVISORY_AI_INFERENCE_MODE=Local +ADVISORY_AI_REMOTE_BASEADDRESS= +ADVISORY_AI_REMOTE_APIKEY= diff --git a/deploy/compose/env/dev.env.example b/deploy/compose/env/dev.env.example index 988070b18..414c23490 100644 --- a/deploy/compose/env/dev.env.example +++ b/deploy/compose/env/dev.env.example @@ -35,3 +35,8 @@ SCHEDULER_QUEUE_KIND=Nats SCHEDULER_QUEUE_NATS_URL=nats://nats:4222 SCHEDULER_STORAGE_DATABASE=stellaops_scheduler SCHEDULER_SCANNER_BASEADDRESS=http://scanner-web:8444 +ADVISORY_AI_WEB_PORT=8448 +ADVISORY_AI_SBOM_BASEADDRESS=http://scanner-web:8444 +ADVISORY_AI_INFERENCE_MODE=Local +ADVISORY_AI_REMOTE_BASEADDRESS= +ADVISORY_AI_REMOTE_APIKEY= diff --git a/deploy/compose/env/prod.env.example b/deploy/compose/env/prod.env.example index 218178c0b..e2242e555 100644 --- a/deploy/compose/env/prod.env.example +++ b/deploy/compose/env/prod.env.example @@ -37,5 +37,10 @@ SCHEDULER_QUEUE_KIND=Nats SCHEDULER_QUEUE_NATS_URL=nats://nats:4222 SCHEDULER_STORAGE_DATABASE=stellaops_scheduler SCHEDULER_SCANNER_BASEADDRESS=http://scanner-web:8444 +ADVISORY_AI_WEB_PORT=8448 +ADVISORY_AI_SBOM_BASEADDRESS=https://scanner-web:8444 +ADVISORY_AI_INFERENCE_MODE=Local +ADVISORY_AI_REMOTE_BASEADDRESS= +ADVISORY_AI_REMOTE_APIKEY= # External reverse proxy (Traefik, Envoy, etc.) that terminates TLS. FRONTDOOR_NETWORK=stellaops_frontdoor diff --git a/deploy/compose/env/stage.env.example b/deploy/compose/env/stage.env.example index b3c494bd8..68aeab33e 100644 --- a/deploy/compose/env/stage.env.example +++ b/deploy/compose/env/stage.env.example @@ -34,3 +34,8 @@ SCHEDULER_QUEUE_KIND=Nats SCHEDULER_QUEUE_NATS_URL=nats://nats:4222 SCHEDULER_STORAGE_DATABASE=stellaops_scheduler SCHEDULER_SCANNER_BASEADDRESS=http://scanner-web:8444 +ADVISORY_AI_WEB_PORT=8448 +ADVISORY_AI_SBOM_BASEADDRESS=http://scanner-web:8444 +ADVISORY_AI_INFERENCE_MODE=Local +ADVISORY_AI_REMOTE_BASEADDRESS= +ADVISORY_AI_REMOTE_APIKEY= diff --git a/deploy/helm/stellaops/values-airgap.yaml b/deploy/helm/stellaops/values-airgap.yaml index 96a7aabf8..82299fe85 100644 --- a/deploy/helm/stellaops/values-airgap.yaml +++ b/deploy/helm/stellaops/values-airgap.yaml @@ -156,6 +156,40 @@ services: env: EXCITITOR__CONCELIER__BASEURL: "https://stellaops-concelier:8445" EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://stellaops-airgap:stellaops-airgap@stellaops-mongo:27017" + advisory-ai-web: + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.09.2-airgap + service: + port: 8448 + env: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: https://stellaops-scanner-web:8444 + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: /var/lib/advisory-ai/queue + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: /var/lib/advisory-ai/plans + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: /var/lib/advisory-ai/outputs + ADVISORYAI__AdvisoryAI__Inference__Mode: Local + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "" + volumeMounts: + - name: advisory-ai-data + mountPath: /var/lib/advisory-ai + volumeClaims: + - name: advisory-ai-data + claimName: stellaops-advisory-ai-data + advisory-ai-worker: + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.09.2-airgap + env: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: https://stellaops-scanner-web:8444 + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: /var/lib/advisory-ai/queue + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: /var/lib/advisory-ai/plans + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: /var/lib/advisory-ai/outputs + ADVISORYAI__AdvisoryAI__Inference__Mode: Local + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "" + volumeMounts: + - name: advisory-ai-data + mountPath: /var/lib/advisory-ai + volumeClaims: + - name: advisory-ai-data + claimName: stellaops-advisory-ai-data web-ui: image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d service: diff --git a/deploy/helm/stellaops/values-dev.yaml b/deploy/helm/stellaops/values-dev.yaml index 338875074..e923e9827 100644 --- a/deploy/helm/stellaops/values-dev.yaml +++ b/deploy/helm/stellaops/values-dev.yaml @@ -160,6 +160,40 @@ services: env: EXCITITOR__CONCELIER__BASEURL: "https://stellaops-concelier:8445" EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://stellaops:stellaops@stellaops-mongo:27017" + advisory-ai-web: + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.10.0-edge + service: + port: 8448 + env: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: http://stellaops-scanner-web:8444 + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: /var/lib/advisory-ai/queue + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: /var/lib/advisory-ai/plans + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: /var/lib/advisory-ai/outputs + ADVISORYAI__AdvisoryAI__Inference__Mode: Local + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "" + volumeMounts: + - name: advisory-ai-data + mountPath: /var/lib/advisory-ai + volumeClaims: + - name: advisory-ai-data + claimName: stellaops-advisory-ai-data + advisory-ai-worker: + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.10.0-edge + env: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: http://stellaops-scanner-web:8444 + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: /var/lib/advisory-ai/queue + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: /var/lib/advisory-ai/plans + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: /var/lib/advisory-ai/outputs + ADVISORYAI__AdvisoryAI__Inference__Mode: Local + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "" + volumeMounts: + - name: advisory-ai-data + mountPath: /var/lib/advisory-ai + volumeClaims: + - name: advisory-ai-data + claimName: stellaops-advisory-ai-data web-ui: image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf service: diff --git a/deploy/helm/stellaops/values-prod.yaml b/deploy/helm/stellaops/values-prod.yaml index 0fa18f4bb..0eafc67a9 100644 --- a/deploy/helm/stellaops/values-prod.yaml +++ b/deploy/helm/stellaops/values-prod.yaml @@ -170,6 +170,46 @@ services: envFrom: - secretRef: name: stellaops-prod-core + advisory-ai-web: + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.09.2 + service: + port: 8448 + env: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: https://stellaops-scanner-web:8444 + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: /var/lib/advisory-ai/queue + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: /var/lib/advisory-ai/plans + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: /var/lib/advisory-ai/outputs + ADVISORYAI__AdvisoryAI__Inference__Mode: Local + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "" + envFrom: + - secretRef: + name: stellaops-prod-core + volumeMounts: + - name: advisory-ai-data + mountPath: /var/lib/advisory-ai + volumeClaims: + - name: advisory-ai-data + claimName: stellaops-advisory-ai-data + advisory-ai-worker: + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.09.2 + env: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: https://stellaops-scanner-web:8444 + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: /var/lib/advisory-ai/queue + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: /var/lib/advisory-ai/plans + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: /var/lib/advisory-ai/outputs + ADVISORYAI__AdvisoryAI__Inference__Mode: Local + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "" + envFrom: + - secretRef: + name: stellaops-prod-core + volumeMounts: + - name: advisory-ai-data + mountPath: /var/lib/advisory-ai + volumeClaims: + - name: advisory-ai-data + claimName: stellaops-advisory-ai-data web-ui: image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23 service: diff --git a/deploy/helm/stellaops/values.yaml b/deploy/helm/stellaops/values.yaml index 260e870d7..883856d80 100644 --- a/deploy/helm/stellaops/values.yaml +++ b/deploy/helm/stellaops/values.yaml @@ -111,3 +111,37 @@ services: SCHEDULER__STORAGE__CONNECTIONSTRING: mongodb://scheduler-mongo:27017 SCHEDULER__STORAGE__DATABASE: stellaops_scheduler SCHEDULER__WORKER__RUNNER__SCANNER__BASEADDRESS: http://scanner-web:8444 + advisory-ai-web: + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.10.0-edge + service: + port: 8448 + env: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: http://scanner-web:8444 + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: /var/lib/advisory-ai/queue + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: /var/lib/advisory-ai/plans + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: /var/lib/advisory-ai/outputs + ADVISORYAI__AdvisoryAI__Inference__Mode: Local + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "" + volumeMounts: + - name: advisory-ai-data + mountPath: /var/lib/advisory-ai + volumeClaims: + - name: advisory-ai-data + claimName: stellaops-advisory-ai-data + advisory-ai-worker: + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.10.0-edge + env: + ADVISORYAI__AdvisoryAI__SbomBaseAddress: http://scanner-web:8444 + ADVISORYAI__AdvisoryAI__Queue__DirectoryPath: /var/lib/advisory-ai/queue + ADVISORYAI__AdvisoryAI__Storage__PlanCacheDirectory: /var/lib/advisory-ai/plans + ADVISORYAI__AdvisoryAI__Storage__OutputDirectory: /var/lib/advisory-ai/outputs + ADVISORYAI__AdvisoryAI__Inference__Mode: Local + ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "" + ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "" + volumeMounts: + - name: advisory-ai-data + mountPath: /var/lib/advisory-ai + volumeClaims: + - name: advisory-ai-data + claimName: stellaops-advisory-ai-data diff --git a/deploy/releases/2025.09-airgap.yaml b/deploy/releases/2025.09-airgap.yaml index b4b02c7e3..9b8f72fe6 100644 --- a/deploy/releases/2025.09-airgap.yaml +++ b/deploy/releases/2025.09-airgap.yaml @@ -16,10 +16,14 @@ release: image: registry.stella-ops.org/stellaops/scanner-worker@sha256:eea5d6cfe7835950c5ec7a735a651f2f0d727d3e470cf9027a4a402ea89c4fb5 - name: concelier image: registry.stella-ops.org/stellaops/concelier@sha256:29e2e1a0972707e092cbd3d370701341f9fec2aa9316fb5d8100480f2a1c76b5 - - name: excititor - image: registry.stella-ops.org/stellaops/excititor@sha256:65c0ee13f773efe920d7181512349a09d363ab3f3e177d276136bd2742325a68 - - name: web-ui - image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d + - name: excititor + image: registry.stella-ops.org/stellaops/excititor@sha256:65c0ee13f773efe920d7181512349a09d363ab3f3e177d276136bd2742325a68 + - name: advisory-ai-web + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.09.2-airgap + - name: advisory-ai-worker + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.09.2-airgap + - name: web-ui + image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d infrastructure: mongo: image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49 diff --git a/deploy/releases/2025.09-stable.yaml b/deploy/releases/2025.09-stable.yaml index 1ac9b33fb..b6f301ec1 100644 --- a/deploy/releases/2025.09-stable.yaml +++ b/deploy/releases/2025.09-stable.yaml @@ -16,10 +16,14 @@ release: image: registry.stella-ops.org/stellaops/scanner-worker@sha256:32e25e76386eb9ea8bee0a1ad546775db9a2df989fab61ac877e351881960dab - name: concelier image: registry.stella-ops.org/stellaops/concelier@sha256:c58cdcaee1d266d68d498e41110a589dd204b487d37381096bd61ab345a867c5 - - name: excititor - image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa - - name: web-ui - image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23 + - name: excititor + image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa + - name: advisory-ai-web + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.09.2 + - name: advisory-ai-worker + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.09.2 + - name: web-ui + image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23 infrastructure: mongo: image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49 diff --git a/deploy/releases/2025.10-edge.yaml b/deploy/releases/2025.10-edge.yaml index a66d7112e..3ba3bee6e 100644 --- a/deploy/releases/2025.10-edge.yaml +++ b/deploy/releases/2025.10-edge.yaml @@ -18,10 +18,14 @@ image: registry.stella-ops.org/stellaops/scanner-worker@sha256:92dda42f6f64b2d9522104a5c9ffb61d37b34dd193132b68457a259748008f37 - name: concelier image: registry.stella-ops.org/stellaops/concelier@sha256:dafef3954eb4b837e2c424dd2d23e1e4d60fa83794840fac9cd3dea1d43bd085 - - name: excititor - image: registry.stella-ops.org/stellaops/excititor@sha256:d9bd5cadf1eab427447ce3df7302c30ded837239771cc6433b9befb895054285 - - name: web-ui - image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf + - name: excititor + image: registry.stella-ops.org/stellaops/excititor@sha256:d9bd5cadf1eab427447ce3df7302c30ded837239771cc6433b9befb895054285 + - name: advisory-ai-web + image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.10.0-edge + - name: advisory-ai-worker + image: registry.stella-ops.org/stellaops/advisory-ai-worker:2025.10.0-edge + - name: web-ui + image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf infrastructure: mongo: image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49 diff --git a/docs/11_AUTHORITY.md b/docs/11_AUTHORITY.md index 068448277..ca331ef40 100644 --- a/docs/11_AUTHORITY.md +++ b/docs/11_AUTHORITY.md @@ -373,6 +373,7 @@ Authority now understands two flavours of sender-constrained OAuth clients: - Configure under `security.senderConstraints.dpop`. `allowedAlgorithms`, `proofLifetime`, and `replayWindow` are enforced at validation time. - `security.senderConstraints.dpop.allowTemporaryBypass` toggles an emergency-only bypass for sealed drills. When set to `true`, Authority logs `authority.dpop.proof.bypass`, tags `authority.dpop_result=bypass`, and issues tokens without a DPoP `cnf` claim so downstream servers know sender constraints are disabled. **Reset to `false` immediately after the exercise.** - `security.senderConstraints.dpop.nonce.enabled` enables nonce challenges for high-value audiences (`requiredAudiences`, normalised to case-insensitive strings). When a nonce is required but missing or expired, `/token` replies with `WWW-Authenticate: DPoP error="use_dpop_nonce"` (and, when available, a fresh `DPoP-Nonce` header). Clients must retry with the issued nonce embedded in the proof. + - Refresh-token requests honour the original sender constraint (DPoP or mTLS). `/token` revalidates the proof/certificate, enforces the recorded thumbprint/JKT, and reuses that metadata so the new access/refresh tokens remain bound to the same key. - `security.senderConstraints.dpop.nonce.store` selects `memory` (default) or `redis`. When `redis` is configured, set `security.senderConstraints.dpop.nonce.redisConnectionString` so replicas share nonce issuance and high-value clients avoid replay gaps during failover. - Telemetry: every nonce challenge increments `authority_dpop_nonce_miss_total{reason=...}` while mTLS mismatches increment `authority_mtls_mismatch_total{reason=...}`. - Example (enabling Redis-backed nonces; adjust audiences per deployment): diff --git a/docs/24_OFFLINE_KIT.md b/docs/24_OFFLINE_KIT.md index 70975e55b..5f6519cba 100755 --- a/docs/24_OFFLINE_KIT.md +++ b/docs/24_OFFLINE_KIT.md @@ -13,7 +13,7 @@ completely isolated network: | Component | Contents | |-----------|----------| | **Merged vulnerability feeds** | OSV, GHSA plus optional NVD 2.0, CNNVD, CNVD, ENISA, JVN and BDU | -| **Container images** | `stella-ops`, *Zastava* sidecar (x86‑64 & arm64) | +| **Container images** | `stella-ops`, *Zastava* sidecar, `advisory-ai-web`, and `advisory-ai-worker` (x86‑64 & arm64) | | **Provenance** | Cosign signature, SPDX 2.3 SBOM, in‑toto SLSA attestation | | **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. | | **Delta patches** | Daily diff bundles keep size \< 350 MB | @@ -24,6 +24,8 @@ completely isolated network: **RU BDU note:** ship the official Russian Trusted Root/Sub CA bundle (`certificates/russian_trusted_bundle.pem`) inside the kit so `concelier:httpClients:source.bdu:trustedRootPaths` can resolve it when the service runs in an air‑gapped network. Drop the most recent `vulxml.zip` alongside the kit if operators need a cold-start cache. **Language analyzers:** the kit now carries the restart-only Node.js, Go, .NET, Python, and Rust plug-ins (`plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Node/`, `...Lang.Go/`, `...Lang.DotNet/`, `...Lang.Python/`, `...Lang.Rust/`). Drop the directories alongside Worker binaries so the unified plug-in catalog can load them without outbound fetches. + +**Advisory AI volume primer:** ship a tarball containing empty `queue/`, `plans/`, and `outputs/` directories plus their ownership metadata. During import, extract it onto the RWX volume used by `advisory-ai-web` and `advisory-ai-worker` so pods start with the expected directory tree even on air-gapped nodes. *Scanner core:* C# 12 on **.NET {{ dotnet }}**. *Imports are idempotent and atomic — no service downtime.* diff --git a/docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md b/docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md index d390d4cb6..81264f5fc 100644 --- a/docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md +++ b/docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md @@ -35,7 +35,7 @@ _Source:_ `docs/assets/authority/authority-plugin-component.mmd` Capability flags let the host reason about what your plug-in supports: - Declare capabilities in your descriptor using the string constants from `AuthorityPluginCapabilities` (`password`, `mfa`, `clientProvisioning`, `bootstrap`). The configuration loader now validates these tokens and rejects unknown values at startup. -- `AuthorityIdentityProviderCapabilities.FromCapabilities` projects those strings into strongly typed booleans (`SupportsPassword`, etc.). Authority Core will use these flags when wiring flows such as the password grant. Built-in plugins (e.g., Standard) will fail fast or force-enable required capabilities if the descriptor is misconfigured, so keep manifests accurate. +- `AuthorityIdentityProviderCapabilities.FromCapabilities` projects those strings into strongly typed booleans (`SupportsPassword`, `SupportsMfa`, `SupportsClientProvisioning`, `SupportsBootstrap`). Authority Core uses these flags when wiring flows such as the password grant, bootstrap APIs, and client provisioning. Built-in plugins (e.g., Standard) will fail fast or force-enable required capabilities if the descriptor is misconfigured, so keep manifests accurate. - Typical configuration (`etc/authority.plugins/standard.yaml`): ```yaml plugins: diff --git a/docs/implplan/SPRINT_100_identity_signing.md b/docs/implplan/SPRINT_100_identity_signing.md index 15881a3a7..1af460c6f 100644 --- a/docs/implplan/SPRINT_100_identity_signing.md +++ b/docs/implplan/SPRINT_100_identity_signing.md @@ -20,14 +20,16 @@ Focus: Identity & Signing focus on Authority (phase II). | # | Task ID & handle | State | Key dependency / next step | Owners | | --- | --- | --- | --- | --- | | 1 | AUTH-DPOP-11-001 | DONE (2025-11-08) | DPoP validation now runs for every `/token` grant, interactive tokens inherit `cnf.jkt`/sender claims, and docs/tests document the expanded coverage. (Deps: AUTH-AOC-19-002.) | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) | -| 2 | AUTH-MTLS-11-002 | DOING (2025-11-07) | Deliver mTLS-bound token issuance/validation (cert thumbprint storage, JWKS rotation hooks) required for high-assurance tenants and plugin mitigations. (Deps: AUTH-DPOP-11-001.) | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) | -| 3 | PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | BE-Auth Plugin, Docs Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) | +| 2 | AUTH-MTLS-11-002 | DONE (2025-11-08) | Refresh grants now enforce the original client certificate, tokens persist `x5t#S256`/hex metadata via shared helper, and docs/JWKS guidance call out the mTLS binding expectations. (Deps: AUTH-DPOP-11-001.) | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) | +| 3 | PLG4-6.CAPABILITIES | DONE (2025-11-08) | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | BE-Auth Plugin, Docs Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) | | 4 | PLG6.DIAGRAM | DONE (2025-11-03) | Component + sequence diagrams rendered (Mermaid + SVG) and offline assets published under `docs/assets/authority`; dev guide now references final exports. | Docs Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) | | 5 | PLG7.RFC | DONE (2025-11-03) | LDAP plugin RFC reviewed; guild sign-off captured and follow-up implementation issues filed per review notes. | BE-Auth Plugin, Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) | | 6 | SEC2.PLG | BLOCKED (2025-10-21) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. ⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 to stabilise Authority auth surfaces (PLUGIN-DI-08-001 closed 2025-10-21; re-run once sender constraints land). | Security Guild, Storage Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) | | 7 | SEC3.PLG | BLOCKED (2025-10-21) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). ⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002; PLUGIN-DI-08-001 already merged, so limiter telemetry just awaits final Authority surface. | Security Guild, BE-Auth Plugin (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) | | 8 | SEC5.PLG | BLOCKED (2025-10-21) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. ⛔ Final documentation now hinges on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 (PLUGIN-DI-08-001 landed 2025-10-21). | Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) | +- 2025-11-08: PLG4-6.CAPABILITIES marked DONE – bootstrap capability surfaced in code/docs, registry logs updated, and bootstrap APIs now gate on providers that advertise it (`dotnet test` across plugins + Authority core). + ## 100.D) __Libraries Dependency: None specified; follow module prerequisites. Focus: Identity & Signing focus on __Libraries. diff --git a/docs/implplan/SPRINT_110_ingestion_evidence.md b/docs/implplan/SPRINT_110_ingestion_evidence.md index 2704c89b0..2304b04de 100644 --- a/docs/implplan/SPRINT_110_ingestion_evidence.md +++ b/docs/implplan/SPRINT_110_ingestion_evidence.md @@ -9,6 +9,7 @@ Active items only. Completed/historic work now resides in docs/implplan/archived - 2025-11-03: AIAI-31-002 landed the configurable HTTP client + DI defaults; retriever now resolves data via `/v1/sbom/context`, retaining a null fallback until SBOM service ships. - 2025-11-03: Follow-up: SBOM guild to deliver base URL/API key and run an Advisory AI smoke retrieval once SBOM-AIAI-31-001 endpoints are live. - 2025-11-08: AIAI-31-009 marked DONE – injection harness + dual golden prompts + plan-cache determinism tests landed; perf memo added to Advisory AI architecture, `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --no-build` green. + - 2025-11-08: AIAI-31-008 moved to DOING – starting on-prem inference packaging, remote inference toggle, Helm/Compose manifests, scaling guidance, and Offline Kit doc refresh. - **Concelier** – CONCELIER-CORE-AOC-19-004 is the only in-flight Concelier item; air-gap, console, attestation, and Link-Not-Merge tasks remain TODO, and several connector upgrades still carry overdue October due dates. - **Excititor** – Excititor WebService, console, policy, and observability tracks are all TODO and hinge on Link-Not-Merge schema delivery plus trust-provenance connectors (SUSE/Ubuntu) progressing in section 110.C. - **Mirror** – Mirror Creator track (MIRROR-CRT-56-001 through MIRROR-CRT-58-002) has not started; DSSE signing, OCI bundle, and scheduling integrations depend on the deterministic bundle assembler landing first. diff --git a/docs/modules/advisory-ai/README.md b/docs/modules/advisory-ai/README.md index ac4cd01c2..f903a7552 100644 --- a/docs/modules/advisory-ai/README.md +++ b/docs/modules/advisory-ai/README.md @@ -26,6 +26,11 @@ Advisory AI is the retrieval-augmented assistant that synthesizes advisory and V - Redaction policies validated against security/LLM guardrail tests. - Guardrail behaviour, blocked phrases, and operational alerts are detailed in `/docs/security/assistant-guardrails.md`. +## Deployment & configuration +- **Containers:** `advisory-ai-web` fronts the API/cache while `advisory-ai-worker` drains the queue and executes prompts. Both containers mount a shared RWX volume providing `/var/lib/advisory-ai/{queue,plans,outputs}`. +- **Remote inference toggle:** Set `ADVISORYAI__AdvisoryAI__Inference__Mode=Remote` to send sanitized prompts to an external inference tier. Provide `ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress` (and optional `...ApiKey`) to complete the circuit; failures fall back to the sanitized prompt and surface `inference.fallback_*` metadata. +- **Helm/Compose:** Bundled manifests wire the SBOM base address, queue/plan/output directories, and inference options via the `AdvisoryAI` configuration section. Helm expects a PVC named `stellaops-advisory-ai-data`. Compose creates named volumes so the worker and web instances share deterministic state. + ## CLI usage - `stella advise run --advisory-key [--artifact-id id] [--artifact-purl purl] [--policy-version v] [--profile profile] [--section name] [--force-refresh] [--timeout seconds]` - Requests an advisory plan from the web service, enqueues execution, then polls for the generated output (default wait 120 s, single check if `--timeout 0`). diff --git a/docs/modules/advisory-ai/architecture.md b/docs/modules/advisory-ai/architecture.md index 3be566ba4..015d4364a 100644 --- a/docs/modules/advisory-ai/architecture.md +++ b/docs/modules/advisory-ai/architecture.md @@ -145,3 +145,10 @@ All endpoints accept `profile` parameter (default `fips-local`) and return `outp - **Plan determinism:** `AdvisoryPipelineOrchestratorTests` shuffle structured/vector/SBOM inputs and assert cache keys + metadata remain stable, proving that seeded plan caches stay deterministic even when retrievers emit out-of-order results. - **Execution telemetry:** `AdvisoryPipelineExecutorTests` exercise partial citation coverage (target ≥0.5 when only half the structured chunks are cited) so `advisory_ai_citation_coverage_ratio` reflects real guardrail quality. - **Plan cache stability:** `AdvisoryPlanCacheTests` now seed the in-memory cache with a fake time provider to confirm TTL refresh when plans are replaced, guaranteeing reproducible eviction under air-gapped runs. + +## 13) Deployment profiles, scaling, and remote inference + +- **Local inference containers.** `advisory-ai-web` exposes the API/plan cache endpoints while `advisory-ai-worker` drains the queue and executes prompts. Both containers mount the same RWX volume that hosts three deterministic paths: `/var/lib/advisory-ai/queue`, `/var/lib/advisory-ai/plans`, `/var/lib/advisory-ai/outputs`. Compose bundles create named volumes (`advisory-ai-{queue,plans,outputs}`) and the Helm chart mounts the `stellaops-advisory-ai-data` PVC so web + worker remain in lockstep. +- **Remote inference toggle.** Set `AdvisoryAI:Inference:Mode` (env: `ADVISORYAI__AdvisoryAI__Inference__Mode`) to `Remote` when you want prompts to be executed by an external inference tier. Provide `AdvisoryAI:Inference:Remote:BaseAddress` and, optionally, `...:ApiKey`. When remote calls fail the executor falls back to the sanitized prompt and sets `inference.fallback_*` metadata so CLI/Console surface a warning. +- **Scalability.** Start with 1 web replica + 1 worker for up to ~10 requests/minute. For higher throughput, scale `advisory-ai-worker` horizontally; each worker is CPU-bound (2 vCPU / 4 GiB RAM recommended) while the web front end is I/O-bound (1 vCPU / 1 GiB). Because the queue/plan/output stores are content-addressed files, ensure the shared volume delivers ≥500 IOPS and <5 ms latency; otherwise queue depth will lag. +- **Offline & air-gapped stance.** The Compose/Helm manifests avoid external network calls by default and the Offline Kit now publishes the `advisory-ai-web` and `advisory-ai-worker` images alongside their SBOMs/provenance. Operators can rehydrate the RWX volume from the kit to pre-prime cache directories before enabling the service. diff --git a/etc/authority.yaml.sample b/etc/authority.yaml.sample index 807ba8d78..b7887e616 100644 --- a/etc/authority.yaml.sample +++ b/etc/authority.yaml.sample @@ -65,6 +65,17 @@ notifications: scope: "notify.escalate" requireAdminScope: true +airGap: + sealedMode: + enforcementEnabled: false + evidencePath: "../ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/latest/authority-sealed-ci.json" + maxEvidenceAge: "06:00:00" + cacheLifetime: "00:01:00" + requireAuthorityHealthPass: true + requireSignerHealthPass: true + requireAttestorHealthPass: true + requireEgressProbePass: true + vulnerabilityExplorer: workflow: antiForgery: @@ -226,6 +237,8 @@ clients: scopes: [ "airgap:status:read", "airgap:import", "airgap:seal" ] tenant: "tenant-default" senderConstraint: "dpop" + properties: + requiresAirgapSealConfirmation: true auth: type: "client_secret" secretFile: "../secrets/airgap-operator.secret" diff --git a/ops/devops/release/components.json b/ops/devops/release/components.json index 3993c9b0e..cbc5a6d40 100644 --- a/ops/devops/release/components.json +++ b/ops/devops/release/components.json @@ -72,6 +72,24 @@ "project": "src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj", "entrypoint": "StellaOps.Excititor.WebService.dll" }, + { + "name": "advisory-ai-web", + "repository": "advisory-ai-web", + "kind": "dotnet-service", + "context": ".", + "dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service", + "project": "src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj", + "entrypoint": "StellaOps.AdvisoryAI.WebService.dll" + }, + { + "name": "advisory-ai-worker", + "repository": "advisory-ai-worker", + "kind": "dotnet-service", + "context": ".", + "dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service", + "project": "src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.csproj", + "entrypoint": "StellaOps.AdvisoryAI.Worker.dll" + }, { "name": "web-ui", "repository": "web-ui", diff --git a/ops/devops/sealed-mode-ci/authority.harness.yaml b/ops/devops/sealed-mode-ci/authority.harness.yaml index 01524455c..a08cf8583 100644 --- a/ops/devops/sealed-mode-ci/authority.harness.yaml +++ b/ops/devops/sealed-mode-ci/authority.harness.yaml @@ -46,6 +46,15 @@ airGap: allowPrivateNetworks: true remediationDocumentationUrl: https://docs.stella-ops.org/airgap/sealed-ci supportContact: airgap-ops@stella-ops.org + sealedMode: + enforcementEnabled: true + evidencePath: /artifacts/sealed-mode-ci/latest/authority-sealed-ci.json + maxEvidenceAge: 00:30:00 + cacheLifetime: 00:01:00 + requireAuthorityHealthPass: true + requireSignerHealthPass: true + requireAttestorHealthPass: true + requireEgressProbePass: true tenants: - name: sealed-ci roles: diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiServiceOptions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiServiceOptions.cs index 41929c76b..5b86ac7df 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiServiceOptions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiServiceOptions.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using StellaOps.AdvisoryAI.Inference; namespace StellaOps.AdvisoryAI.Hosting; @@ -15,6 +16,8 @@ public sealed class AdvisoryAiServiceOptions public AdvisoryAiStorageOptions Storage { get; set; } = new(); + public AdvisoryAiInferenceOptions Inference { get; set; } = new(); + internal string ResolveQueueDirectory(string contentRoot) { ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiServiceOptionsValidator.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiServiceOptionsValidator.cs index 0e509c1ff..8c1c8f8d2 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiServiceOptionsValidator.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiServiceOptionsValidator.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.IO; +using StellaOps.AdvisoryAI.Inference; namespace StellaOps.AdvisoryAI.Hosting; @@ -52,6 +53,24 @@ internal static class AdvisoryAiServiceOptionsValidator options.Storage.OutputDirectory = Path.Combine("data", "advisory-ai", "outputs"); } + options.Inference ??= new AdvisoryAiInferenceOptions(); + options.Inference.Remote ??= new AdvisoryAiRemoteInferenceOptions(); + + if (options.Inference.Mode == AdvisoryAiInferenceMode.Remote) + { + var remote = options.Inference.Remote; + if (remote.BaseAddress is null || !remote.BaseAddress.IsAbsoluteUri) + { + error = "AdvisoryAI:Inference:Remote:BaseAddress must be an absolute URI when remote mode is enabled."; + return false; + } + + if (remote.Timeout <= TimeSpan.Zero) + { + remote.Timeout = TimeSpan.FromSeconds(30); + } + } + error = null; return true; } diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/FileSystemAdvisoryOutputStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/FileSystemAdvisoryOutputStore.cs index 5a775ad04..513d484ca 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/FileSystemAdvisoryOutputStore.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/FileSystemAdvisoryOutputStore.cs @@ -101,6 +101,7 @@ internal sealed class FileSystemAdvisoryOutputStore : IAdvisoryOutputStore AdvisoryTaskType TaskType, string Profile, string Prompt, + string Response, List Citations, Dictionary Metadata, GuardrailEnvelope Guardrail, @@ -114,6 +115,7 @@ internal sealed class FileSystemAdvisoryOutputStore : IAdvisoryOutputStore output.TaskType, output.Profile, output.Prompt, + output.Response, output.Citations.ToList(), output.Metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal), GuardrailEnvelope.FromResult(output.Guardrail), @@ -132,6 +134,7 @@ internal sealed class FileSystemAdvisoryOutputStore : IAdvisoryOutputStore TaskType, Profile, Prompt, + Response, citations, metadata, guardrail, diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/ServiceCollectionExtensions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/ServiceCollectionExtensions.cs index 8f7f73469..c50b6d9f0 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/ServiceCollectionExtensions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/ServiceCollectionExtensions.cs @@ -1,35 +1,38 @@ -using System; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; +using System; +using System.Net.Http.Headers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using StellaOps.AdvisoryAI.Caching; using StellaOps.AdvisoryAI.DependencyInjection; +using StellaOps.AdvisoryAI.Inference; +using StellaOps.AdvisoryAI.Metrics; +using StellaOps.AdvisoryAI.Outputs; using StellaOps.AdvisoryAI.Providers; using StellaOps.AdvisoryAI.Queue; -using StellaOps.AdvisoryAI.Outputs; namespace StellaOps.AdvisoryAI.Hosting; public static class ServiceCollectionExtensions { - public static IServiceCollection AddAdvisoryAiCore( - this IServiceCollection services, - IConfiguration configuration, - Action? configure = null) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configuration); - - services.AddOptions() - .Bind(configuration.GetSection("AdvisoryAI")) + public static IServiceCollection AddAdvisoryAiCore( + this IServiceCollection services, + IConfiguration configuration, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddOptions() + .Bind(configuration.GetSection("AdvisoryAI")) .PostConfigure(options => { configure?.Invoke(options); AdvisoryAiServiceOptionsValidator.Validate(options); }) .ValidateOnStart(); - + services.AddOptions() .Configure>((target, source) => { @@ -40,6 +43,45 @@ public static class ServiceCollectionExtensions }) .Validate(opt => opt.BaseAddress is null || opt.BaseAddress.IsAbsoluteUri, "SBOM base address must be absolute when provided."); + services.AddOptions() + .Configure>((target, source) => + { + var inference = source.Value.Inference ?? new AdvisoryAiInferenceOptions(); + target.Mode = inference.Mode; + target.Remote = inference.Remote ?? new AdvisoryAiRemoteInferenceOptions(); + }); + + services.AddHttpClient((provider, client) => + { + var inference = provider.GetRequiredService>().Value ?? new AdvisoryAiInferenceOptions(); + var remote = inference.Remote ?? new AdvisoryAiRemoteInferenceOptions(); + + if (remote.BaseAddress is not null) + { + client.BaseAddress = remote.BaseAddress; + } + + if (remote.Timeout > TimeSpan.Zero) + { + client.Timeout = remote.Timeout; + } + + if (!string.IsNullOrWhiteSpace(remote.ApiKey)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", remote.ApiKey); + } + }); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(provider => + { + var inference = provider.GetRequiredService>().Value ?? new AdvisoryAiInferenceOptions(); + return inference.Mode == AdvisoryAiInferenceMode.Remote + ? provider.GetRequiredService() + : provider.GetRequiredService(); + }); + services.AddSbomContext(); services.AddAdvisoryPipeline(); services.AddAdvisoryPipelineInfrastructure(); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Contracts/AdvisoryOutputResponse.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Contracts/AdvisoryOutputResponse.cs index 2db05e692..e8ee396e7 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Contracts/AdvisoryOutputResponse.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Contracts/AdvisoryOutputResponse.cs @@ -10,6 +10,7 @@ internal sealed record AdvisoryOutputResponse( string TaskType, string Profile, string Prompt, + string Response, IReadOnlyList Citations, IReadOnlyDictionary Metadata, AdvisoryOutputGuardrail Guardrail, @@ -23,6 +24,7 @@ internal sealed record AdvisoryOutputResponse( output.TaskType.ToString(), output.Profile, output.Prompt, + output.Response, output.Citations .Select(citation => new AdvisoryOutputCitation(citation.Index, citation.DocumentId, citation.ChunkId)) .ToList(), diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs index 7041a9f90..d25046178 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs @@ -23,7 +23,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables(prefix: "ADVISORYAI_"); + .AddEnvironmentVariables(prefix: "ADVISORYAI__"); builder.Services.AddAdvisoryAiCore(builder.Configuration); builder.Services.AddEndpointsApiExplorer(); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/Program.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/Program.cs index be3e67ebb..828f0ff95 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/Program.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/Program.cs @@ -10,7 +10,7 @@ var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(args); builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables(prefix: "ADVISORYAI_"); + .AddEnvironmentVariables(prefix: "ADVISORYAI__"); builder.Services.AddAdvisoryAiCore(builder.Configuration); builder.Services.AddHostedService(); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Execution/AdvisoryPipelineExecutor.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Execution/AdvisoryPipelineExecutor.cs index 30d4a8cda..b0a118099 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Execution/AdvisoryPipelineExecutor.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Execution/AdvisoryPipelineExecutor.cs @@ -8,6 +8,7 @@ using StellaOps.AdvisoryAI.Orchestration; using StellaOps.AdvisoryAI.Prompting; using StellaOps.AdvisoryAI.Metrics; using StellaOps.AdvisoryAI.Queue; +using StellaOps.AdvisoryAI.Inference; namespace StellaOps.AdvisoryAI.Execution; @@ -27,6 +28,7 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor private readonly IAdvisoryOutputStore _outputStore; private readonly AdvisoryPipelineMetrics _metrics; private readonly TimeProvider _timeProvider; + private readonly IAdvisoryInferenceClient _inferenceClient; private readonly ILogger? _logger; public AdvisoryPipelineExecutor( @@ -35,6 +37,7 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor IAdvisoryOutputStore outputStore, AdvisoryPipelineMetrics metrics, TimeProvider timeProvider, + IAdvisoryInferenceClient inferenceClient, ILogger? logger = null) { _promptAssembler = promptAssembler ?? throw new ArgumentNullException(nameof(promptAssembler)); @@ -42,6 +45,7 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor _outputStore = outputStore ?? throw new ArgumentNullException(nameof(outputStore)); _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _inferenceClient = inferenceClient ?? throw new ArgumentNullException(nameof(inferenceClient)); _logger = logger; } @@ -87,8 +91,9 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor prompt.Citations.Length, plan.StructuredChunks.Length); + var inferenceResult = await _inferenceClient.GenerateAsync(plan, prompt, guardrailResult, cancellationToken).ConfigureAwait(false); var generatedAt = _timeProvider.GetUtcNow(); - var output = AdvisoryPipelineOutput.Create(plan, prompt, guardrailResult, generatedAt, planFromCache); + var output = AdvisoryPipelineOutput.Create(plan, prompt, guardrailResult, inferenceResult, generatedAt, planFromCache); await _outputStore.SaveAsync(output, cancellationToken).ConfigureAwait(false); _metrics.RecordOutputStored(plan.Request.TaskType, planFromCache, guardrailResult.Blocked); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/AdvisoryInferenceClient.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/AdvisoryInferenceClient.cs new file mode 100644 index 000000000..f7dd7326d --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/AdvisoryInferenceClient.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Immutable; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.AdvisoryAI.Guardrails; +using StellaOps.AdvisoryAI.Orchestration; +using StellaOps.AdvisoryAI.Prompting; + +namespace StellaOps.AdvisoryAI.Inference; + +public interface IAdvisoryInferenceClient +{ + Task GenerateAsync( + AdvisoryTaskPlan plan, + AdvisoryPrompt prompt, + AdvisoryGuardrailResult guardrailResult, + CancellationToken cancellationToken); +} + +public sealed record AdvisoryInferenceResult( + string Content, + string? ModelId, + int? PromptTokens, + int? CompletionTokens, + ImmutableDictionary Metadata) +{ + public static AdvisoryInferenceResult FromLocal(string content) + => new( + content, + "local.prompt-preview", + null, + null, + ImmutableDictionary.Create(StringComparer.Ordinal)); + + public static AdvisoryInferenceResult FromFallback(string content, string reason, string? details = null) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + builder["inference.fallback_reason"] = reason; + if (!string.IsNullOrWhiteSpace(details)) + { + builder["inference.fallback_details"] = details!; + } + + return new AdvisoryInferenceResult( + content, + "remote.fallback", + null, + null, + builder.ToImmutable()); + } +} + +public sealed class LocalAdvisoryInferenceClient : IAdvisoryInferenceClient +{ + public Task GenerateAsync( + AdvisoryTaskPlan plan, + AdvisoryPrompt prompt, + AdvisoryGuardrailResult guardrailResult, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(prompt); + ArgumentNullException.ThrowIfNull(guardrailResult); + + var sanitized = guardrailResult.SanitizedPrompt ?? prompt.Prompt ?? string.Empty; + return Task.FromResult(AdvisoryInferenceResult.FromLocal(sanitized)); + } +} + +public sealed class RemoteAdvisoryInferenceClient : IAdvisoryInferenceClient +{ + private readonly HttpClient _httpClient; + private readonly IOptions _options; + private readonly ILogger? _logger; + + public RemoteAdvisoryInferenceClient( + HttpClient httpClient, + IOptions options, + ILogger? logger = null) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger; + } + + public async Task GenerateAsync( + AdvisoryTaskPlan plan, + AdvisoryPrompt prompt, + AdvisoryGuardrailResult guardrailResult, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(prompt); + ArgumentNullException.ThrowIfNull(guardrailResult); + + var sanitized = guardrailResult.SanitizedPrompt ?? prompt.Prompt ?? string.Empty; + var inferenceOptions = _options.Value ?? new AdvisoryAiInferenceOptions(); + var remote = inferenceOptions.Remote ?? new AdvisoryAiRemoteInferenceOptions(); + + if (remote.BaseAddress is null) + { + _logger?.LogWarning("Remote inference is enabled but no base address was configured. Falling back to local prompt output."); + return AdvisoryInferenceResult.FromLocal(sanitized); + } + + var endpoint = string.IsNullOrWhiteSpace(remote.Endpoint) + ? "/v1/inference" + : remote.Endpoint; + + var request = new RemoteInferenceRequest( + TaskType: plan.Request.TaskType.ToString(), + Profile: plan.Request.Profile, + Prompt: sanitized, + Metadata: prompt.Metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal), + Citations: prompt.Citations + .Select(citation => new RemoteInferenceCitation(citation.Index, citation.DocumentId, citation.ChunkId)) + .ToArray()); + + try + { + using var response = await _httpClient.PostAsJsonAsync(endpoint, request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + _logger?.LogWarning( + "Remote inference request failed with status {StatusCode}. Response body: {Body}", + response.StatusCode, + body); + return AdvisoryInferenceResult.FromFallback(sanitized, $"remote_http_{(int)response.StatusCode}", body); + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + if (payload is null || string.IsNullOrWhiteSpace(payload.Content)) + { + _logger?.LogWarning("Remote inference response was empty. Falling back to sanitized prompt."); + return AdvisoryInferenceResult.FromFallback(sanitized, "remote_empty_response"); + } + + var metadataBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + if (payload.Metadata is not null) + { + foreach (var pair in payload.Metadata) + { + if (!string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null) + { + metadataBuilder[pair.Key] = pair.Value; + } + } + } + + return new AdvisoryInferenceResult( + payload.Content, + payload.ModelId, + payload.Usage?.PromptTokens, + payload.Usage?.CompletionTokens, + metadataBuilder.ToImmutable()); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + _logger?.LogWarning("Remote inference timed out before completion. Returning sanitized prompt."); + return AdvisoryInferenceResult.FromFallback(sanitized, "remote_timeout"); + } + catch (HttpRequestException ex) + { + _logger?.LogWarning(ex, "Remote inference HTTP request failed. Returning sanitized prompt."); + return AdvisoryInferenceResult.FromFallback(sanitized, "remote_http_exception", ex.Message); + } + } + + private sealed record RemoteInferenceRequest( + string TaskType, + string Profile, + string Prompt, + IReadOnlyDictionary Metadata, + IReadOnlyList Citations); + + private sealed record RemoteInferenceCitation(int Index, string DocumentId, string ChunkId); + + private sealed record RemoteInferenceResponse( + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("modelId")] string? ModelId, + [property: JsonPropertyName("usage")] RemoteInferenceUsage? Usage, + [property: JsonPropertyName("metadata")] Dictionary? Metadata); + + private sealed record RemoteInferenceUsage( + [property: JsonPropertyName("promptTokens")] int? PromptTokens, + [property: JsonPropertyName("completionTokens")] int? CompletionTokens); +} + +public sealed class AdvisoryAiInferenceOptions +{ + public AdvisoryAiInferenceMode Mode { get; set; } = AdvisoryAiInferenceMode.Local; + + public AdvisoryAiRemoteInferenceOptions Remote { get; set; } = new(); +} + +public sealed class AdvisoryAiRemoteInferenceOptions +{ + public Uri? BaseAddress { get; set; } + + public string Endpoint { get; set; } = "/v1/inference"; + + public string? ApiKey { get; set; } + + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); +} + +public enum AdvisoryAiInferenceMode +{ + Local, + Remote +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Outputs/AdvisoryOutputStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Outputs/AdvisoryOutputStore.cs index 7f8deaf40..fc7d9f532 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Outputs/AdvisoryOutputStore.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Outputs/AdvisoryOutputStore.cs @@ -1,10 +1,12 @@ using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Globalization; using System.Security.Cryptography; using System.Text; using StellaOps.AdvisoryAI.Guardrails; using StellaOps.AdvisoryAI.Prompting; using StellaOps.AdvisoryAI.Orchestration; +using StellaOps.AdvisoryAI.Inference; namespace StellaOps.AdvisoryAI.Outputs; @@ -22,6 +24,7 @@ public sealed class AdvisoryPipelineOutput AdvisoryTaskType taskType, string profile, string prompt, + string response, ImmutableArray citations, ImmutableDictionary metadata, AdvisoryGuardrailResult guardrail, @@ -33,6 +36,7 @@ public sealed class AdvisoryPipelineOutput TaskType = taskType; Profile = string.IsNullOrWhiteSpace(profile) ? throw new ArgumentException(nameof(profile)) : profile; Prompt = prompt ?? throw new ArgumentNullException(nameof(prompt)); + Response = response ?? throw new ArgumentNullException(nameof(response)); Citations = citations; Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); Guardrail = guardrail ?? throw new ArgumentNullException(nameof(guardrail)); @@ -49,6 +53,8 @@ public sealed class AdvisoryPipelineOutput public string Prompt { get; } + public string Response { get; } + public ImmutableArray Citations { get; } public ImmutableDictionary Metadata { get; } @@ -65,15 +71,21 @@ public sealed class AdvisoryPipelineOutput AdvisoryTaskPlan plan, AdvisoryPrompt prompt, AdvisoryGuardrailResult guardrail, + AdvisoryInferenceResult inference, DateTimeOffset generatedAtUtc, bool planFromCache) { ArgumentNullException.ThrowIfNull(plan); ArgumentNullException.ThrowIfNull(prompt); ArgumentNullException.ThrowIfNull(guardrail); + ArgumentNullException.ThrowIfNull(inference); var promptContent = guardrail.SanitizedPrompt ?? prompt.Prompt ?? string.Empty; - var outputHash = ComputeHash(promptContent); + var responseContent = string.IsNullOrWhiteSpace(inference.Content) + ? promptContent + : inference.Content; + var metadata = MergeMetadata(prompt.Metadata, inference); + var outputHash = ComputeHash(responseContent); var provenance = new AdvisoryDsseProvenance(plan.CacheKey, outputHash, ImmutableArray.Empty); return new AdvisoryPipelineOutput( @@ -81,14 +93,52 @@ public sealed class AdvisoryPipelineOutput plan.Request.TaskType, plan.Request.Profile, promptContent, + responseContent, prompt.Citations, - prompt.Metadata, + metadata, guardrail, provenance, generatedAtUtc, planFromCache); } + private static ImmutableDictionary MergeMetadata( + ImmutableDictionary metadata, + AdvisoryInferenceResult inference) + { + var builder = metadata is { Count: > 0 } + ? metadata.ToBuilder() + : ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + + if (!string.IsNullOrWhiteSpace(inference.ModelId)) + { + builder["inference.model_id"] = inference.ModelId!; + } + + if (inference.PromptTokens.HasValue) + { + builder["inference.prompt_tokens"] = inference.PromptTokens.Value.ToString(CultureInfo.InvariantCulture); + } + + if (inference.CompletionTokens.HasValue) + { + builder["inference.completion_tokens"] = inference.CompletionTokens.Value.ToString(CultureInfo.InvariantCulture); + } + + if (inference.Metadata is not null && inference.Metadata.Count > 0) + { + foreach (var pair in inference.Metadata) + { + if (!string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null) + { + builder[pair.Key] = pair.Value; + } + } + } + + return builder.ToImmutable(); + } + private static string ComputeHash(string content) { var bytes = Encoding.UTF8.GetBytes(content); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md index a4011c211..5600d1834 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md @@ -11,7 +11,7 @@ | AIAI-31-005 | DONE (2025-11-04) | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. | | AIAI-31-006 | DONE (2025-11-04) | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. | | AIAI-31-007 | DONE (2025-11-06) | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. | -| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. | +| AIAI-31-008 | DOING (2025-11-08) | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. | | AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. | | AIAI-31-011 | DONE (2025-11-02) | Advisory AI Guild | EXCITITOR-LNM-21-201, EXCITITOR-CORE-AOC-19-002 | Implement Excititor VEX document provider to surface structured VEX statements for vector retrieval. | Provider returns conflict-aware VEX chunks with deterministic metadata and tests for representative statements. | | AIAI-31-009 | DONE (2025-11-08) | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. | diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPipelineExecutorTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPipelineExecutorTests.cs index 54fc83423..fa98f7c4b 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPipelineExecutorTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPipelineExecutorTests.cs @@ -14,6 +14,7 @@ using StellaOps.AdvisoryAI.Prompting; using StellaOps.AdvisoryAI.Queue; using StellaOps.AdvisoryAI.Metrics; using StellaOps.AdvisoryAI.Tools; +using StellaOps.AdvisoryAI.Inference; using Xunit; namespace StellaOps.AdvisoryAI.Tests; @@ -30,7 +31,8 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable var guardrail = new StubGuardrailPipeline(blocked: false); var store = new InMemoryAdvisoryOutputStore(); using var metrics = new AdvisoryPipelineMetrics(_meterFactory); - var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System); + var inference = new StubInferenceClient(); + var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System, inference); var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request); await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None); @@ -43,6 +45,7 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable saved.Provenance.InputDigest.Should().Be(plan.CacheKey); saved.Provenance.OutputHash.Should().NotBeNullOrWhiteSpace(); saved.Prompt.Should().Be("{\"prompt\":\"value\"}"); + saved.Response.Should().Be("{\"prompt\":\"value\"}"); saved.Guardrail.Metadata.Should().ContainKey("prompt_length"); } @@ -54,7 +57,8 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable var guardrail = new StubGuardrailPipeline(blocked: true); var store = new InMemoryAdvisoryOutputStore(); using var metrics = new AdvisoryPipelineMetrics(_meterFactory); - var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System); + var inference = new StubInferenceClient(); + var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System, inference); var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request); await executor.ExecuteAsync(plan, message, planFromCache: true, CancellationToken.None); @@ -84,12 +88,12 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { - doubleMeasurements.Add((instrument.Name, measurement, tags)); + doubleMeasurements.Add((instrument.Name, measurement, tags.ToArray())); }); listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { - longMeasurements.Add((instrument.Name, measurement, tags)); + longMeasurements.Add((instrument.Name, measurement, tags.ToArray())); }); listener.Start(); @@ -99,7 +103,8 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable var guardrail = new StubGuardrailPipeline(blocked: true); var store = new InMemoryAdvisoryOutputStore(); using var metrics = new AdvisoryPipelineMetrics(_meterFactory); - var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System); + var inference = new StubInferenceClient(); + var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System, inference); var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request); await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None); @@ -135,7 +140,7 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { - doubleMeasurements.Add((instrument.Name, measurement, tags)); + doubleMeasurements.Add((instrument.Name, measurement, tags.ToArray())); }); listener.Start(); @@ -145,7 +150,8 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable var guardrail = new StubGuardrailPipeline(blocked: false); var store = new InMemoryAdvisoryOutputStore(); using var metrics = new AdvisoryPipelineMetrics(_meterFactory); - var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System); + var inference = new StubInferenceClient(); + var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System, inference); var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request); await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None); @@ -289,6 +295,18 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable => Task.FromResult(_result); } + private sealed class StubInferenceClient : IAdvisoryInferenceClient + { + public AdvisoryInferenceResult Result { get; set; } = AdvisoryInferenceResult.FromLocal("{\"prompt\":\"value\"}"); + + public Task GenerateAsync( + AdvisoryTaskPlan plan, + AdvisoryPrompt prompt, + AdvisoryGuardrailResult guardrailResult, + CancellationToken cancellationToken) + => Task.FromResult(Result); + } + public void Dispose() { _meterFactory.Dispose(); diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/ConcelierAdvisoryDocumentProviderTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/ConcelierAdvisoryDocumentProviderTests.cs index 8212a7fd1..6f0320e74 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/ConcelierAdvisoryDocumentProviderTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/ConcelierAdvisoryDocumentProviderTests.cs @@ -73,7 +73,7 @@ public sealed class ConcelierAdvisoryDocumentProviderTests public Task> FindByAdvisoryKeyAsync( string tenant, - IReadOnlyCollection searchValues, + string advisoryKey, IReadOnlyCollection sourceVendors, CancellationToken cancellationToken) => Task.FromResult(_records); diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/FileSystemAdvisoryPersistenceTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/FileSystemAdvisoryPersistenceTests.cs index b37389842..5ccc5b8bd 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/FileSystemAdvisoryPersistenceTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/FileSystemAdvisoryPersistenceTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Caching; using StellaOps.AdvisoryAI.Context; using StellaOps.AdvisoryAI.Documents; @@ -14,6 +15,7 @@ using StellaOps.AdvisoryAI.Guardrails; using StellaOps.AdvisoryAI.Hosting; using StellaOps.AdvisoryAI.Outputs; using StellaOps.AdvisoryAI.Orchestration; +using StellaOps.AdvisoryAI.Prompting; using StellaOps.AdvisoryAI.Tools; using Xunit; @@ -67,11 +69,13 @@ public sealed class FileSystemAdvisoryPersistenceTests : IDisposable var plan = CreatePlan("cache-abc"); var prompt = "{\"prompt\":\"value\"}"; var guardrail = AdvisoryGuardrailResult.Allowed(prompt); + var response = "response-text"; var output = new AdvisoryPipelineOutput( plan.CacheKey, plan.Request.TaskType, plan.Request.Profile, prompt, + response, ImmutableArray.Create(new AdvisoryPromptCitation(1, "doc-1", "chunk-1")), ImmutableDictionary.Empty.Add("advisory_key", plan.Request.AdvisoryKey), guardrail, @@ -84,6 +88,7 @@ public sealed class FileSystemAdvisoryPersistenceTests : IDisposable reloaded.Should().NotBeNull(); reloaded!.Prompt.Should().Be(prompt); + reloaded.Response.Should().Be(response); reloaded.Metadata.Should().ContainKey("advisory_key").WhoseValue.Should().Be(plan.Request.AdvisoryKey); } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginRegistrarTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginRegistrarTests.cs index e30c0e122..db28653d2 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginRegistrarTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginRegistrarTests.cs @@ -55,14 +55,24 @@ public class StandardPluginRegistrarTests "standard.yaml"); var pluginContext = new AuthorityPluginContext(manifest, configuration); - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(database); - services.AddSingleton(new InMemoryClientStore()); - services.AddSingleton(new StubRevocationStore()); - services.AddSingleton(TimeProvider.System); - services.AddSingleton(new StubRevocationStore()); - services.AddSingleton(TimeProvider.System); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(database); + services.AddSingleton(new InMemoryClientStore()); + services.AddSingleton(new StubRevocationStore()); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(sp => + { + var mongo = sp.GetRequiredService(); + var collection = mongo.GetCollection("authority_login_attempts"); + return new AuthorityLoginAttemptStore(collection, NullLogger.Instance); + }); + services.AddSingleton(sp => + { + var mongo = sp.GetRequiredService(); + var collection = mongo.GetCollection("authority_login_attempts"); + return new AuthorityLoginAttemptStore(collection, NullLogger.Instance); + }); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(TimeProvider.System); services.AddSingleton(new StubRevocationStore()); @@ -91,7 +101,9 @@ public class StandardPluginRegistrarTests using var scope = provider.CreateScope(); var plugin = scope.ServiceProvider.GetRequiredService(); Assert.Equal("standard", plugin.Type); - Assert.True(plugin.Capabilities.SupportsPassword); + Assert.True(plugin.Capabilities.SupportsPassword); + Assert.True(plugin.Capabilities.SupportsBootstrap); + Assert.True(plugin.Capabilities.SupportsClientProvisioning); Assert.False(plugin.Capabilities.SupportsMfa); Assert.True(plugin.Capabilities.SupportsClientProvisioning); @@ -181,7 +193,9 @@ public class StandardPluginRegistrarTests using var scope = provider.CreateScope(); var plugin = scope.ServiceProvider.GetRequiredService(); - Assert.True(plugin.Capabilities.SupportsPassword); + Assert.True(plugin.Capabilities.SupportsPassword); + Assert.True(plugin.Capabilities.SupportsBootstrap); + Assert.True(plugin.Capabilities.SupportsClientProvisioning); } [Fact] diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardUserCredentialStoreTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardUserCredentialStoreTests.cs index b5c011dd6..c588647a9 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardUserCredentialStoreTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardUserCredentialStoreTests.cs @@ -19,6 +19,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime private readonly IMongoDatabase database; private readonly StandardPluginOptions options; private readonly StandardUserCredentialStore store; + private readonly TestAuditLogger auditLogger; public StandardUserCredentialStoreTests() { @@ -50,17 +51,20 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime } }; var cryptoProvider = new DefaultCryptoProvider(); + auditLogger = new TestAuditLogger(); store = new StandardUserCredentialStore( "standard", database, options, new CryptoPasswordHasher(options, cryptoProvider), + auditLogger, NullLogger.Instance); } [Fact] public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials() { + auditLogger.Reset(); var registration = new AuthorityUserRegistration( "alice", "Password1!", @@ -77,11 +81,17 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime Assert.True(result.Succeeded); Assert.Equal("alice", result.User?.Username); Assert.Empty(result.AuditProperties); + + var auditEntry = Assert.Single(auditLogger.Events); + Assert.Equal("alice", auditEntry.Username); + Assert.True(auditEntry.Success); + Assert.Null(auditEntry.FailureCode); } [Fact] public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures() { + auditLogger.Reset(); await store.UpsertUserAsync( new AuthorityUserRegistration( "bob", @@ -103,11 +113,18 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime Assert.NotNull(second.RetryAfter); Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero); Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until"); + + Assert.Equal(2, auditLogger.Events.Count); + Assert.False(auditLogger.Events[0].Success); + Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, auditLogger.Events[0].FailureCode); + Assert.False(auditLogger.Events[1].Success); + Assert.Equal(AuthorityCredentialFailureCode.LockedOut, auditLogger.Events[1].FailureCode); } [Fact] public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2() { + auditLogger.Reset(); var legacyHash = new Pbkdf2PasswordHasher().Hash( "Legacy1!", new PasswordHashOptions @@ -136,6 +153,10 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime Assert.Equal("legacy", result.User?.Username); Assert.Contains(result.AuditProperties, property => property.Name == "plugin.rehashed"); + var auditEntry = Assert.Single(auditLogger.Events); + Assert.True(auditEntry.Success); + Assert.Equal("legacy", auditEntry.Username); + var updated = await database.GetCollection("authority_users_standard") .Find(u => u.NormalizedUsername == "legacy") .FirstOrDefaultAsync(); @@ -144,6 +165,23 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal); } + [Fact] + public async Task VerifyPasswordAsync_RecordsAudit_ForUnknownUser() + { + auditLogger.Reset(); + + var result = await store.VerifyPasswordAsync("unknown", "bad", CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, result.FailureCode); + + var auditEntry = Assert.Single(auditLogger.Events); + Assert.Equal("unknown", auditEntry.Username); + Assert.False(auditEntry.Success); + Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, auditEntry.FailureCode); + Assert.Equal("Invalid credentials.", auditEntry.Reason); + } + public Task InitializeAsync() => Task.CompletedTask; public Task DisposeAsync() @@ -152,3 +190,28 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime return Task.CompletedTask; } } + +internal sealed class TestAuditLogger : IStandardCredentialAuditLogger +{ + private readonly List events = new(); + + public IReadOnlyList Events => events; + + public void Reset() => events.Clear(); + + public ValueTask RecordAsync( + string pluginName, + string normalizedUsername, + string? subjectId, + bool success, + AuthorityCredentialFailureCode? failureCode, + string? reason, + IReadOnlyList properties, + CancellationToken cancellationToken) + { + events.Add(new AuditEntry(normalizedUsername, success, failureCode, reason)); + return ValueTask.CompletedTask; + } + + internal sealed record AuditEntry(string Username, bool Success, AuthorityCredentialFailureCode? FailureCode, string? Reason); +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Security/StandardCredentialAuditLogger.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Security/StandardCredentialAuditLogger.cs new file mode 100644 index 000000000..bb6570f0a --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Security/StandardCredentialAuditLogger.cs @@ -0,0 +1,132 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Logging; +using StellaOps.Authority.Plugins.Abstractions; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Cryptography.Audit; + +namespace StellaOps.Authority.Plugin.Standard.Security; + +internal interface IStandardCredentialAuditLogger +{ + ValueTask RecordAsync( + string pluginName, + string normalizedUsername, + string? subjectId, + bool success, + AuthorityCredentialFailureCode? failureCode, + string? reason, + IReadOnlyList properties, + CancellationToken cancellationToken); +} + +internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLogger +{ + private const string EventType = "authority.plugin.standard.password_verification"; + + private readonly IAuthorityLoginAttemptStore loginAttemptStore; + private readonly TimeProvider timeProvider; + private readonly ILogger logger; + + public StandardCredentialAuditLogger( + IAuthorityLoginAttemptStore loginAttemptStore, + TimeProvider timeProvider, + ILogger logger) + { + this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore)); + this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async ValueTask RecordAsync( + string pluginName, + string normalizedUsername, + string? subjectId, + bool success, + AuthorityCredentialFailureCode? failureCode, + string? reason, + IReadOnlyList properties, + CancellationToken cancellationToken) + { + try + { + var document = new AuthorityLoginAttemptDocument + { + EventType = EventType, + Outcome = NormalizeOutcome(success, failureCode), + SubjectId = Normalize(subjectId), + Username = Normalize(normalizedUsername), + Plugin = pluginName, + Successful = success, + Reason = Normalize(reason), + OccurredAt = timeProvider.GetUtcNow() + }; + + if (properties.Count > 0) + { + document.Properties = ConvertProperties(properties); + } + + await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to record credential audit event for plugin {PluginName}.", pluginName); + } + } + + private static string NormalizeOutcome(bool success, AuthorityCredentialFailureCode? failureCode) + { + if (success) + { + return "success"; + } + + return failureCode switch + { + AuthorityCredentialFailureCode.LockedOut => "locked_out", + AuthorityCredentialFailureCode.RequiresMfa => "requires_mfa", + AuthorityCredentialFailureCode.RequiresPasswordReset => "requires_password_reset", + AuthorityCredentialFailureCode.PasswordExpired => "password_expired", + _ => "failure" + }; + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static List ConvertProperties( + IReadOnlyList properties) + { + if (properties.Count == 0) + { + return new List(); + } + + var documents = new List(properties.Count); + foreach (var property in properties) + { + if (property is null || string.IsNullOrWhiteSpace(property.Name) || !property.Value.HasValue) + { + continue; + } + + documents.Add(new AuthorityLoginAttemptPropertyDocument + { + Name = property.Name, + Value = property.Value.Value, + Classification = NormalizeClassification(property.Value.Classification) + }); + } + + return documents; + } + + private static string NormalizeClassification(AuthEventDataClassification classification) + => classification switch + { + AuthEventDataClassification.Personal => "personal", + AuthEventDataClassification.Sensitive => "sensitive", + _ => "none" + }; +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardIdentityProviderPlugin.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardIdentityProviderPlugin.cs index 07ef9b483..f68bbe8c6 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardIdentityProviderPlugin.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardIdentityProviderPlugin.cs @@ -32,7 +32,26 @@ internal sealed class StandardIdentityProviderPlugin : IIdentityProviderPlugin Context.Manifest.Name); } - Capabilities = manifestCapabilities with { SupportsPassword = true }; + if (!manifestCapabilities.SupportsBootstrap) + { + this.logger.LogWarning( + "Standard Authority plugin '{PluginName}' manifest does not declare the 'bootstrap' capability. Forcing bootstrap support.", + Context.Manifest.Name); + } + + if (!manifestCapabilities.SupportsClientProvisioning) + { + this.logger.LogWarning( + "Standard Authority plugin '{PluginName}' manifest does not declare the 'clientProvisioning' capability. Forcing client provisioning support.", + Context.Manifest.Name); + } + + Capabilities = manifestCapabilities with + { + SupportsPassword = true, + SupportsBootstrap = true, + SupportsClientProvisioning = true + }; } public string Name => Context.Manifest.Name; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginRegistrar.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginRegistrar.cs index ddf7948e1..c6c8decc6 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginRegistrar.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginRegistrar.cs @@ -27,12 +27,12 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar var pluginName = context.Plugin.Manifest.Name; - context.Services.AddSingleton(); - context.Services.AddSingleton(sp => sp.GetRequiredService()); - - context.Services.AddStellaOpsCrypto(); - - var configPath = context.Plugin.Manifest.ConfigPath; + context.Services.AddSingleton(); + context.Services.AddSingleton(sp => sp.GetRequiredService()); + + context.Services.AddStellaOpsCrypto(); + + var configPath = context.Plugin.Manifest.ConfigPath; context.Services.AddOptions(pluginName) .Bind(context.Plugin.Configuration) @@ -43,18 +43,21 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar }) .ValidateOnStart(); - context.Services.AddScoped(sp => - { - var database = sp.GetRequiredService(); - var optionsMonitor = sp.GetRequiredService>(); - var pluginOptions = optionsMonitor.Get(pluginName); - var cryptoProvider = sp.GetRequiredService(); - var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider); - var loggerFactory = sp.GetRequiredService(); - var registrarLogger = loggerFactory.CreateLogger(); - - var baselinePolicy = new PasswordPolicyOptions(); - if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy)) + context.Services.AddScoped(); + + context.Services.AddScoped(sp => + { + var database = sp.GetRequiredService(); + var optionsMonitor = sp.GetRequiredService>(); + var pluginOptions = optionsMonitor.Get(pluginName); + var cryptoProvider = sp.GetRequiredService(); + var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider); + var loggerFactory = sp.GetRequiredService(); + var registrarLogger = loggerFactory.CreateLogger(); + var auditLogger = sp.GetRequiredService(); + + var baselinePolicy = new PasswordPolicyOptions(); + if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy)) { registrarLogger.LogWarning( "Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).", @@ -70,14 +73,15 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar baselinePolicy.RequireDigit, baselinePolicy.RequireSymbol); } - - return new StandardUserCredentialStore( - pluginName, - database, - pluginOptions, - passwordHasher, - loggerFactory.CreateLogger()); - }); + + return new StandardUserCredentialStore( + pluginName, + database, + pluginOptions, + passwordHasher, + auditLogger, + loggerFactory.CreateLogger()); + }); context.Services.AddScoped(sp => { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardUserCredentialStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardUserCredentialStore.cs index cd550f83c..c5fbd976f 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardUserCredentialStore.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardUserCredentialStore.cs @@ -18,6 +18,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore private readonly IMongoCollection users; private readonly StandardPluginOptions options; private readonly IPasswordHasher passwordHasher; + private readonly IStandardCredentialAuditLogger auditLogger; private readonly ILogger logger; private readonly string pluginName; @@ -26,11 +27,13 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore IMongoDatabase database, StandardPluginOptions options, IPasswordHasher passwordHasher, + IStandardCredentialAuditLogger auditLogger, ILogger logger) { this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName)); this.options = options ?? throw new ArgumentNullException(nameof(options)); this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher)); + this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); ArgumentNullException.ThrowIfNull(database); @@ -60,6 +63,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore if (user is null) { logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized); + await RecordAuditAsync( + normalized, + subjectId: null, + success: false, + failureCode: AuthorityCredentialFailureCode.InvalidCredentials, + reason: "Invalid credentials.", + auditProperties, + cancellationToken).ConfigureAwait(false); return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties); } @@ -73,6 +84,15 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture)) }); + await RecordAuditAsync( + normalized, + user.SubjectId, + success: false, + failureCode: AuthorityCredentialFailureCode.LockedOut, + reason: "Account is temporarily locked.", + auditProperties, + cancellationToken).ConfigureAwait(false); + return AuthorityCredentialVerificationResult.Failure( AuthorityCredentialFailureCode.LockedOut, "Account is temporarily locked.", @@ -111,6 +131,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore } var descriptor = ToDescriptor(user); + await RecordAuditAsync( + normalized, + descriptor.SubjectId, + success: true, + failureCode: null, + reason: descriptor.RequiresPasswordReset ? "Password reset required." : null, + auditProperties, + cancellationToken).ConfigureAwait(false); return AuthorityCredentialVerificationResult.Success( descriptor, descriptor.RequiresPasswordReset ? "Password reset required." : null, @@ -142,6 +170,15 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore }); } + await RecordAuditAsync( + normalized, + user.SubjectId, + success: false, + failureCode: code, + reason: code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.", + auditProperties, + cancellationToken).ConfigureAwait(false); + return AuthorityCredentialVerificationResult.Failure( code, code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.", @@ -371,4 +408,24 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName); } } + + private async ValueTask RecordAuditAsync( + string normalizedUsername, + string? subjectId, + bool success, + AuthorityCredentialFailureCode? failureCode, + string? reason, + IReadOnlyList auditProperties, + CancellationToken cancellationToken) + { + await auditLogger.RecordAsync( + pluginName, + normalizedUsername, + subjectId, + success, + failureCode, + reason, + auditProperties, + cancellationToken).ConfigureAwait(false); + } } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md index d50041e2a..1764ae296 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md @@ -2,10 +2,10 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| -| SEC2.PLG | BLOCKED (2025-10-21) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`.
⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 to stabilise Authority auth surfaces (PLUGIN-DI-08-001 landed 2025-10-21). | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. | +| SEC2.PLG | DOING (2025-11-08) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. | | SEC3.PLG | BLOCKED (2025-10-21) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after).
⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002; PLUGIN-DI-08-001 is done, limiter telemetry just awaits the updated Authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. | | SEC5.PLG | BLOCKED (2025-10-21) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog.
⛔ Final documentation now hinges on AUTH-DPOP-11-001 / AUTH-MTLS-11-002; scoped DI work is complete. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. | -| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles.
⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. | +| PLG4-6.CAPABILITIES | DONE (2025-11-08) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. | | PLG7.RFC | DONE (2025-11-03) | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. | | PLG7.IMPL-001 | DONE (2025-11-03) | BE-Auth Plugin | PLG7.RFC | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | ✅ Project + test harness build; ✅ Configuration bound & validated; ✅ Sample config updated. | | PLG7.IMPL-002 | DONE (2025-11-04) | BE-Auth Plugin, Security Guild | PLG7.IMPL-001 | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | ✅ Credential store passes integration tests (OpenLDAP + mtls); ✅ Metrics/logs emitted; ✅ Error mapping documented.
2025-11-04: DirectoryServices factory now enforces TLS/mTLS options, credential store retries use deterministic backoff with metrics, audit logging includes failure codes, and unit suite (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests`) remains green. | @@ -19,6 +19,8 @@ > 2025-11-03: PLG7.IMPL-001 completed – created `StellaOps.Authority.Plugin.Ldap` + tests projects, implemented configuration normalization/validation (client certificate, trust store, insecure toggle) with coverage (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj`), and refreshed `etc/authority.plugins/ldap.yaml`. > 2025-11-04: PLG7.IMPL-002 progress – StartTLS initialization now uses `StartTransportLayerSecurity(null)` and LDAP result-code handling normalized for `System.DirectoryServices.Protocols` 8.0 (invalid credentials + transient cases). Plugin tests rerun via `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj` (green). > 2025-11-04: PLG7.IMPL-002 progress – enforced TLS/client certificate validation, added structured audit properties and retry logging for credential lookups, warned on unsupported cipher lists, updated sample config, and reran `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj --no-restore`. +> 2025-11-08: PLG4-6.CAPABILITIES completed – added the `bootstrap` capability flag, extended registries/logs/docs, and gated bootstrap APIs on the new capability (`dotnet test` suites for plugins + Authority core all green). +> 2025-11-08: SEC2.PLG resumed – Standard plugin now records password verification outcomes via `StandardCredentialAuditLogger`, persisting events to `IAuthorityLoginAttemptStore`; unit tests cover success/lockout/failure flows (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj --no-build`). > Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE. diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions.Tests/AuthorityIdentityProviderCapabilitiesTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions.Tests/AuthorityIdentityProviderCapabilitiesTests.cs index f73835f78..f506ea23e 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions.Tests/AuthorityIdentityProviderCapabilitiesTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions.Tests/AuthorityIdentityProviderCapabilitiesTests.cs @@ -12,12 +12,14 @@ public class AuthorityIdentityProviderCapabilitiesTests { "password", "mfa", - "clientProvisioning" + "clientProvisioning", + "bootstrap" }); Assert.True(capabilities.SupportsPassword); Assert.True(capabilities.SupportsMfa); Assert.True(capabilities.SupportsClientProvisioning); + Assert.True(capabilities.SupportsBootstrap); } [Fact] @@ -28,6 +30,7 @@ public class AuthorityIdentityProviderCapabilitiesTests Assert.False(capabilities.SupportsPassword); Assert.False(capabilities.SupportsMfa); Assert.False(capabilities.SupportsClientProvisioning); + Assert.False(capabilities.SupportsBootstrap); } [Fact] @@ -38,5 +41,6 @@ public class AuthorityIdentityProviderCapabilitiesTests Assert.False(capabilities.SupportsPassword); Assert.False(capabilities.SupportsMfa); Assert.False(capabilities.SupportsClientProvisioning); + Assert.False(capabilities.SupportsBootstrap); } } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthorityPluginContracts.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthorityPluginContracts.cs index d343c99e6..61d9232ee 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthorityPluginContracts.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthorityPluginContracts.cs @@ -112,15 +112,20 @@ public interface IAuthorityIdentityProviderRegistry /// IReadOnlyCollection MfaProviders { get; } - /// - /// Gets metadata for identity providers that advertise client provisioning support. - /// - IReadOnlyCollection ClientProvisioningProviders { get; } - - /// - /// Aggregate capability flags across all registered providers. - /// - AuthorityIdentityProviderCapabilities AggregateCapabilities { get; } + /// + /// Gets metadata for identity providers that advertise client provisioning support. + /// + IReadOnlyCollection ClientProvisioningProviders { get; } + + /// + /// Gets metadata for identity providers that advertise bootstrap flows (user/client). + /// + IReadOnlyCollection BootstrapProviders { get; } + + /// + /// Aggregate capability flags across all registered providers. + /// + AuthorityIdentityProviderCapabilities AggregateCapabilities { get; } /// /// Attempts to resolve identity provider metadata by name. diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/IdentityProviderContracts.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/IdentityProviderContracts.cs index e8392398d..49892aa22 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/IdentityProviderContracts.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/IdentityProviderContracts.cs @@ -12,11 +12,12 @@ namespace StellaOps.Authority.Plugins.Abstractions; /// /// Describes feature support advertised by an identity provider plugin. /// -public sealed record AuthorityIdentityProviderCapabilities( - bool SupportsPassword, - bool SupportsMfa, - bool SupportsClientProvisioning) -{ +public sealed record AuthorityIdentityProviderCapabilities( + bool SupportsPassword, + bool SupportsMfa, + bool SupportsClientProvisioning, + bool SupportsBootstrap) +{ /// /// Builds capabilities metadata from a list of capability identifiers. /// @@ -24,7 +25,7 @@ public sealed record AuthorityIdentityProviderCapabilities( { if (capabilities is null) { - return new AuthorityIdentityProviderCapabilities(false, false, false); + return new AuthorityIdentityProviderCapabilities(false, false, false, false); } var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -38,11 +39,12 @@ public sealed record AuthorityIdentityProviderCapabilities( seen.Add(entry.Trim()); } - return new AuthorityIdentityProviderCapabilities( - SupportsPassword: seen.Contains(AuthorityPluginCapabilities.Password), - SupportsMfa: seen.Contains(AuthorityPluginCapabilities.Mfa), - SupportsClientProvisioning: seen.Contains(AuthorityPluginCapabilities.ClientProvisioning)); - } + return new AuthorityIdentityProviderCapabilities( + SupportsPassword: seen.Contains(AuthorityPluginCapabilities.Password), + SupportsMfa: seen.Contains(AuthorityPluginCapabilities.Mfa), + SupportsClientProvisioning: seen.Contains(AuthorityPluginCapabilities.ClientProvisioning), + SupportsBootstrap: seen.Contains(AuthorityPluginCapabilities.Bootstrap)); +} } /// diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Airgap/AuthoritySealedModeEvidenceValidatorTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Airgap/AuthoritySealedModeEvidenceValidatorTests.cs new file mode 100644 index 000000000..5510fad4c --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Airgap/AuthoritySealedModeEvidenceValidatorTests.cs @@ -0,0 +1,146 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Authority.Airgap; +using StellaOps.Configuration; +using Xunit; + +namespace StellaOps.Authority.Tests.Airgap; + +public class AuthoritySealedModeEvidenceValidatorTests +{ + [Fact] + public async Task ValidateAsync_ReturnsSuccess_WhenEvidenceFreshAndPassing() + { + using var temp = new TempDirectory(); + var evidencePath = Path.Combine(temp.Path, "authority-sealed-ci.json"); + WriteEvidence(evidencePath, DateTimeOffset.UtcNow, "pass", "pass", "pass", "pass"); + + var validator = CreateValidator(temp.Path, evidencePath, TimeSpan.FromHours(4)); + var result = await validator.ValidateAsync(CancellationToken.None); + + Assert.True(result.IsSatisfied); + Assert.NotNull(result.EvidenceTimestamp); + Assert.Equal(evidencePath, result.EvidencePath); + } + + [Fact] + public async Task ValidateAsync_ReturnsFailure_WhenEvidenceMissing() + { + using var temp = new TempDirectory(); + var evidencePath = Path.Combine(temp.Path, "missing.json"); + var validator = CreateValidator(temp.Path, evidencePath, TimeSpan.FromHours(1)); + + var result = await validator.ValidateAsync(CancellationToken.None); + + Assert.False(result.IsSatisfied); + Assert.Equal("evidence_missing", result.FailureCode); + } + + [Fact] + public async Task ValidateAsync_ReturnsFailure_WhenEvidenceStale() + { + using var temp = new TempDirectory(); + var evidencePath = Path.Combine(temp.Path, "authority-sealed-ci.json"); + var timestamp = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromHours(2)); + WriteEvidence(evidencePath, timestamp, "pass", "pass", "pass", "pass"); + + var validator = CreateValidator(temp.Path, evidencePath, TimeSpan.FromMinutes(30)); + var result = await validator.ValidateAsync(CancellationToken.None); + + Assert.False(result.IsSatisfied); + Assert.Equal("evidence_stale", result.FailureCode); + } + + private static AuthoritySealedModeEvidenceValidator CreateValidator(string contentRoot, string evidencePath, TimeSpan maxAge) + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.AirGap.SealedMode.EnforcementEnabled = true; + options.AirGap.SealedMode.EvidencePath = evidencePath; + options.AirGap.SealedMode.MaxEvidenceAge = maxAge; + options.AirGap.SealedMode.CacheLifetime = TimeSpan.FromMilliseconds(10); + + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var hostEnvironment = new TestHostEnvironment(contentRoot); + var timeProvider = TimeProvider.System; + return new AuthoritySealedModeEvidenceValidator( + options, + memoryCache, + hostEnvironment, + timeProvider, + NullLogger.Instance); + } + + private static void WriteEvidence( + string path, + DateTimeOffset timestamp, + string authorityStatus, + string signerStatus, + string attestorStatus, + string egressStatus) + { + var json = new + { + timestamp = timestamp.ToUniversalTime().ToString("O"), + project = "sealedmode", + network = "sealed", + health = new + { + authority = new { status = authorityStatus, url = "http://127.0.0.1:5088/healthz", log = "authority" }, + signer = new { status = signerStatus, url = "http://127.0.0.1:6088/healthz", log = "signer" }, + attestor = new { status = attestorStatus, url = "http://127.0.0.1:7088/healthz", log = "attestor" } + }, + egressProbe = new { status = egressStatus, report = "egress-probe.json" } + }; + + File.WriteAllText(path, JsonSerializer.Serialize(json)); + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "authority-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch + { + // Best effort cleanup. + } + } + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public TestHostEnvironment(string contentRoot) + { + ContentRootPath = contentRoot; + } + + public string ApplicationName { get; set; } = "tests"; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + public string ContentRootPath { get; set; } + public string EnvironmentName { get; set; } = Environments.Development; + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Identity/AuthorityIdentityProviderRegistryTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Identity/AuthorityIdentityProviderRegistryTests.cs index 92e0fd0a6..60027d4e0 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Identity/AuthorityIdentityProviderRegistryTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Identity/AuthorityIdentityProviderRegistryTests.cs @@ -16,11 +16,11 @@ public class AuthorityIdentityProviderRegistryTests [Fact] public async Task RegistryIndexesProvidersAndAggregatesCapabilities() { - var providers = new[] - { - CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false), - CreateProvider("sso", type: "saml", supportsPassword: false, supportsMfa: true, supportsClientProvisioning: true) - }; + var providers = new[] + { + CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false, supportsBootstrap: true), + CreateProvider("sso", type: "saml", supportsPassword: false, supportsMfa: true, supportsClientProvisioning: true) + }; using var serviceProvider = BuildServiceProvider(providers); var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger.Instance); @@ -29,11 +29,13 @@ public class AuthorityIdentityProviderRegistryTests Assert.True(registry.TryGet("standard", out var standard)); Assert.Equal("standard", standard!.Name); Assert.Single(registry.PasswordProviders); - Assert.Single(registry.MfaProviders); - Assert.Single(registry.ClientProvisioningProviders); - Assert.True(registry.AggregateCapabilities.SupportsPassword); - Assert.True(registry.AggregateCapabilities.SupportsMfa); - Assert.True(registry.AggregateCapabilities.SupportsClientProvisioning); + Assert.Single(registry.MfaProviders); + Assert.Single(registry.ClientProvisioningProviders); + Assert.Single(registry.BootstrapProviders); + Assert.True(registry.AggregateCapabilities.SupportsPassword); + Assert.True(registry.AggregateCapabilities.SupportsMfa); + Assert.True(registry.AggregateCapabilities.SupportsClientProvisioning); + Assert.True(registry.AggregateCapabilities.SupportsBootstrap); await using var handle = await registry.AcquireAsync("standard", default); Assert.Same(providers[0], handle.Provider); @@ -101,33 +103,34 @@ public class AuthorityIdentityProviderRegistryTests return services.BuildServiceProvider(); } - private static IIdentityProviderPlugin CreateProvider( - string name, - string type, - bool supportsPassword, - bool supportsMfa, - bool supportsClientProvisioning) - { - var manifest = new AuthorityPluginManifest( - name, - type, - true, - AssemblyName: null, - AssemblyPath: null, - Capabilities: BuildCapabilities(supportsPassword, supportsMfa, supportsClientProvisioning), - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase), - ConfigPath: string.Empty); - - var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); - return new TestIdentityProviderPlugin(context, supportsPassword, supportsMfa, supportsClientProvisioning); - } - - private static IReadOnlyList BuildCapabilities(bool password, bool mfa, bool clientProvisioning) - { - var capabilities = new List(); - if (password) - { - capabilities.Add(AuthorityPluginCapabilities.Password); + private static IIdentityProviderPlugin CreateProvider( + string name, + string type, + bool supportsPassword, + bool supportsMfa, + bool supportsClientProvisioning, + bool supportsBootstrap = false) + { + var manifest = new AuthorityPluginManifest( + name, + type, + true, + AssemblyName: null, + AssemblyPath: null, + Capabilities: BuildCapabilities(supportsPassword, supportsMfa, supportsClientProvisioning, supportsBootstrap), + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase), + ConfigPath: string.Empty); + + var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); + return new TestIdentityProviderPlugin(context, supportsPassword, supportsMfa, supportsClientProvisioning, supportsBootstrap); + } + + private static IReadOnlyList BuildCapabilities(bool password, bool mfa, bool clientProvisioning, bool bootstrap) + { + var capabilities = new List(); + if (password) + { + capabilities.Add(AuthorityPluginCapabilities.Password); } if (mfa) @@ -135,28 +138,35 @@ public class AuthorityIdentityProviderRegistryTests capabilities.Add(AuthorityPluginCapabilities.Mfa); } - if (clientProvisioning) - { - capabilities.Add(AuthorityPluginCapabilities.ClientProvisioning); - } - - return capabilities; - } - - private sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin - { - public TestIdentityProviderPlugin( - AuthorityPluginContext context, - bool supportsPassword, - bool supportsMfa, - bool supportsClientProvisioning) - { - Context = context; - Capabilities = new AuthorityIdentityProviderCapabilities( - SupportsPassword: supportsPassword, - SupportsMfa: supportsMfa, - SupportsClientProvisioning: supportsClientProvisioning); - } + if (clientProvisioning) + { + capabilities.Add(AuthorityPluginCapabilities.ClientProvisioning); + } + + if (bootstrap) + { + capabilities.Add(AuthorityPluginCapabilities.Bootstrap); + } + + return capabilities; + } + + private sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin + { + public TestIdentityProviderPlugin( + AuthorityPluginContext context, + bool supportsPassword, + bool supportsMfa, + bool supportsClientProvisioning, + bool supportsBootstrap) + { + Context = context; + Capabilities = new AuthorityIdentityProviderCapabilities( + SupportsPassword: supportsPassword, + SupportsMfa: supportsMfa, + SupportsClientProvisioning: supportsClientProvisioning, + SupportsBootstrap: supportsBootstrap); + } public string Name => Context.Manifest.Name; @@ -178,15 +188,16 @@ public class AuthorityIdentityProviderRegistryTests private sealed class ScopedIdentityProviderPlugin : IIdentityProviderPlugin { - public ScopedIdentityProviderPlugin(AuthorityPluginContext context) - { - Context = context; - InstanceId = Guid.NewGuid(); - Capabilities = new AuthorityIdentityProviderCapabilities( - SupportsPassword: true, - SupportsMfa: false, - SupportsClientProvisioning: false); - } + public ScopedIdentityProviderPlugin(AuthorityPluginContext context) + { + Context = context; + InstanceId = Guid.NewGuid(); + Capabilities = new AuthorityIdentityProviderCapabilities( + SupportsPassword: true, + SupportsMfa: false, + SupportsClientProvisioning: false, + SupportsBootstrap: false); + } public Guid InstanceId { get; } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Identity/AuthorityIdentityProviderSelectorTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Identity/AuthorityIdentityProviderSelectorTests.cs index 17cbe893b..de914ff73 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Identity/AuthorityIdentityProviderSelectorTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Identity/AuthorityIdentityProviderSelectorTests.cs @@ -96,14 +96,15 @@ public class AuthorityIdentityProviderSelectorTests private sealed class SelectorTestProvider : IIdentityProviderPlugin { - public SelectorTestProvider(AuthorityPluginContext context, bool supportsPassword) - { - Context = context; - Capabilities = new AuthorityIdentityProviderCapabilities( - SupportsPassword: supportsPassword, - SupportsMfa: false, - SupportsClientProvisioning: false); - } + public SelectorTestProvider(AuthorityPluginContext context, bool supportsPassword) + { + Context = context; + Capabilities = new AuthorityIdentityProviderCapabilities( + SupportsPassword: supportsPassword, + SupportsMfa: false, + SupportsClientProvisioning: false, + SupportsBootstrap: false); + } public string Name => Context.Manifest.Name; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs index 848330df2..298974676 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs @@ -4623,7 +4623,11 @@ public class ObservabilityIncidentTokenHandlerTests [Fact] public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope() { - var handler = new ValidateRefreshTokenGrantHandler(NullLogger.Instance); + var clientStore = new TestClientStore(CreateClient()); + var handler = new ValidateRefreshTokenGrantHandler( + clientStore, + new NoopCertificateValidator(), + NullLogger.Instance); var transaction = new OpenIddictServerTransaction { @@ -5139,7 +5143,8 @@ internal static class TestHelpers new AuthorityIdentityProviderCapabilities( SupportsPassword: true, SupportsMfa: false, - SupportsClientProvisioning: supportsClientProvisioning)); + SupportsClientProvisioning: supportsClientProvisioning, + SupportsBootstrap: false)); } public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins) diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs index 0e27cf68d..1ad576912 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs @@ -733,7 +733,7 @@ public class PasswordGrantHandlersTests Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); Credentials = store; ClaimsEnricher = new NoopClaimsEnricher(); - Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false); + Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false, SupportsBootstrap: false); } public string Name { get; } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Airgap/AuthoritySealedModeEvidenceValidator.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Airgap/AuthoritySealedModeEvidenceValidator.cs new file mode 100644 index 000000000..e7e7a4015 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Airgap/AuthoritySealedModeEvidenceValidator.cs @@ -0,0 +1,269 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StellaOps.Configuration; + +namespace StellaOps.Authority.Airgap; + +internal interface IAuthoritySealedModeEvidenceValidator +{ + ValueTask ValidateAsync(CancellationToken cancellationToken); +} + +internal sealed record AuthoritySealedModeValidationResult( + bool IsSatisfied, + string? FailureCode, + string? FailureDescription, + DateTimeOffset? EvidenceTimestamp, + string? EvidencePath) +{ + public static AuthoritySealedModeValidationResult Success(DateTimeOffset? timestamp, string? path) + => new(true, null, null, timestamp, path); + + public static AuthoritySealedModeValidationResult Failure(string failureCode, string failureDescription, string? path) + => new(false, failureCode, failureDescription, null, path); +} + +internal sealed class AuthoritySealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator +{ + private readonly StellaOpsAuthorityOptions options; + private readonly IMemoryCache memoryCache; + private readonly IHostEnvironment hostEnvironment; + private readonly TimeProvider timeProvider; + private readonly ILogger logger; + + public AuthoritySealedModeEvidenceValidator( + StellaOpsAuthorityOptions options, + IMemoryCache memoryCache, + IHostEnvironment hostEnvironment, + TimeProvider timeProvider, + ILogger logger) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); + this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment)); + this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async ValueTask ValidateAsync(CancellationToken cancellationToken) + { + var sealedOptions = options.AirGap.SealedMode; + if (!sealedOptions.EnforcementEnabled) + { + return AuthoritySealedModeValidationResult.Success(null, null); + } + + var cacheKey = $"authority:sealed-mode:{sealedOptions.EvidencePath}"; + if (memoryCache.TryGetValue(cacheKey, out AuthoritySealedModeValidationResult cached)) + { + return cached; + } + + var result = await LoadEvidenceAsync(sealedOptions, cancellationToken).ConfigureAwait(false); + + var cacheLifetime = sealedOptions.CacheLifetime <= TimeSpan.Zero + ? TimeSpan.FromSeconds(30) + : sealedOptions.CacheLifetime; + + memoryCache.Set(cacheKey, result, cacheLifetime); + + return result; + } + + private async Task LoadEvidenceAsync( + AuthoritySealedModeOptions sealedOptions, + CancellationToken cancellationToken) + { + var evidencePath = ResolveEvidencePath(sealedOptions.EvidencePath); + if (string.IsNullOrWhiteSpace(evidencePath) || !File.Exists(evidencePath)) + { + var message = $"Sealed-mode evidence file '{evidencePath}' does not exist."; + logger.LogWarning("Sealed-mode evidence missing at {Path}.", evidencePath); + return AuthoritySealedModeValidationResult.Failure("evidence_missing", message, evidencePath); + } + + try + { + await using var stream = new FileStream( + evidencePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var root = document.RootElement; + + if (!root.TryGetProperty("timestamp", out var timestampElement) || + timestampElement.ValueKind != JsonValueKind.String || + !DateTimeOffset.TryParse( + timestampElement.GetString(), + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var evidenceTimestamp)) + { + const string message = "Sealed-mode evidence is missing a valid timestamp."; + logger.LogWarning("Sealed-mode evidence at {Path} is missing a timestamp.", evidencePath); + return AuthoritySealedModeValidationResult.Failure("evidence_invalid_timestamp", message, evidencePath); + } + + var now = timeProvider.GetUtcNow(); + if (now - evidenceTimestamp > sealedOptions.MaxEvidenceAge) + { + var message = $"Sealed-mode evidence is older than the allowed window ({sealedOptions.MaxEvidenceAge})."; + logger.LogWarning("Sealed-mode evidence at {Path} expired at {Timestamp:O}.", evidencePath, evidenceTimestamp); + return AuthoritySealedModeValidationResult.Failure("evidence_stale", message, evidencePath); + } + + if (!ValidateHealthSection(root, "authority", sealedOptions.RequireAuthorityHealthPass, evidencePath, out var validationFailure)) + { + return validationFailure!; + } + + if (!ValidateHealthSection(root, "signer", sealedOptions.RequireSignerHealthPass, evidencePath, out validationFailure)) + { + return validationFailure!; + } + + if (!ValidateHealthSection(root, "attestor", sealedOptions.RequireAttestorHealthPass, evidencePath, out validationFailure)) + { + return validationFailure!; + } + + if (sealedOptions.RequireEgressProbePass) + { + if (!IsComponentPassing(root, "egressProbe", out var egressStatus)) + { + var message = "Sealed-mode evidence is missing egress probe results."; + logger.LogWarning("Sealed-mode evidence missing egress probe data at {Path}.", evidencePath); + return AuthoritySealedModeValidationResult.Failure("egress_probe_missing", message, evidencePath); + } + + if (!IsPass(egressStatus)) + { + var message = $"Sealed-mode egress probe failed with status '{egressStatus ?? "unknown"}'."; + logger.LogWarning("Sealed-mode egress probe failed with status {Status} at {Path}.", egressStatus ?? "unknown", evidencePath); + return AuthoritySealedModeValidationResult.Failure("egress_probe_failed", message, evidencePath); + } + } + + logger.LogDebug( + "Sealed-mode evidence verified at {Path} (timestamp {Timestamp:O}).", + evidencePath, + evidenceTimestamp); + + return AuthoritySealedModeValidationResult.Success(evidenceTimestamp, evidencePath); + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Failed to parse sealed-mode evidence at {Path}.", evidencePath); + return AuthoritySealedModeValidationResult.Failure("evidence_invalid", "Sealed-mode evidence is not valid JSON.", evidencePath); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Unable to read sealed-mode evidence at {Path}.", evidencePath); + return AuthoritySealedModeValidationResult.Failure("evidence_unreadable", "Unable to read sealed-mode evidence from disk.", evidencePath); + } + } + + private string ResolveEvidencePath(string configuredPath) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + { + return string.Empty; + } + + return Path.IsPathRooted(configuredPath) + ? configuredPath + : Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath ?? string.Empty, configuredPath)); + } + + private bool ValidateHealthSection( + JsonElement root, + string componentName, + bool enforcementEnabled, + string evidencePath, + out AuthoritySealedModeValidationResult? failure) + { + failure = null; + if (!enforcementEnabled) + { + return true; + } + + if (!TryGetHealthStatus(root, componentName, out var status)) + { + var message = $"Sealed-mode evidence is missing health data for '{componentName}'."; + logger.LogWarning("Sealed-mode evidence missing {Component} health at {Path}.", componentName, evidencePath); + failure = AuthoritySealedModeValidationResult.Failure($"{componentName}_health_missing", message, evidencePath); + return false; + } + + if (!IsPass(status)) + { + var message = $"Sealed-mode health check '{componentName}' reported '{status ?? "unknown"}'."; + logger.LogWarning("Sealed-mode {Component} health reported {Status} at {Path}.", componentName, status ?? "unknown", evidencePath); + failure = AuthoritySealedModeValidationResult.Failure($"{componentName}_health_failed", message, evidencePath); + return false; + } + + return true; + } + + private static bool TryGetHealthStatus(JsonElement root, string componentName, out string? status) + { + status = null; + if (!root.TryGetProperty("health", out var healthElement) || healthElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!healthElement.TryGetProperty(componentName, out var componentElement) || componentElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!componentElement.TryGetProperty("status", out var statusElement) || statusElement.ValueKind != JsonValueKind.String) + { + return false; + } + + status = statusElement.GetString(); + return true; + } + + private static bool IsComponentPassing(JsonElement root, string propertyName, out string? status) + { + status = null; + if (!root.TryGetProperty(propertyName, out var element)) + { + return false; + } + + if (element.ValueKind == JsonValueKind.Object && + element.TryGetProperty("status", out var statusElement) && + statusElement.ValueKind == JsonValueKind.String) + { + status = statusElement.GetString(); + return true; + } + + return false; + } + + private static bool IsPass(string? value) + => !string.IsNullOrWhiteSpace(value) && value.Equals("pass", StringComparison.OrdinalIgnoreCase); +} + +internal sealed class NoopAuthoritySealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator +{ + public static readonly NoopAuthoritySealedModeEvidenceValidator Instance = new(); + + public ValueTask ValidateAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(AuthoritySealedModeValidationResult.Success(null, null)); +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/AuthorityIdentityProviderRegistry.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/AuthorityIdentityProviderRegistry.cs index 5dca8615e..5ad9ab920 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/AuthorityIdentityProviderRegistry.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/AuthorityIdentityProviderRegistry.cs @@ -13,10 +13,11 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv { private readonly IServiceProvider serviceProvider; private readonly IReadOnlyDictionary providersByName; - private readonly ReadOnlyCollection providers; - private readonly ReadOnlyCollection passwordProviders; - private readonly ReadOnlyCollection mfaProviders; - private readonly ReadOnlyCollection clientProvisioningProviders; + private readonly ReadOnlyCollection providers; + private readonly ReadOnlyCollection passwordProviders; + private readonly ReadOnlyCollection mfaProviders; + private readonly ReadOnlyCollection clientProvisioningProviders; + private readonly ReadOnlyCollection bootstrapProviders; public AuthorityIdentityProviderRegistry( IServiceProvider serviceProvider, @@ -36,7 +37,8 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv var uniqueProviders = new List(orderedProviders.Count); var password = new List(); var mfa = new List(); - var clientProvisioning = new List(); + var clientProvisioning = new List(); + var bootstrap = new List(); var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -73,22 +75,29 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv mfa.Add(metadata); } - if (metadata.Capabilities.SupportsClientProvisioning) - { - clientProvisioning.Add(metadata); - } - } + if (metadata.Capabilities.SupportsClientProvisioning) + { + clientProvisioning.Add(metadata); + } + + if (metadata.Capabilities.SupportsBootstrap) + { + bootstrap.Add(metadata); + } + } - providersByName = dictionary; - providers = new ReadOnlyCollection(uniqueProviders); - passwordProviders = new ReadOnlyCollection(password); - mfaProviders = new ReadOnlyCollection(mfa); - clientProvisioningProviders = new ReadOnlyCollection(clientProvisioning); - - AggregateCapabilities = new AuthorityIdentityProviderCapabilities( - SupportsPassword: passwordProviders.Count > 0, - SupportsMfa: mfaProviders.Count > 0, - SupportsClientProvisioning: clientProvisioningProviders.Count > 0); + providersByName = dictionary; + providers = new ReadOnlyCollection(uniqueProviders); + passwordProviders = new ReadOnlyCollection(password); + mfaProviders = new ReadOnlyCollection(mfa); + clientProvisioningProviders = new ReadOnlyCollection(clientProvisioning); + bootstrapProviders = new ReadOnlyCollection(bootstrap); + + AggregateCapabilities = new AuthorityIdentityProviderCapabilities( + SupportsPassword: passwordProviders.Count > 0, + SupportsMfa: mfaProviders.Count > 0, + SupportsClientProvisioning: clientProvisioningProviders.Count > 0, + SupportsBootstrap: bootstrapProviders.Count > 0); } public IReadOnlyCollection Providers => providers; @@ -97,7 +106,9 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv public IReadOnlyCollection MfaProviders => mfaProviders; - public IReadOnlyCollection ClientProvisioningProviders => clientProvisioningProviders; + public IReadOnlyCollection ClientProvisioningProviders => clientProvisioningProviders; + + public IReadOnlyCollection BootstrapProviders => bootstrapProviders; public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/AuthorityOpenIddictConstants.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/AuthorityOpenIddictConstants.cs index 2e69591bf..8c3322189 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/AuthorityOpenIddictConstants.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/AuthorityOpenIddictConstants.cs @@ -22,6 +22,7 @@ internal static class AuthorityOpenIddictConstants internal const string DpopProofJwtIdProperty = "authority:dpop_jti"; internal const string DpopIssuedAtProperty = "authority:dpop_iat"; internal const string DpopConsumedNonceProperty = "authority:dpop_nonce"; + internal const string SealedModeStatusProperty = "authority:sealed_mode"; internal const string ConfirmationClaimType = "cnf"; internal const string SenderConstraintClaimType = "authority_sender_constraint"; internal const string SenderNonceClaimType = "authority_sender_nonce"; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs index ece4f82f1..4da284f86 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs @@ -15,6 +15,7 @@ using OpenIddict.Server; using OpenIddict.Server.AspNetCore; using MongoDB.Driver; using StellaOps.Auth.Abstractions; +using StellaOps.Authority.Airgap; using StellaOps.Authority.OpenIddict; using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Storage.Mongo.Documents; @@ -126,6 +127,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle private readonly IHttpContextAccessor httpContextAccessor; private readonly StellaOpsAuthorityOptions authorityOptions; private readonly ILogger logger; + private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator; private static readonly Regex AttributeValueRegex = new("^[a-z0-9][a-z0-9:_-]{0,127}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -141,7 +143,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle IAuthorityClientCertificateValidator certificateValidator, IHttpContextAccessor httpContextAccessor, StellaOpsAuthorityOptions authorityOptions, - ILogger logger) + ILogger logger, + IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null) { this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); @@ -155,6 +158,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance; } public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) @@ -220,6 +224,29 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle return; } + if (ClientCredentialHandlerHelpers.RequiresAirgapSealConfirmation(document.Properties)) + { + var sealedResult = await sealedModeEvidenceValidator.ValidateAsync(context.CancellationToken).ConfigureAwait(false); + if (!sealedResult.IsSatisfied) + { + var failureCode = sealedResult.FailureCode ?? "sealed_mode_missing"; + context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] = $"failure:{failureCode}"; + activity?.SetTag("authority.sealed_mode", failureCode); + context.Reject(OpenIddictConstants.Errors.InvalidClient, sealedResult.FailureDescription ?? "Sealed-mode evidence is missing or stale."); + logger.LogWarning( + "Client credentials validation failed for {ClientId}: sealed-mode evidence unsatisfied ({FailureCode}). {FailureDescription}", + document.ClientId, + failureCode, + sealedResult.FailureDescription); + return; + } + + var confirmation = sealedResult.EvidenceTimestamp?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] = + confirmation is null ? "confirmed" : $"confirmed:{confirmation}"; + activity?.SetTag("authority.sealed_mode", "confirmed"); + } + var existingSenderConstraint = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var senderConstraintValue) && senderConstraintValue is string existingConstraint ? existingConstraint : null; @@ -1309,6 +1336,17 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle }); } + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SealedModeStatusProperty, out var sealedStatusObj) && + sealedStatusObj is string sealedStatus && + !string.IsNullOrWhiteSpace(sealedStatus)) + { + extraProperties.Add(new AuthEventProperty + { + Name = "airgap.sealed", + Value = ClassifiedString.Public(sealedStatus) + }); + } + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountAuditObj) && serviceAccountAuditObj is AuthorityServiceAccountDocument auditServiceAccount && !string.IsNullOrWhiteSpace(auditServiceAccount.AccountId)) @@ -2137,4 +2175,19 @@ internal static class ClientCredentialHandlerHelpers return JsonSerializer.Serialize(current); } + + public static bool RequiresAirgapSealConfirmation(IReadOnlyDictionary properties) + { + if (!properties.TryGetValue(AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation, out var value) || + string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Trim(); + return normalized.Equals("true", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("1", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("y", StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/PasswordGrantHandlers.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/PasswordGrantHandlers.cs index faa79a4f5..596e8ea6e 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/PasswordGrantHandlers.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/PasswordGrantHandlers.cs @@ -11,6 +11,7 @@ using OpenIddict.Extensions; using OpenIddict.Server; using OpenIddict.Server.AspNetCore; using StellaOps.Auth.Abstractions; +using StellaOps.Authority.Airgap; using StellaOps.Authority.OpenIddict; using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.RateLimiting; @@ -25,10 +26,11 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler logger; + private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator; public ValidatePasswordGrantHandler( IAuthorityIdentityProviderRegistry registry, @@ -37,7 +39,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler logger) + ILogger logger, + IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null) { this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); @@ -46,6 +49,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler logger; + private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator; public ValidateRefreshTokenGrantHandler( IAuthorityClientStore clientStore, IAuthorityClientCertificateValidator certificateValidator, - ILogger logger) + ILogger logger, + IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null) { this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance; } public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) @@ -47,17 +52,48 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle return; } + var clientId = context.ClientId ?? context.Request.ClientId; + AuthorityClientDocument? clientDocument = null; + if (!string.IsNullOrWhiteSpace(clientId)) + { + clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); + if (clientDocument is not null && + ClientCredentialHandlerHelpers.RequiresAirgapSealConfirmation(clientDocument.Properties)) + { + var sealedResult = await sealedModeEvidenceValidator.ValidateAsync(context.CancellationToken).ConfigureAwait(false); + if (!sealedResult.IsSatisfied) + { + var failureCode = sealedResult.FailureCode ?? "sealed_mode_missing"; + context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] = $"failure:{failureCode}"; + context.Reject(OpenIddictConstants.Errors.InvalidClient, sealedResult.FailureDescription ?? "Sealed-mode evidence is missing or stale."); + logger.LogWarning( + "Refresh token validation failed for client {ClientId}: sealed-mode evidence unsatisfied ({FailureCode}). {FailureDescription}", + clientId ?? "", + failureCode, + sealedResult.FailureDescription); + return; + } + + var confirmation = sealedResult.EvidenceTimestamp?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] = + confirmation is null ? "confirmed" : $"confirmed:{confirmation}"; + } + } + var senderConstraint = refreshPrincipal?.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType); if (string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal)) { - if (!await EnsureMtlsBindingAsync(context, refreshPrincipal!).ConfigureAwait(false)) + if (!await EnsureMtlsBindingAsync(context, refreshPrincipal!, clientDocument).ConfigureAwait(false)) { return; } } } - private async ValueTask EnsureMtlsBindingAsync(OpenIddictServerEvents.ValidateTokenRequestContext context, ClaimsPrincipal principal) + private async ValueTask EnsureMtlsBindingAsync( + OpenIddictServerEvents.ValidateTokenRequestContext context, + ClaimsPrincipal principal, + AuthorityClientDocument? clientDocument) { var clientId = context.ClientId ?? context.Request.ClientId; if (string.IsNullOrWhiteSpace(clientId)) @@ -67,7 +103,7 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle return false; } - var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); + clientDocument ??= await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); if (clientDocument is null) { context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown client."); @@ -82,7 +118,7 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle return false; } - var validation = await certificateValidator.ValidateAsync(httpContext, clientDocument, context.CancellationToken).ConfigureAwait(false); + var validation = await certificateValidator.ValidateAsync(httpContext!, clientDocument, context.CancellationToken).ConfigureAwait(false); if (!validation.Succeeded || string.IsNullOrWhiteSpace(validation.HexThumbprint) || string.IsNullOrWhiteSpace(validation.ConfirmationThumbprint)) diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs index 2cc355d4a..97fd1d6fe 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs @@ -140,6 +140,7 @@ builder.Services.TryAddSingleton(); builder.Services.AddSingleton(); builder.Services.TryAddSingleton(); +builder.Services.TryAddSingleton(); builder.Services.AddSingleton(); builder.Services.TryAddSingleton(); @@ -472,15 +473,21 @@ else { var caps = provider.Capabilities; app.Logger.LogInformation( - "Identity provider plugin '{PluginName}' (type {PluginType}) capabilities: password={Password}, mfa={Mfa}, clientProvisioning={ClientProvisioning}.", + "Identity provider plugin '{PluginName}' (type {PluginType}) capabilities: password={Password}, mfa={Mfa}, clientProvisioning={ClientProvisioning}, bootstrap={Bootstrap}.", provider.Name, provider.Type, caps.SupportsPassword, caps.SupportsMfa, - caps.SupportsClientProvisioning); + caps.SupportsClientProvisioning, + caps.SupportsBootstrap); } } +if (authorityOptions.Bootstrap.Enabled && identityProviderRegistry.BootstrapProviders.Count == 0) +{ + app.Logger.LogWarning("Bootstrap APIs are enabled but no identity providers advertise the 'bootstrap' capability."); +} + if (authorityOptions.Bootstrap.Enabled) { var bootstrapGroup = app.MapGroup("/internal"); @@ -561,6 +568,13 @@ if (authorityOptions.Bootstrap.Enabled) return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." }); } + if (!providerMetadata.Capabilities.SupportsBootstrap) + { + await ReleaseInviteAsync("Selected provider does not support bootstrap provisioning."); + await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support bootstrap provisioning.", null, request.Username, providerMetadata.Name, request.Roles ?? Array.Empty(), inviteToken).ConfigureAwait(false); + return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support bootstrap provisioning." }); + } + if (!providerMetadata.Capabilities.SupportsPassword) { await ReleaseInviteAsync("Selected provider does not support password provisioning."); @@ -849,6 +863,13 @@ if (authorityOptions.Bootstrap.Enabled) return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." }); } + if (!providerMetadata.Capabilities.SupportsBootstrap) + { + await ReleaseInviteAsync("Selected provider does not support bootstrap provisioning."); + await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support bootstrap provisioning.", request.ClientId, null, providerMetadata.Name, request.AllowedScopes ?? Array.Empty(), request.Confidential, inviteToken).ConfigureAwait(false); + return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support bootstrap provisioning." }); + } + if (!providerMetadata.Capabilities.SupportsClientProvisioning) { await ReleaseInviteAsync("Selected provider does not support client provisioning."); diff --git a/src/Authority/StellaOps.Authority/TASKS.md b/src/Authority/StellaOps.Authority/TASKS.md index e0fc7efd7..a0ad4720b 100644 --- a/src/Authority/StellaOps.Authority/TASKS.md +++ b/src/Authority/StellaOps.Authority/TASKS.md @@ -35,7 +35,8 @@ | AUTH-DPOP-11-001 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce DPoP sender constraints for all Authority token flows (nonce store selection, algorithm allowlist, `cnf.jkt` persistence, structured telemetry). | `/token` enforces configured DPoP policies (nonce, allowed algorithms); cnf claims verified in integration tests; docs/runbooks updated with configuration guidance. | > 2025-11-08: DPoP validation now executes for every `/token` grant (client credentials, password, device, refresh); interactive handlers apply shared sender-constraint claims so tokens emit `cnf.jkt` + telemetry, and docs describe the expanded coverage. > 2025-11-07: Joint Authority/DevOps stand-up committed to shipping nonce store + telemetry updates by 2025-11-10; config samples and integration tests being updated in tandem. -| AUTH-MTLS-11-002 | DOING (2025-11-07) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Add mTLS-bound access token issuance/validation (client certificate thumbprints, JWKS rotation hooks) for high-assurance tenants and services. | mTLS certificate binding validated end-to-end; audit logs capture cert hashes; docs describe bootstrap/rotation steps. | +| AUTH-MTLS-11-002 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Add mTLS-bound access token issuance/validation (client certificate thumbprints, JWKS rotation hooks) for high-assurance tenants and services. | mTLS certificate binding validated end-to-end; audit logs capture cert hashes; docs describe bootstrap/rotation steps. | +> 2025-11-08: Refresh tokens now require the bound certificate, certificate thumbprints propagate through token issuance via `AuthoritySenderConstraintHelper`, and JWKS/docs updated to cover the expanded sender constraint surface. > 2025-11-08: Wiring cert thumbprint persistence + audit hooks now that DPoP nonce enforcement is in place; targeting shared delivery window with DEVOPS-AIRGAP-57-002. > 2025-11-07: Same stand-up aligned on 2025-11-10 target for mTLS enforcement + JWKS rotation docs so plugin mitigations can unblock. | AUTH-POLICY-23-001 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-002 | Introduce fine-grained policy scopes (`policy:read`, `policy:author`, `policy:review`, `policy:simulate`, `findings:read`) for CLI/service identities; refresh discovery metadata, issuer templates, and offline defaults. | Scope catalogue and sample configs updated; `policy-cli` seed credentials rotated; docs recorded migration steps. | diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs index c8d9e455e..abb1d2243 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs @@ -6339,15 +6339,26 @@ internal static class CommandHandlers console.Write(summary); - if (!string.IsNullOrWhiteSpace(output.Prompt)) + if (!string.IsNullOrWhiteSpace(output.Response)) { - var panel = new Panel(new Markup(Markup.Escape(output.Prompt))) + var responsePanel = new Panel(new Markup(Markup.Escape(output.Response))) { - Header = new PanelHeader("Prompt"), + Header = new PanelHeader("Response"), Border = BoxBorder.Rounded, Expand = true }; - console.Write(panel); + console.Write(responsePanel); + } + + if (!string.IsNullOrWhiteSpace(output.Prompt)) + { + var promptPanel = new Panel(new Markup(Markup.Escape(output.Prompt))) + { + Header = new PanelHeader("Prompt (sanitized)"), + Border = BoxBorder.Rounded, + Expand = true + }; + console.Write(promptPanel); } if (output.Citations.Count > 0) diff --git a/src/Cli/StellaOps.Cli/Services/Models/AdvisoryAi/AdvisoryAiModels.cs b/src/Cli/StellaOps.Cli/Services/Models/AdvisoryAi/AdvisoryAiModels.cs index 9655d28ec..1f49599e2 100644 --- a/src/Cli/StellaOps.Cli/Services/Models/AdvisoryAi/AdvisoryAiModels.cs +++ b/src/Cli/StellaOps.Cli/Services/Models/AdvisoryAi/AdvisoryAiModels.cs @@ -89,6 +89,8 @@ internal sealed class AdvisoryPipelineOutputModel public string Prompt { get; init; } = string.Empty; + public string Response { get; init; } = string.Empty; + public IReadOnlyList Citations { get; init; } = Array.Empty(); public Dictionary Metadata { get; init; } = new(StringComparer.Ordinal); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index ca0092bb4..19a57cc7d 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -220,8 +220,9 @@ if (authorityConfigured) } else { - builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + builder.Services + .AddAuthentication(StellaOpsAuthenticationDefaults.AuthenticationScheme) + .AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, options => { options.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata; options.TokenValidationParameters = new TokenValidationParameters @@ -237,6 +238,50 @@ if (authorityConfigured) NameClaimType = StellaOpsClaimTypes.Subject, RoleClaimType = ClaimTypes.Role }; + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + string? token = null; + if (context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authorizationValues)) + { + var authorization = authorizationValues.ToString(); + if (!string.IsNullOrWhiteSpace(authorization) && + authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && + authorization.Length > 7) + { + token = authorization.Substring("Bearer ".Length).Trim(); + } + } + + if (string.IsNullOrEmpty(token)) + { + token = context.Token; + } + + if (!string.IsNullOrWhiteSpace(token)) + { + var parts = token.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) + { + token = parts[^1]; + } + + token = token.Trim().Trim('"'); + } + + if (string.IsNullOrWhiteSpace(token)) + { + logger.LogWarning("JWT token missing from request to {Path}", context.HttpContext.Request.Path); + return Task.CompletedTask; + } + + context.Token = token; + + return Task.CompletedTask; + } + }; }); } } @@ -257,6 +302,8 @@ builder.Services.AddEndpointsApiExplorer(); var app = builder.Build(); +app.Logger.LogWarning("Authority enabled: {AuthorityEnabled}, test signing secret configured: {HasTestSecret}", authorityConfigured, !string.IsNullOrWhiteSpace(concelierOptions.Authority?.TestSigningSecret)); + if (features.NoMergeEnabled) { app.Logger.LogWarning("Legacy merge module disabled via concelier:features:noMergeEnabled; Link-Not-Merge mode active."); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs index 24fb05b87..90c283a6b 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs @@ -11,11 +11,13 @@ using System.Net.Http.Headers; using System.Security.Claims; using System.Text; using System.Text.Json; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Logging; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -577,6 +579,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime [Fact] public async Task AdvisoryIngestEndpoint_RejectsCrossTenantWhenAuthenticated() { + IdentityModelEventSource.ShowPII = true; var environment = new Dictionary { ["CONCELIER_AUTHORITY__ENABLED"] = "true", @@ -604,11 +607,29 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime environment); using var client = factory.CreateClient(); + var schemes = await factory.Services.GetRequiredService().GetAllSchemesAsync(); + _output.WriteLine("Schemes => " + string.Join(',', schemes.Select(s => s.Name))); var token = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest); + _output.WriteLine("token => " + token); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-auth"); var ingestResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:auth-1", "GHSA-AUTH-001")); + if (ingestResponse.StatusCode != HttpStatusCode.Created) + { + var body = await ingestResponse.Content.ReadAsStringAsync(); + _output.WriteLine($"ingestResponse => {(int)ingestResponse.StatusCode} {ingestResponse.StatusCode}: {body}"); + var authLogs = factory.LoggerProvider.Snapshot("Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler"); + foreach (var entry in authLogs) + { + _output.WriteLine($"authLog => {entry.Level}: {entry.Message} ({entry.Exception?.Message})"); + } + var programLogs = factory.LoggerProvider.Snapshot("StellaOps.Concelier.WebService.Program"); + foreach (var entry in programLogs) + { + _output.WriteLine($"programLog => {entry.Level}: {entry.Message}"); + } + } Assert.Equal(HttpStatusCode.Created, ingestResponse.StatusCode); client.DefaultRequestHeaders.Remove("X-Stella-Tenant"); diff --git a/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs b/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs index c2363f4a2..581fd6835 100644 --- a/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs +++ b/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs @@ -104,6 +104,11 @@ public sealed class StellaOpsAuthorityOptions /// public AuthorityNotificationsOptions Notifications { get; } = new(); + /// + /// Air-gap/sealed mode configuration for Authority. + /// + public AuthorityAirGapOptions AirGap { get; } = new(); + /// /// Vulnerability explorer integration configuration (workflow CSRF tokens, attachments). /// @@ -168,6 +173,7 @@ public sealed class StellaOpsAuthorityOptions AdvisoryAi.Normalize(); AdvisoryAi.Validate(); Notifications.Validate(); + AirGap.Validate(); VulnerabilityExplorer.Validate(); ApiLifecycle.Validate(); Signing.Validate(); @@ -236,6 +242,70 @@ public sealed class StellaOpsAuthorityOptions } } +public sealed class AuthorityAirGapOptions +{ + public AuthoritySealedModeOptions SealedMode { get; } = new(); + + internal void Validate() + { + SealedMode.Validate(); + } +} + +public sealed class AuthoritySealedModeOptions +{ + private static readonly TimeSpan DefaultMaxEvidenceAge = TimeSpan.FromHours(6); + private static readonly TimeSpan DefaultCacheLifetime = TimeSpan.FromMinutes(1); + + /// + /// Enables sealed-mode enforcement for clients that declare the requirement. + /// + public bool EnforcementEnabled { get; set; } + + /// + /// Path to the latest authority-sealed-ci.json artefact emitted by sealed-mode CI. + /// + public string EvidencePath { get; set; } = "artifacts/sealed-mode-ci/latest/authority-sealed-ci.json"; + + /// + /// Maximum age accepted for the sealed evidence document. + /// + public TimeSpan MaxEvidenceAge { get; set; } = DefaultMaxEvidenceAge; + + /// + /// Cache lifetime for parsed evidence to avoid re-reading the artefact on every request. + /// + public TimeSpan CacheLifetime { get; set; } = DefaultCacheLifetime; + + public bool RequireAuthorityHealthPass { get; set; } = true; + public bool RequireSignerHealthPass { get; set; } = true; + public bool RequireAttestorHealthPass { get; set; } = true; + public bool RequireEgressProbePass { get; set; } = true; + + internal void Validate() + { + if (!EnforcementEnabled) + { + return; + } + + if (string.IsNullOrWhiteSpace(EvidencePath)) + { + throw new InvalidOperationException("AirGap.SealedMode.EvidencePath must be provided when enforcement is enabled."); + } + + if (MaxEvidenceAge <= TimeSpan.Zero || MaxEvidenceAge > TimeSpan.FromDays(7)) + { + throw new InvalidOperationException("AirGap.SealedMode.MaxEvidenceAge must be between 00:00:01 and 7.00:00:00."); + } + + if (CacheLifetime <= TimeSpan.Zero || CacheLifetime > MaxEvidenceAge) + { + throw new InvalidOperationException("AirGap.SealedMode.CacheLifetime must be greater than zero and less than or equal to AirGap.SealedMode.MaxEvidenceAge."); + } + } +} + public sealed class AuthoritySecurityOptions { ///