feat: Enhance Authority Identity Provider Registry with Bootstrap Capability

- Added support for bootstrap providers in AuthorityIdentityProviderRegistry.
- Introduced a new property for bootstrap providers and updated AggregateCapabilities.
- Updated relevant methods to handle bootstrap capabilities during provider registration.

feat: Introduce Sealed Mode Status in OpenIddict Handlers

- Added SealedModeStatusProperty to AuthorityOpenIddictConstants.
- Enhanced ValidateClientCredentialsHandler, ValidatePasswordGrantHandler, and ValidateRefreshTokenGrantHandler to validate sealed mode evidence.
- Implemented logic to handle airgap seal confirmation requirements.

feat: Update Program Configuration for Sealed Mode

- Registered IAuthoritySealedModeEvidenceValidator in Program.cs.
- Added logging for bootstrap capabilities in identity provider plugins.
- Implemented checks for bootstrap support in API endpoints.

chore: Update Tasks and Documentation

- Marked AUTH-MTLS-11-002 as DONE in TASKS.md.
- Updated documentation to reflect changes in sealed mode and bootstrap capabilities.

fix: Improve CLI Command Handlers Output

- Enhanced output formatting for command responses and prompts in CommandHandlers.cs.

feat: Extend Advisory AI Models

- Added Response property to AdvisoryPipelineOutputModel for better output handling.

fix: Adjust Concelier Web Service Authentication

- Improved JWT token handling in Concelier Web Service to ensure proper token extraction and logging.

test: Enhance Web Service Endpoints Tests

- Added detailed logging for authentication failures in WebServiceEndpointsTests.
- Enabled PII logging for better debugging of authentication issues.

feat: Introduce Air-Gap Configuration Options

- Added AuthorityAirGapOptions and AuthoritySealedModeOptions to StellaOpsAuthorityOptions.
- Implemented validation logic for air-gap configurations to ensure proper setup.
This commit is contained in:
master
2025-11-09 12:18:14 +02:00
parent d71c81e45d
commit ba4c935182
68 changed files with 2142 additions and 291 deletions

View File

@@ -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 Helm deployments inherit the same defaults from `services.scheduler-worker.env` in
`values.yaml`; override them per environment as needed. `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 ### 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. `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.

View File

@@ -243,18 +243,62 @@ services:
- stellaops - stellaops
labels: *release-labels labels: *release-labels
excititor: excititor:
image: registry.stella-ops.org/stellaops/excititor@sha256:65c0ee13f773efe920d7181512349a09d363ab3f3e177d276136bd2742325a68 image: registry.stella-ops.org/stellaops/excititor@sha256:65c0ee13f773efe920d7181512349a09d363ab3f3e177d276136bd2742325a68
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- concelier - concelier
environment: environment:
EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445"
EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017"
networks: networks:
- stellaops - stellaops
labels: *release-labels 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: web-ui:
image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d
restart: unless-stopped restart: unless-stopped

View File

@@ -13,6 +13,9 @@ volumes:
rustfs-data: rustfs-data:
concelier-jobs: concelier-jobs:
nats-data: nats-data:
advisory-ai-queue:
advisory-ai-plans:
advisory-ai-outputs:
services: services:
mongo: mongo:
@@ -241,23 +244,67 @@ services:
- stellaops - stellaops
labels: *release-labels labels: *release-labels
excititor: excititor:
image: registry.stella-ops.org/stellaops/excititor@sha256:d9bd5cadf1eab427447ce3df7302c30ded837239771cc6433b9befb895054285 image: registry.stella-ops.org/stellaops/excititor@sha256:d9bd5cadf1eab427447ce3df7302c30ded837239771cc6433b9befb895054285
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- concelier - concelier
environment: environment:
EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445"
EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017"
networks: networks:
- stellaops - stellaops
labels: *release-labels labels: *release-labels
web-ui: advisory-ai-web:
image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf image: registry.stella-ops.org/stellaops/advisory-ai-web:2025.10.0-edge
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- scanner-web - 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: environment:
STELLAOPS_UI__BACKEND__BASEURL: "https://scanner-web:8444" STELLAOPS_UI__BACKEND__BASEURL: "https://scanner-web:8444"
ports: ports:

View File

@@ -10,12 +10,15 @@ networks:
external: true external: true
name: ${FRONTDOOR_NETWORK:-stellaops_frontdoor} name: ${FRONTDOOR_NETWORK:-stellaops_frontdoor}
volumes: volumes:
mongo-data: mongo-data:
minio-data: minio-data:
rustfs-data: rustfs-data:
concelier-jobs: concelier-jobs:
nats-data: nats-data:
advisory-ai-queue:
advisory-ai-plans:
advisory-ai-outputs:
services: services:
mongo: mongo:
@@ -250,19 +253,64 @@ services:
- frontdoor - frontdoor
labels: *release-labels labels: *release-labels
excititor: excititor:
image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- concelier - concelier
environment: environment:
EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445"
EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017"
networks: networks:
- stellaops - stellaops
labels: *release-labels labels: *release-labels
web-ui: 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 image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:

View File

@@ -13,6 +13,9 @@ volumes:
rustfs-data: rustfs-data:
concelier-jobs: concelier-jobs:
nats-data: nats-data:
advisory-ai-queue:
advisory-ai-plans:
advisory-ai-outputs:
services: services:
mongo: mongo:
@@ -241,19 +244,63 @@ services:
- stellaops - stellaops
labels: *release-labels labels: *release-labels
excititor: excititor:
image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- concelier - concelier
environment: environment:
EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445" EXCITITOR__CONCELIER__BASEURL: "https://concelier:8445"
EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017" EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017"
networks: networks:
- stellaops - stellaops
labels: *release-labels labels: *release-labels
web-ui: 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 image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:

View File

@@ -35,3 +35,8 @@ SCHEDULER_QUEUE_KIND=Nats
SCHEDULER_QUEUE_NATS_URL=nats://nats:4222 SCHEDULER_QUEUE_NATS_URL=nats://nats:4222
SCHEDULER_STORAGE_DATABASE=stellaops_scheduler SCHEDULER_STORAGE_DATABASE=stellaops_scheduler
SCHEDULER_SCANNER_BASEADDRESS=http://scanner-web:8444 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=

View File

@@ -35,3 +35,8 @@ SCHEDULER_QUEUE_KIND=Nats
SCHEDULER_QUEUE_NATS_URL=nats://nats:4222 SCHEDULER_QUEUE_NATS_URL=nats://nats:4222
SCHEDULER_STORAGE_DATABASE=stellaops_scheduler SCHEDULER_STORAGE_DATABASE=stellaops_scheduler
SCHEDULER_SCANNER_BASEADDRESS=http://scanner-web:8444 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=

View File

@@ -37,5 +37,10 @@ SCHEDULER_QUEUE_KIND=Nats
SCHEDULER_QUEUE_NATS_URL=nats://nats:4222 SCHEDULER_QUEUE_NATS_URL=nats://nats:4222
SCHEDULER_STORAGE_DATABASE=stellaops_scheduler SCHEDULER_STORAGE_DATABASE=stellaops_scheduler
SCHEDULER_SCANNER_BASEADDRESS=http://scanner-web:8444 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. # External reverse proxy (Traefik, Envoy, etc.) that terminates TLS.
FRONTDOOR_NETWORK=stellaops_frontdoor FRONTDOOR_NETWORK=stellaops_frontdoor

View File

@@ -34,3 +34,8 @@ SCHEDULER_QUEUE_KIND=Nats
SCHEDULER_QUEUE_NATS_URL=nats://nats:4222 SCHEDULER_QUEUE_NATS_URL=nats://nats:4222
SCHEDULER_STORAGE_DATABASE=stellaops_scheduler SCHEDULER_STORAGE_DATABASE=stellaops_scheduler
SCHEDULER_SCANNER_BASEADDRESS=http://scanner-web:8444 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=

View File

@@ -156,6 +156,40 @@ services:
env: env:
EXCITITOR__CONCELIER__BASEURL: "https://stellaops-concelier:8445" EXCITITOR__CONCELIER__BASEURL: "https://stellaops-concelier:8445"
EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://stellaops-airgap:stellaops-airgap@stellaops-mongo:27017" 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: web-ui:
image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d
service: service:

View File

@@ -160,6 +160,40 @@ services:
env: env:
EXCITITOR__CONCELIER__BASEURL: "https://stellaops-concelier:8445" EXCITITOR__CONCELIER__BASEURL: "https://stellaops-concelier:8445"
EXCITITOR__STORAGE__MONGO__CONNECTIONSTRING: "mongodb://stellaops:stellaops@stellaops-mongo:27017" 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: web-ui:
image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf
service: service:

View File

@@ -170,6 +170,46 @@ services:
envFrom: envFrom:
- secretRef: - secretRef:
name: stellaops-prod-core 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: web-ui:
image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23 image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23
service: service:

View File

@@ -111,3 +111,37 @@ services:
SCHEDULER__STORAGE__CONNECTIONSTRING: mongodb://scheduler-mongo:27017 SCHEDULER__STORAGE__CONNECTIONSTRING: mongodb://scheduler-mongo:27017
SCHEDULER__STORAGE__DATABASE: stellaops_scheduler SCHEDULER__STORAGE__DATABASE: stellaops_scheduler
SCHEDULER__WORKER__RUNNER__SCANNER__BASEADDRESS: http://scanner-web:8444 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

View File

@@ -16,10 +16,14 @@ release:
image: registry.stella-ops.org/stellaops/scanner-worker@sha256:eea5d6cfe7835950c5ec7a735a651f2f0d727d3e470cf9027a4a402ea89c4fb5 image: registry.stella-ops.org/stellaops/scanner-worker@sha256:eea5d6cfe7835950c5ec7a735a651f2f0d727d3e470cf9027a4a402ea89c4fb5
- name: concelier - name: concelier
image: registry.stella-ops.org/stellaops/concelier@sha256:29e2e1a0972707e092cbd3d370701341f9fec2aa9316fb5d8100480f2a1c76b5 image: registry.stella-ops.org/stellaops/concelier@sha256:29e2e1a0972707e092cbd3d370701341f9fec2aa9316fb5d8100480f2a1c76b5
- name: excititor - name: excititor
image: registry.stella-ops.org/stellaops/excititor@sha256:65c0ee13f773efe920d7181512349a09d363ab3f3e177d276136bd2742325a68 image: registry.stella-ops.org/stellaops/excititor@sha256:65c0ee13f773efe920d7181512349a09d363ab3f3e177d276136bd2742325a68
- name: web-ui - name: advisory-ai-web
image: registry.stella-ops.org/stellaops/web-ui@sha256:bee9668011ff414572131dc777faab4da24473fe12c230893f161cabee092a1d 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: infrastructure:
mongo: mongo:
image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49 image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49

View File

@@ -16,10 +16,14 @@ release:
image: registry.stella-ops.org/stellaops/scanner-worker@sha256:32e25e76386eb9ea8bee0a1ad546775db9a2df989fab61ac877e351881960dab image: registry.stella-ops.org/stellaops/scanner-worker@sha256:32e25e76386eb9ea8bee0a1ad546775db9a2df989fab61ac877e351881960dab
- name: concelier - name: concelier
image: registry.stella-ops.org/stellaops/concelier@sha256:c58cdcaee1d266d68d498e41110a589dd204b487d37381096bd61ab345a867c5 image: registry.stella-ops.org/stellaops/concelier@sha256:c58cdcaee1d266d68d498e41110a589dd204b487d37381096bd61ab345a867c5
- name: excititor - name: excititor
image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa image: registry.stella-ops.org/stellaops/excititor@sha256:59022e2016aebcef5c856d163ae705755d3f81949d41195256e935ef40a627fa
- name: web-ui - name: advisory-ai-web
image: registry.stella-ops.org/stellaops/web-ui@sha256:10d924808c48e4353e3a241da62eb7aefe727a1d6dc830eb23a8e181013b3a23 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: infrastructure:
mongo: mongo:
image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49 image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49

View File

@@ -18,10 +18,14 @@
image: registry.stella-ops.org/stellaops/scanner-worker@sha256:92dda42f6f64b2d9522104a5c9ffb61d37b34dd193132b68457a259748008f37 image: registry.stella-ops.org/stellaops/scanner-worker@sha256:92dda42f6f64b2d9522104a5c9ffb61d37b34dd193132b68457a259748008f37
- name: concelier - name: concelier
image: registry.stella-ops.org/stellaops/concelier@sha256:dafef3954eb4b837e2c424dd2d23e1e4d60fa83794840fac9cd3dea1d43bd085 image: registry.stella-ops.org/stellaops/concelier@sha256:dafef3954eb4b837e2c424dd2d23e1e4d60fa83794840fac9cd3dea1d43bd085
- name: excititor - name: excititor
image: registry.stella-ops.org/stellaops/excititor@sha256:d9bd5cadf1eab427447ce3df7302c30ded837239771cc6433b9befb895054285 image: registry.stella-ops.org/stellaops/excititor@sha256:d9bd5cadf1eab427447ce3df7302c30ded837239771cc6433b9befb895054285
- name: web-ui - name: advisory-ai-web
image: registry.stella-ops.org/stellaops/web-ui@sha256:38b225fa7767a5b94ebae4dae8696044126aac429415e93de514d5dd95748dcf 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: infrastructure:
mongo: mongo:
image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49 image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49

View File

@@ -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. - 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.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. - `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. - `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=...}`. - 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): - Example (enabling Redis-backed nonces; adjust audiences per deployment):

View File

@@ -13,7 +13,7 @@ completely isolated network:
| Component | Contents | | Component | Contents |
|-----------|----------| |-----------|----------|
| **Merged vulnerability feeds** | OSV, GHSA plus optional NVD 2.0, CNNVD, CNVD, ENISA, JVN and BDU | | **Merged vulnerability feeds** | OSV, GHSA plus optional NVD 2.0, CNNVD, CNVD, ENISA, JVN and BDU |
| **Container images** | `stella-ops`, *Zastava* sidecar (x8664 &arm64) | | **Container images** | `stella-ops`, *Zastava* sidecar, `advisory-ai-web`, and `advisory-ai-worker` (x8664 &arm64) |
| **Provenance** | Cosign signature, SPDX 2.3 SBOM, intoto SLSA attestation | | **Provenance** | Cosign signature, SPDX 2.3 SBOM, intoto SLSA attestation |
| **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. | | **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. |
| **Delta patches** | Daily diff bundles keep size \<350MB | | **Delta patches** | Daily diff bundles keep size \<350MB |
@@ -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 airgapped network. Drop the most recent `vulxml.zip` alongside the kit if operators need a cold-start cache. **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 airgapped 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. **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 }}**. *Scanner core:* C# 12 on **.NET{{ dotnet }}**.
*Imports are idempotent and atomic — no service downtime.* *Imports are idempotent and atomic — no service downtime.*

View File

@@ -35,7 +35,7 @@ _Source:_ `docs/assets/authority/authority-plugin-component.mmd`
Capability flags let the host reason about what your plug-in supports: 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. - 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`): - Typical configuration (`etc/authority.plugins/standard.yaml`):
```yaml ```yaml
plugins: plugins:

View File

@@ -20,14 +20,16 @@ Focus: Identity & Signing focus on Authority (phase II).
| # | Task ID & handle | State | Key dependency / next step | Owners | | # | 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) | | 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) | | 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 | 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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 ## 100.D) __Libraries
Dependency: None specified; follow module prerequisites. Dependency: None specified; follow module prerequisites.
Focus: Identity & Signing focus on __Libraries. Focus: Identity & Signing focus on __Libraries.

View File

@@ -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: 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-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-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. - **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. - **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. - **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.

View File

@@ -26,6 +26,11 @@ Advisory AI is the retrieval-augmented assistant that synthesizes advisory and V
- Redaction policies validated against security/LLM guardrail tests. - Redaction policies validated against security/LLM guardrail tests.
- Guardrail behaviour, blocked phrases, and operational alerts are detailed in `/docs/security/assistant-guardrails.md`. - 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 ## CLI usage
- `stella advise run <summary|conflict|remediation> --advisory-key <id> [--artifact-id id] [--artifact-purl purl] [--policy-version v] [--profile profile] [--section name] [--force-refresh] [--timeout seconds]` - `stella advise run <summary|conflict|remediation> --advisory-key <id> [--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 120s, single check if `--timeout 0`). - Requests an advisory plan from the web service, enqueues execution, then polls for the generated output (default wait 120s, single check if `--timeout 0`).

View File

@@ -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. - **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. - **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. - **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 / 4GiB RAM recommended) while the web front end is I/O-bound (1 vCPU / 1GiB). Because the queue/plan/output stores are content-addressed files, ensure the shared volume delivers ≥500IOPS and <5ms 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.

View File

@@ -65,6 +65,17 @@ notifications:
scope: "notify.escalate" scope: "notify.escalate"
requireAdminScope: true 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: vulnerabilityExplorer:
workflow: workflow:
antiForgery: antiForgery:
@@ -226,6 +237,8 @@ clients:
scopes: [ "airgap:status:read", "airgap:import", "airgap:seal" ] scopes: [ "airgap:status:read", "airgap:import", "airgap:seal" ]
tenant: "tenant-default" tenant: "tenant-default"
senderConstraint: "dpop" senderConstraint: "dpop"
properties:
requiresAirgapSealConfirmation: true
auth: auth:
type: "client_secret" type: "client_secret"
secretFile: "../secrets/airgap-operator.secret" secretFile: "../secrets/airgap-operator.secret"

View File

@@ -72,6 +72,24 @@
"project": "src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj", "project": "src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj",
"entrypoint": "StellaOps.Excititor.WebService.dll" "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", "name": "web-ui",
"repository": "web-ui", "repository": "web-ui",

View File

@@ -46,6 +46,15 @@ airGap:
allowPrivateNetworks: true allowPrivateNetworks: true
remediationDocumentationUrl: https://docs.stella-ops.org/airgap/sealed-ci remediationDocumentationUrl: https://docs.stella-ops.org/airgap/sealed-ci
supportContact: airgap-ops@stella-ops.org 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: tenants:
- name: sealed-ci - name: sealed-ci
roles: roles:

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using StellaOps.AdvisoryAI.Inference;
namespace StellaOps.AdvisoryAI.Hosting; namespace StellaOps.AdvisoryAI.Hosting;
@@ -15,6 +16,8 @@ public sealed class AdvisoryAiServiceOptions
public AdvisoryAiStorageOptions Storage { get; set; } = new(); public AdvisoryAiStorageOptions Storage { get; set; } = new();
public AdvisoryAiInferenceOptions Inference { get; set; } = new();
internal string ResolveQueueDirectory(string contentRoot) internal string ResolveQueueDirectory(string contentRoot)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot); ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot);

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using StellaOps.AdvisoryAI.Inference;
namespace StellaOps.AdvisoryAI.Hosting; namespace StellaOps.AdvisoryAI.Hosting;
@@ -52,6 +53,24 @@ internal static class AdvisoryAiServiceOptionsValidator
options.Storage.OutputDirectory = Path.Combine("data", "advisory-ai", "outputs"); 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; error = null;
return true; return true;
} }

View File

@@ -101,6 +101,7 @@ internal sealed class FileSystemAdvisoryOutputStore : IAdvisoryOutputStore
AdvisoryTaskType TaskType, AdvisoryTaskType TaskType,
string Profile, string Profile,
string Prompt, string Prompt,
string Response,
List<AdvisoryPromptCitation> Citations, List<AdvisoryPromptCitation> Citations,
Dictionary<string, string> Metadata, Dictionary<string, string> Metadata,
GuardrailEnvelope Guardrail, GuardrailEnvelope Guardrail,
@@ -114,6 +115,7 @@ internal sealed class FileSystemAdvisoryOutputStore : IAdvisoryOutputStore
output.TaskType, output.TaskType,
output.Profile, output.Profile,
output.Prompt, output.Prompt,
output.Response,
output.Citations.ToList(), output.Citations.ToList(),
output.Metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal), output.Metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal),
GuardrailEnvelope.FromResult(output.Guardrail), GuardrailEnvelope.FromResult(output.Guardrail),
@@ -132,6 +134,7 @@ internal sealed class FileSystemAdvisoryOutputStore : IAdvisoryOutputStore
TaskType, TaskType,
Profile, Profile,
Prompt, Prompt,
Response,
citations, citations,
metadata, metadata,
guardrail, guardrail,

View File

@@ -1,35 +1,38 @@
using System; using System;
using Microsoft.Extensions.Configuration; using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Caching; using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.DependencyInjection; using StellaOps.AdvisoryAI.DependencyInjection;
using StellaOps.AdvisoryAI.Inference;
using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Outputs;
using StellaOps.AdvisoryAI.Providers; using StellaOps.AdvisoryAI.Providers;
using StellaOps.AdvisoryAI.Queue; using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Outputs;
namespace StellaOps.AdvisoryAI.Hosting; namespace StellaOps.AdvisoryAI.Hosting;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddAdvisoryAiCore( public static IServiceCollection AddAdvisoryAiCore(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration, IConfiguration configuration,
Action<AdvisoryAiServiceOptions>? configure = null) Action<AdvisoryAiServiceOptions>? configure = null)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
services.AddOptions<AdvisoryAiServiceOptions>() services.AddOptions<AdvisoryAiServiceOptions>()
.Bind(configuration.GetSection("AdvisoryAI")) .Bind(configuration.GetSection("AdvisoryAI"))
.PostConfigure(options => .PostConfigure(options =>
{ {
configure?.Invoke(options); configure?.Invoke(options);
AdvisoryAiServiceOptionsValidator.Validate(options); AdvisoryAiServiceOptionsValidator.Validate(options);
}) })
.ValidateOnStart(); .ValidateOnStart();
services.AddOptions<SbomContextClientOptions>() services.AddOptions<SbomContextClientOptions>()
.Configure<IOptions<AdvisoryAiServiceOptions>>((target, source) => .Configure<IOptions<AdvisoryAiServiceOptions>>((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."); .Validate(opt => opt.BaseAddress is null || opt.BaseAddress.IsAbsoluteUri, "SBOM base address must be absolute when provided.");
services.AddOptions<AdvisoryAiInferenceOptions>()
.Configure<IOptions<AdvisoryAiServiceOptions>>((target, source) =>
{
var inference = source.Value.Inference ?? new AdvisoryAiInferenceOptions();
target.Mode = inference.Mode;
target.Remote = inference.Remote ?? new AdvisoryAiRemoteInferenceOptions();
});
services.AddHttpClient<RemoteAdvisoryInferenceClient>((provider, client) =>
{
var inference = provider.GetRequiredService<IOptions<AdvisoryAiInferenceOptions>>().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<LocalAdvisoryInferenceClient>();
services.TryAddSingleton<RemoteAdvisoryInferenceClient>();
services.AddSingleton<IAdvisoryInferenceClient>(provider =>
{
var inference = provider.GetRequiredService<IOptions<AdvisoryAiInferenceOptions>>().Value ?? new AdvisoryAiInferenceOptions();
return inference.Mode == AdvisoryAiInferenceMode.Remote
? provider.GetRequiredService<RemoteAdvisoryInferenceClient>()
: provider.GetRequiredService<LocalAdvisoryInferenceClient>();
});
services.AddSbomContext(); services.AddSbomContext();
services.AddAdvisoryPipeline(); services.AddAdvisoryPipeline();
services.AddAdvisoryPipelineInfrastructure(); services.AddAdvisoryPipelineInfrastructure();

View File

@@ -10,6 +10,7 @@ internal sealed record AdvisoryOutputResponse(
string TaskType, string TaskType,
string Profile, string Profile,
string Prompt, string Prompt,
string Response,
IReadOnlyList<AdvisoryOutputCitation> Citations, IReadOnlyList<AdvisoryOutputCitation> Citations,
IReadOnlyDictionary<string, string> Metadata, IReadOnlyDictionary<string, string> Metadata,
AdvisoryOutputGuardrail Guardrail, AdvisoryOutputGuardrail Guardrail,
@@ -23,6 +24,7 @@ internal sealed record AdvisoryOutputResponse(
output.TaskType.ToString(), output.TaskType.ToString(),
output.Profile, output.Profile,
output.Prompt, output.Prompt,
output.Response,
output.Citations output.Citations
.Select(citation => new AdvisoryOutputCitation(citation.Index, citation.DocumentId, citation.ChunkId)) .Select(citation => new AdvisoryOutputCitation(citation.Index, citation.DocumentId, citation.ChunkId))
.ToList(), .ToList(),

View File

@@ -23,7 +23,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Configuration builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.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.AddAdvisoryAiCore(builder.Configuration);
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();

View File

@@ -10,7 +10,7 @@ var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(args);
builder.Configuration builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.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.AddAdvisoryAiCore(builder.Configuration);
builder.Services.AddHostedService<AdvisoryTaskWorker>(); builder.Services.AddHostedService<AdvisoryTaskWorker>();

View File

@@ -8,6 +8,7 @@ using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Prompting; using StellaOps.AdvisoryAI.Prompting;
using StellaOps.AdvisoryAI.Metrics; using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Queue; using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Inference;
namespace StellaOps.AdvisoryAI.Execution; namespace StellaOps.AdvisoryAI.Execution;
@@ -27,6 +28,7 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor
private readonly IAdvisoryOutputStore _outputStore; private readonly IAdvisoryOutputStore _outputStore;
private readonly AdvisoryPipelineMetrics _metrics; private readonly AdvisoryPipelineMetrics _metrics;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
private readonly IAdvisoryInferenceClient _inferenceClient;
private readonly ILogger<AdvisoryPipelineExecutor>? _logger; private readonly ILogger<AdvisoryPipelineExecutor>? _logger;
public AdvisoryPipelineExecutor( public AdvisoryPipelineExecutor(
@@ -35,6 +37,7 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor
IAdvisoryOutputStore outputStore, IAdvisoryOutputStore outputStore,
AdvisoryPipelineMetrics metrics, AdvisoryPipelineMetrics metrics,
TimeProvider timeProvider, TimeProvider timeProvider,
IAdvisoryInferenceClient inferenceClient,
ILogger<AdvisoryPipelineExecutor>? logger = null) ILogger<AdvisoryPipelineExecutor>? logger = null)
{ {
_promptAssembler = promptAssembler ?? throw new ArgumentNullException(nameof(promptAssembler)); _promptAssembler = promptAssembler ?? throw new ArgumentNullException(nameof(promptAssembler));
@@ -42,6 +45,7 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor
_outputStore = outputStore ?? throw new ArgumentNullException(nameof(outputStore)); _outputStore = outputStore ?? throw new ArgumentNullException(nameof(outputStore));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_inferenceClient = inferenceClient ?? throw new ArgumentNullException(nameof(inferenceClient));
_logger = logger; _logger = logger;
} }
@@ -87,8 +91,9 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor
prompt.Citations.Length, prompt.Citations.Length,
plan.StructuredChunks.Length); plan.StructuredChunks.Length);
var inferenceResult = await _inferenceClient.GenerateAsync(plan, prompt, guardrailResult, cancellationToken).ConfigureAwait(false);
var generatedAt = _timeProvider.GetUtcNow(); 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); await _outputStore.SaveAsync(output, cancellationToken).ConfigureAwait(false);
_metrics.RecordOutputStored(plan.Request.TaskType, planFromCache, guardrailResult.Blocked); _metrics.RecordOutputStored(plan.Request.TaskType, planFromCache, guardrailResult.Blocked);

View File

@@ -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<AdvisoryInferenceResult> GenerateAsync(
AdvisoryTaskPlan plan,
AdvisoryPrompt prompt,
AdvisoryGuardrailResult guardrailResult,
CancellationToken cancellationToken);
}
public sealed record AdvisoryInferenceResult(
string Content,
string? ModelId,
int? PromptTokens,
int? CompletionTokens,
ImmutableDictionary<string, string> Metadata)
{
public static AdvisoryInferenceResult FromLocal(string content)
=> new(
content,
"local.prompt-preview",
null,
null,
ImmutableDictionary.Create<string, string>(StringComparer.Ordinal));
public static AdvisoryInferenceResult FromFallback(string content, string reason, string? details = null)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(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<AdvisoryInferenceResult> 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<AdvisoryAiInferenceOptions> _options;
private readonly ILogger<RemoteAdvisoryInferenceClient>? _logger;
public RemoteAdvisoryInferenceClient(
HttpClient httpClient,
IOptions<AdvisoryAiInferenceOptions> options,
ILogger<RemoteAdvisoryInferenceClient>? logger = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
}
public async Task<AdvisoryInferenceResult> 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<RemoteInferenceResponse>(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<string, string>(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<string, string> Metadata,
IReadOnlyList<RemoteInferenceCitation> 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<string, string>? 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
}

View File

@@ -1,10 +1,12 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using StellaOps.AdvisoryAI.Guardrails; using StellaOps.AdvisoryAI.Guardrails;
using StellaOps.AdvisoryAI.Prompting; using StellaOps.AdvisoryAI.Prompting;
using StellaOps.AdvisoryAI.Orchestration; using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Inference;
namespace StellaOps.AdvisoryAI.Outputs; namespace StellaOps.AdvisoryAI.Outputs;
@@ -22,6 +24,7 @@ public sealed class AdvisoryPipelineOutput
AdvisoryTaskType taskType, AdvisoryTaskType taskType,
string profile, string profile,
string prompt, string prompt,
string response,
ImmutableArray<AdvisoryPromptCitation> citations, ImmutableArray<AdvisoryPromptCitation> citations,
ImmutableDictionary<string, string> metadata, ImmutableDictionary<string, string> metadata,
AdvisoryGuardrailResult guardrail, AdvisoryGuardrailResult guardrail,
@@ -33,6 +36,7 @@ public sealed class AdvisoryPipelineOutput
TaskType = taskType; TaskType = taskType;
Profile = string.IsNullOrWhiteSpace(profile) ? throw new ArgumentException(nameof(profile)) : profile; Profile = string.IsNullOrWhiteSpace(profile) ? throw new ArgumentException(nameof(profile)) : profile;
Prompt = prompt ?? throw new ArgumentNullException(nameof(prompt)); Prompt = prompt ?? throw new ArgumentNullException(nameof(prompt));
Response = response ?? throw new ArgumentNullException(nameof(response));
Citations = citations; Citations = citations;
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Guardrail = guardrail ?? throw new ArgumentNullException(nameof(guardrail)); Guardrail = guardrail ?? throw new ArgumentNullException(nameof(guardrail));
@@ -49,6 +53,8 @@ public sealed class AdvisoryPipelineOutput
public string Prompt { get; } public string Prompt { get; }
public string Response { get; }
public ImmutableArray<AdvisoryPromptCitation> Citations { get; } public ImmutableArray<AdvisoryPromptCitation> Citations { get; }
public ImmutableDictionary<string, string> Metadata { get; } public ImmutableDictionary<string, string> Metadata { get; }
@@ -65,15 +71,21 @@ public sealed class AdvisoryPipelineOutput
AdvisoryTaskPlan plan, AdvisoryTaskPlan plan,
AdvisoryPrompt prompt, AdvisoryPrompt prompt,
AdvisoryGuardrailResult guardrail, AdvisoryGuardrailResult guardrail,
AdvisoryInferenceResult inference,
DateTimeOffset generatedAtUtc, DateTimeOffset generatedAtUtc,
bool planFromCache) bool planFromCache)
{ {
ArgumentNullException.ThrowIfNull(plan); ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(prompt); ArgumentNullException.ThrowIfNull(prompt);
ArgumentNullException.ThrowIfNull(guardrail); ArgumentNullException.ThrowIfNull(guardrail);
ArgumentNullException.ThrowIfNull(inference);
var promptContent = guardrail.SanitizedPrompt ?? prompt.Prompt ?? string.Empty; 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<string>.Empty); var provenance = new AdvisoryDsseProvenance(plan.CacheKey, outputHash, ImmutableArray<string>.Empty);
return new AdvisoryPipelineOutput( return new AdvisoryPipelineOutput(
@@ -81,14 +93,52 @@ public sealed class AdvisoryPipelineOutput
plan.Request.TaskType, plan.Request.TaskType,
plan.Request.Profile, plan.Request.Profile,
promptContent, promptContent,
responseContent,
prompt.Citations, prompt.Citations,
prompt.Metadata, metadata,
guardrail, guardrail,
provenance, provenance,
generatedAtUtc, generatedAtUtc,
planFromCache); planFromCache);
} }
private static ImmutableDictionary<string, string> MergeMetadata(
ImmutableDictionary<string, string> metadata,
AdvisoryInferenceResult inference)
{
var builder = metadata is { Count: > 0 }
? metadata.ToBuilder()
: ImmutableDictionary.CreateBuilder<string, string>(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) private static string ComputeHash(string content)
{ {
var bytes = Encoding.UTF8.GetBytes(content); var bytes = Encoding.UTF8.GetBytes(content);

View File

@@ -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-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-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-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-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-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. | | 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. |

View File

@@ -14,6 +14,7 @@ using StellaOps.AdvisoryAI.Prompting;
using StellaOps.AdvisoryAI.Queue; using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Metrics; using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Tools; using StellaOps.AdvisoryAI.Tools;
using StellaOps.AdvisoryAI.Inference;
using Xunit; using Xunit;
namespace StellaOps.AdvisoryAI.Tests; namespace StellaOps.AdvisoryAI.Tests;
@@ -30,7 +31,8 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
var guardrail = new StubGuardrailPipeline(blocked: false); var guardrail = new StubGuardrailPipeline(blocked: false);
var store = new InMemoryAdvisoryOutputStore(); var store = new InMemoryAdvisoryOutputStore();
using var metrics = new AdvisoryPipelineMetrics(_meterFactory); 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); var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request);
await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None); 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.InputDigest.Should().Be(plan.CacheKey);
saved.Provenance.OutputHash.Should().NotBeNullOrWhiteSpace(); saved.Provenance.OutputHash.Should().NotBeNullOrWhiteSpace();
saved.Prompt.Should().Be("{\"prompt\":\"value\"}"); saved.Prompt.Should().Be("{\"prompt\":\"value\"}");
saved.Response.Should().Be("{\"prompt\":\"value\"}");
saved.Guardrail.Metadata.Should().ContainKey("prompt_length"); saved.Guardrail.Metadata.Should().ContainKey("prompt_length");
} }
@@ -54,7 +57,8 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
var guardrail = new StubGuardrailPipeline(blocked: true); var guardrail = new StubGuardrailPipeline(blocked: true);
var store = new InMemoryAdvisoryOutputStore(); var store = new InMemoryAdvisoryOutputStore();
using var metrics = new AdvisoryPipelineMetrics(_meterFactory); 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); var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request);
await executor.ExecuteAsync(plan, message, planFromCache: true, CancellationToken.None); await executor.ExecuteAsync(plan, message, planFromCache: true, CancellationToken.None);
@@ -84,12 +88,12 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) => listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{ {
doubleMeasurements.Add((instrument.Name, measurement, tags)); doubleMeasurements.Add((instrument.Name, measurement, tags.ToArray()));
}); });
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) => listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{ {
longMeasurements.Add((instrument.Name, measurement, tags)); longMeasurements.Add((instrument.Name, measurement, tags.ToArray()));
}); });
listener.Start(); listener.Start();
@@ -99,7 +103,8 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
var guardrail = new StubGuardrailPipeline(blocked: true); var guardrail = new StubGuardrailPipeline(blocked: true);
var store = new InMemoryAdvisoryOutputStore(); var store = new InMemoryAdvisoryOutputStore();
using var metrics = new AdvisoryPipelineMetrics(_meterFactory); 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); var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request);
await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None); await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None);
@@ -135,7 +140,7 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) => listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{ {
doubleMeasurements.Add((instrument.Name, measurement, tags)); doubleMeasurements.Add((instrument.Name, measurement, tags.ToArray()));
}); });
listener.Start(); listener.Start();
@@ -145,7 +150,8 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
var guardrail = new StubGuardrailPipeline(blocked: false); var guardrail = new StubGuardrailPipeline(blocked: false);
var store = new InMemoryAdvisoryOutputStore(); var store = new InMemoryAdvisoryOutputStore();
using var metrics = new AdvisoryPipelineMetrics(_meterFactory); 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); var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request);
await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None); await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None);
@@ -289,6 +295,18 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
=> Task.FromResult(_result); => Task.FromResult(_result);
} }
private sealed class StubInferenceClient : IAdvisoryInferenceClient
{
public AdvisoryInferenceResult Result { get; set; } = AdvisoryInferenceResult.FromLocal("{\"prompt\":\"value\"}");
public Task<AdvisoryInferenceResult> GenerateAsync(
AdvisoryTaskPlan plan,
AdvisoryPrompt prompt,
AdvisoryGuardrailResult guardrailResult,
CancellationToken cancellationToken)
=> Task.FromResult(Result);
}
public void Dispose() public void Dispose()
{ {
_meterFactory.Dispose(); _meterFactory.Dispose();

View File

@@ -73,7 +73,7 @@ public sealed class ConcelierAdvisoryDocumentProviderTests
public Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync( public Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync(
string tenant, string tenant,
IReadOnlyCollection<string> searchValues, string advisoryKey,
IReadOnlyCollection<string> sourceVendors, IReadOnlyCollection<string> sourceVendors,
CancellationToken cancellationToken) CancellationToken cancellationToken)
=> Task.FromResult(_records); => Task.FromResult(_records);

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Caching; using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Context; using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Documents; using StellaOps.AdvisoryAI.Documents;
@@ -14,6 +15,7 @@ using StellaOps.AdvisoryAI.Guardrails;
using StellaOps.AdvisoryAI.Hosting; using StellaOps.AdvisoryAI.Hosting;
using StellaOps.AdvisoryAI.Outputs; using StellaOps.AdvisoryAI.Outputs;
using StellaOps.AdvisoryAI.Orchestration; using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Prompting;
using StellaOps.AdvisoryAI.Tools; using StellaOps.AdvisoryAI.Tools;
using Xunit; using Xunit;
@@ -67,11 +69,13 @@ public sealed class FileSystemAdvisoryPersistenceTests : IDisposable
var plan = CreatePlan("cache-abc"); var plan = CreatePlan("cache-abc");
var prompt = "{\"prompt\":\"value\"}"; var prompt = "{\"prompt\":\"value\"}";
var guardrail = AdvisoryGuardrailResult.Allowed(prompt); var guardrail = AdvisoryGuardrailResult.Allowed(prompt);
var response = "response-text";
var output = new AdvisoryPipelineOutput( var output = new AdvisoryPipelineOutput(
plan.CacheKey, plan.CacheKey,
plan.Request.TaskType, plan.Request.TaskType,
plan.Request.Profile, plan.Request.Profile,
prompt, prompt,
response,
ImmutableArray.Create(new AdvisoryPromptCitation(1, "doc-1", "chunk-1")), ImmutableArray.Create(new AdvisoryPromptCitation(1, "doc-1", "chunk-1")),
ImmutableDictionary<string, string>.Empty.Add("advisory_key", plan.Request.AdvisoryKey), ImmutableDictionary<string, string>.Empty.Add("advisory_key", plan.Request.AdvisoryKey),
guardrail, guardrail,
@@ -84,6 +88,7 @@ public sealed class FileSystemAdvisoryPersistenceTests : IDisposable
reloaded.Should().NotBeNull(); reloaded.Should().NotBeNull();
reloaded!.Prompt.Should().Be(prompt); reloaded!.Prompt.Should().Be(prompt);
reloaded.Response.Should().Be(response);
reloaded.Metadata.Should().ContainKey("advisory_key").WhoseValue.Should().Be(plan.Request.AdvisoryKey); reloaded.Metadata.Should().ContainKey("advisory_key").WhoseValue.Should().Be(plan.Request.AdvisoryKey);
} }

View File

@@ -55,14 +55,24 @@ public class StandardPluginRegistrarTests
"standard.yaml"); "standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration); var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
services.AddSingleton<IMongoDatabase>(database); services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore()); services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore()); services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System); services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore()); services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
services.AddSingleton(TimeProvider.System); {
var mongo = sp.GetRequiredService<IMongoDatabase>();
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
});
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
{
var mongo = sp.GetRequiredService<IMongoDatabase>();
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
});
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore()); services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System); services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore()); services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
@@ -91,7 +101,9 @@ public class StandardPluginRegistrarTests
using var scope = provider.CreateScope(); using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>(); var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.Equal("standard", plugin.Type); 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.False(plugin.Capabilities.SupportsMfa);
Assert.True(plugin.Capabilities.SupportsClientProvisioning); Assert.True(plugin.Capabilities.SupportsClientProvisioning);
@@ -181,7 +193,9 @@ public class StandardPluginRegistrarTests
using var scope = provider.CreateScope(); using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>(); var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.True(plugin.Capabilities.SupportsPassword); Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsBootstrap);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
} }
[Fact] [Fact]

View File

@@ -19,6 +19,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
private readonly IMongoDatabase database; private readonly IMongoDatabase database;
private readonly StandardPluginOptions options; private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store; private readonly StandardUserCredentialStore store;
private readonly TestAuditLogger auditLogger;
public StandardUserCredentialStoreTests() public StandardUserCredentialStoreTests()
{ {
@@ -50,17 +51,20 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
} }
}; };
var cryptoProvider = new DefaultCryptoProvider(); var cryptoProvider = new DefaultCryptoProvider();
auditLogger = new TestAuditLogger();
store = new StandardUserCredentialStore( store = new StandardUserCredentialStore(
"standard", "standard",
database, database,
options, options,
new CryptoPasswordHasher(options, cryptoProvider), new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
NullLogger<StandardUserCredentialStore>.Instance); NullLogger<StandardUserCredentialStore>.Instance);
} }
[Fact] [Fact]
public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials() public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials()
{ {
auditLogger.Reset();
var registration = new AuthorityUserRegistration( var registration = new AuthorityUserRegistration(
"alice", "alice",
"Password1!", "Password1!",
@@ -77,11 +81,17 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
Assert.Equal("alice", result.User?.Username); Assert.Equal("alice", result.User?.Username);
Assert.Empty(result.AuditProperties); Assert.Empty(result.AuditProperties);
var auditEntry = Assert.Single(auditLogger.Events);
Assert.Equal("alice", auditEntry.Username);
Assert.True(auditEntry.Success);
Assert.Null(auditEntry.FailureCode);
} }
[Fact] [Fact]
public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures() public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures()
{ {
auditLogger.Reset();
await store.UpsertUserAsync( await store.UpsertUserAsync(
new AuthorityUserRegistration( new AuthorityUserRegistration(
"bob", "bob",
@@ -103,11 +113,18 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.NotNull(second.RetryAfter); Assert.NotNull(second.RetryAfter);
Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero); Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until"); 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] [Fact]
public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2() public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2()
{ {
auditLogger.Reset();
var legacyHash = new Pbkdf2PasswordHasher().Hash( var legacyHash = new Pbkdf2PasswordHasher().Hash(
"Legacy1!", "Legacy1!",
new PasswordHashOptions new PasswordHashOptions
@@ -136,6 +153,10 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.Equal("legacy", result.User?.Username); Assert.Equal("legacy", result.User?.Username);
Assert.Contains(result.AuditProperties, property => property.Name == "plugin.rehashed"); 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<StandardUserDocument>("authority_users_standard") var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard")
.Find(u => u.NormalizedUsername == "legacy") .Find(u => u.NormalizedUsername == "legacy")
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -144,6 +165,23 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal); 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 InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() public Task DisposeAsync()
@@ -152,3 +190,28 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
{
private readonly List<AuditEntry> events = new();
public IReadOnlyList<AuditEntry> Events => events;
public void Reset() => events.Clear();
public ValueTask RecordAsync(
string pluginName,
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> 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);
}

View File

@@ -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<AuthEventProperty> 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<StandardCredentialAuditLogger> logger;
public StandardCredentialAuditLogger(
IAuthorityLoginAttemptStore loginAttemptStore,
TimeProvider timeProvider,
ILogger<StandardCredentialAuditLogger> 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<AuthEventProperty> 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<AuthorityLoginAttemptPropertyDocument> ConvertProperties(
IReadOnlyList<AuthEventProperty> properties)
{
if (properties.Count == 0)
{
return new List<AuthorityLoginAttemptPropertyDocument>();
}
var documents = new List<AuthorityLoginAttemptPropertyDocument>(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"
};
}

View File

@@ -32,7 +32,26 @@ internal sealed class StandardIdentityProviderPlugin : IIdentityProviderPlugin
Context.Manifest.Name); 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; public string Name => Context.Manifest.Name;

View File

@@ -27,12 +27,12 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var pluginName = context.Plugin.Manifest.Name; var pluginName = context.Plugin.Manifest.Name;
context.Services.AddSingleton<StandardClaimsEnricher>(); context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>()); context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto(); context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath; var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName) context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration) .Bind(context.Plugin.Configuration)
@@ -43,18 +43,21 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
}) })
.ValidateOnStart(); .ValidateOnStart();
context.Services.AddScoped(sp => context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
{
var database = sp.GetRequiredService<IMongoDatabase>(); context.Services.AddScoped(sp =>
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>(); {
var pluginOptions = optionsMonitor.Get(pluginName); var database = sp.GetRequiredService<IMongoDatabase>();
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>(); var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider); var pluginOptions = optionsMonitor.Get(pluginName);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>(); var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>(); var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var baselinePolicy = new PasswordPolicyOptions(); var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy)) var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{ {
registrarLogger.LogWarning( 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}).", "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.RequireDigit,
baselinePolicy.RequireSymbol); baselinePolicy.RequireSymbol);
} }
return new StandardUserCredentialStore( return new StandardUserCredentialStore(
pluginName, pluginName,
database, database,
pluginOptions, pluginOptions,
passwordHasher, passwordHasher,
loggerFactory.CreateLogger<StandardUserCredentialStore>()); auditLogger,
}); loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddScoped(sp => context.Services.AddScoped(sp =>
{ {

View File

@@ -18,6 +18,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
private readonly IMongoCollection<StandardUserDocument> users; private readonly IMongoCollection<StandardUserDocument> users;
private readonly StandardPluginOptions options; private readonly StandardPluginOptions options;
private readonly IPasswordHasher passwordHasher; private readonly IPasswordHasher passwordHasher;
private readonly IStandardCredentialAuditLogger auditLogger;
private readonly ILogger<StandardUserCredentialStore> logger; private readonly ILogger<StandardUserCredentialStore> logger;
private readonly string pluginName; private readonly string pluginName;
@@ -26,11 +27,13 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
IMongoDatabase database, IMongoDatabase database,
StandardPluginOptions options, StandardPluginOptions options,
IPasswordHasher passwordHasher, IPasswordHasher passwordHasher,
IStandardCredentialAuditLogger auditLogger,
ILogger<StandardUserCredentialStore> logger) ILogger<StandardUserCredentialStore> logger)
{ {
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName)); this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.options = options ?? throw new ArgumentNullException(nameof(options)); this.options = options ?? throw new ArgumentNullException(nameof(options));
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher)); this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(database);
@@ -60,6 +63,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
if (user is null) if (user is null)
{ {
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized); 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); return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
} }
@@ -73,6 +84,15 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture)) 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( return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.LockedOut, AuthorityCredentialFailureCode.LockedOut,
"Account is temporarily locked.", "Account is temporarily locked.",
@@ -111,6 +131,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
} }
var descriptor = ToDescriptor(user); 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( return AuthorityCredentialVerificationResult.Success(
descriptor, descriptor,
descriptor.RequiresPasswordReset ? "Password reset required." : null, 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( return AuthorityCredentialVerificationResult.Failure(
code, code,
code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.", 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); 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<AuthEventProperty> auditProperties,
CancellationToken cancellationToken)
{
await auditLogger.RecordAsync(
pluginName,
normalizedUsername,
subjectId,
success,
failureCode,
reason,
auditProperties,
cancellationToken).ConfigureAwait(false);
}
} }

View File

@@ -2,10 +2,10 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | 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`. <br>⛔ 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). <br>⛔ 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. | | 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). <br>⛔ 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. <br>⛔ 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. | | 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. <br>⛔ 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 | PLG1PLG3 | 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. <br>⛔ 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 | PLG1PLG3 | 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.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-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.<br>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. | | 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.<br>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-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 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-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. > Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.

View File

@@ -12,12 +12,14 @@ public class AuthorityIdentityProviderCapabilitiesTests
{ {
"password", "password",
"mfa", "mfa",
"clientProvisioning" "clientProvisioning",
"bootstrap"
}); });
Assert.True(capabilities.SupportsPassword); Assert.True(capabilities.SupportsPassword);
Assert.True(capabilities.SupportsMfa); Assert.True(capabilities.SupportsMfa);
Assert.True(capabilities.SupportsClientProvisioning); Assert.True(capabilities.SupportsClientProvisioning);
Assert.True(capabilities.SupportsBootstrap);
} }
[Fact] [Fact]
@@ -28,6 +30,7 @@ public class AuthorityIdentityProviderCapabilitiesTests
Assert.False(capabilities.SupportsPassword); Assert.False(capabilities.SupportsPassword);
Assert.False(capabilities.SupportsMfa); Assert.False(capabilities.SupportsMfa);
Assert.False(capabilities.SupportsClientProvisioning); Assert.False(capabilities.SupportsClientProvisioning);
Assert.False(capabilities.SupportsBootstrap);
} }
[Fact] [Fact]
@@ -38,5 +41,6 @@ public class AuthorityIdentityProviderCapabilitiesTests
Assert.False(capabilities.SupportsPassword); Assert.False(capabilities.SupportsPassword);
Assert.False(capabilities.SupportsMfa); Assert.False(capabilities.SupportsMfa);
Assert.False(capabilities.SupportsClientProvisioning); Assert.False(capabilities.SupportsClientProvisioning);
Assert.False(capabilities.SupportsBootstrap);
} }
} }

View File

@@ -112,15 +112,20 @@ public interface IAuthorityIdentityProviderRegistry
/// </summary> /// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; } IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
/// <summary> /// <summary>
/// Gets metadata for identity providers that advertise client provisioning support. /// Gets metadata for identity providers that advertise client provisioning support.
/// </summary> /// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders { get; } IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders { get; }
/// <summary> /// <summary>
/// Aggregate capability flags across all registered providers. /// Gets metadata for identity providers that advertise bootstrap flows (user/client).
/// </summary> /// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; } IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders { get; }
/// <summary>
/// Aggregate capability flags across all registered providers.
/// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary> /// <summary>
/// Attempts to resolve identity provider metadata by name. /// Attempts to resolve identity provider metadata by name.

View File

@@ -12,11 +12,12 @@ namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary> /// <summary>
/// Describes feature support advertised by an identity provider plugin. /// Describes feature support advertised by an identity provider plugin.
/// </summary> /// </summary>
public sealed record AuthorityIdentityProviderCapabilities( public sealed record AuthorityIdentityProviderCapabilities(
bool SupportsPassword, bool SupportsPassword,
bool SupportsMfa, bool SupportsMfa,
bool SupportsClientProvisioning) bool SupportsClientProvisioning,
{ bool SupportsBootstrap)
{
/// <summary> /// <summary>
/// Builds capabilities metadata from a list of capability identifiers. /// Builds capabilities metadata from a list of capability identifiers.
/// </summary> /// </summary>
@@ -24,7 +25,7 @@ public sealed record AuthorityIdentityProviderCapabilities(
{ {
if (capabilities is null) if (capabilities is null)
{ {
return new AuthorityIdentityProviderCapabilities(false, false, false); return new AuthorityIdentityProviderCapabilities(false, false, false, false);
} }
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -38,11 +39,12 @@ public sealed record AuthorityIdentityProviderCapabilities(
seen.Add(entry.Trim()); seen.Add(entry.Trim());
} }
return new AuthorityIdentityProviderCapabilities( return new AuthorityIdentityProviderCapabilities(
SupportsPassword: seen.Contains(AuthorityPluginCapabilities.Password), SupportsPassword: seen.Contains(AuthorityPluginCapabilities.Password),
SupportsMfa: seen.Contains(AuthorityPluginCapabilities.Mfa), SupportsMfa: seen.Contains(AuthorityPluginCapabilities.Mfa),
SupportsClientProvisioning: seen.Contains(AuthorityPluginCapabilities.ClientProvisioning)); SupportsClientProvisioning: seen.Contains(AuthorityPluginCapabilities.ClientProvisioning),
} SupportsBootstrap: seen.Contains(AuthorityPluginCapabilities.Bootstrap));
}
} }
/// <summary> /// <summary>

View File

@@ -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<AuthoritySealedModeEvidenceValidator>.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;
}
}

View File

@@ -16,11 +16,11 @@ public class AuthorityIdentityProviderRegistryTests
[Fact] [Fact]
public async Task RegistryIndexesProvidersAndAggregatesCapabilities() public async Task RegistryIndexesProvidersAndAggregatesCapabilities()
{ {
var providers = new[] var providers = new[]
{ {
CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false), CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false, supportsBootstrap: true),
CreateProvider("sso", type: "saml", supportsPassword: false, supportsMfa: true, supportsClientProvisioning: true) CreateProvider("sso", type: "saml", supportsPassword: false, supportsMfa: true, supportsClientProvisioning: true)
}; };
using var serviceProvider = BuildServiceProvider(providers); using var serviceProvider = BuildServiceProvider(providers);
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance); var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
@@ -29,11 +29,13 @@ public class AuthorityIdentityProviderRegistryTests
Assert.True(registry.TryGet("standard", out var standard)); Assert.True(registry.TryGet("standard", out var standard));
Assert.Equal("standard", standard!.Name); Assert.Equal("standard", standard!.Name);
Assert.Single(registry.PasswordProviders); Assert.Single(registry.PasswordProviders);
Assert.Single(registry.MfaProviders); Assert.Single(registry.MfaProviders);
Assert.Single(registry.ClientProvisioningProviders); Assert.Single(registry.ClientProvisioningProviders);
Assert.True(registry.AggregateCapabilities.SupportsPassword); Assert.Single(registry.BootstrapProviders);
Assert.True(registry.AggregateCapabilities.SupportsMfa); Assert.True(registry.AggregateCapabilities.SupportsPassword);
Assert.True(registry.AggregateCapabilities.SupportsClientProvisioning); Assert.True(registry.AggregateCapabilities.SupportsMfa);
Assert.True(registry.AggregateCapabilities.SupportsClientProvisioning);
Assert.True(registry.AggregateCapabilities.SupportsBootstrap);
await using var handle = await registry.AcquireAsync("standard", default); await using var handle = await registry.AcquireAsync("standard", default);
Assert.Same(providers[0], handle.Provider); Assert.Same(providers[0], handle.Provider);
@@ -101,33 +103,34 @@ public class AuthorityIdentityProviderRegistryTests
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
private static IIdentityProviderPlugin CreateProvider( private static IIdentityProviderPlugin CreateProvider(
string name, string name,
string type, string type,
bool supportsPassword, bool supportsPassword,
bool supportsMfa, bool supportsMfa,
bool supportsClientProvisioning) bool supportsClientProvisioning,
{ bool supportsBootstrap = false)
var manifest = new AuthorityPluginManifest( {
name, var manifest = new AuthorityPluginManifest(
type, name,
true, type,
AssemblyName: null, true,
AssemblyPath: null, AssemblyName: null,
Capabilities: BuildCapabilities(supportsPassword, supportsMfa, supportsClientProvisioning), AssemblyPath: null,
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase), Capabilities: BuildCapabilities(supportsPassword, supportsMfa, supportsClientProvisioning, supportsBootstrap),
ConfigPath: string.Empty); Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
ConfigPath: string.Empty);
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
return new TestIdentityProviderPlugin(context, supportsPassword, supportsMfa, supportsClientProvisioning); var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
} return new TestIdentityProviderPlugin(context, supportsPassword, supportsMfa, supportsClientProvisioning, supportsBootstrap);
}
private static IReadOnlyList<string> BuildCapabilities(bool password, bool mfa, bool clientProvisioning)
{ private static IReadOnlyList<string> BuildCapabilities(bool password, bool mfa, bool clientProvisioning, bool bootstrap)
var capabilities = new List<string>(); {
if (password) var capabilities = new List<string>();
{ if (password)
capabilities.Add(AuthorityPluginCapabilities.Password); {
capabilities.Add(AuthorityPluginCapabilities.Password);
} }
if (mfa) if (mfa)
@@ -135,28 +138,35 @@ public class AuthorityIdentityProviderRegistryTests
capabilities.Add(AuthorityPluginCapabilities.Mfa); capabilities.Add(AuthorityPluginCapabilities.Mfa);
} }
if (clientProvisioning) if (clientProvisioning)
{ {
capabilities.Add(AuthorityPluginCapabilities.ClientProvisioning); capabilities.Add(AuthorityPluginCapabilities.ClientProvisioning);
} }
return capabilities; if (bootstrap)
} {
capabilities.Add(AuthorityPluginCapabilities.Bootstrap);
private sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin }
{
public TestIdentityProviderPlugin( return capabilities;
AuthorityPluginContext context, }
bool supportsPassword,
bool supportsMfa, private sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin
bool supportsClientProvisioning) {
{ public TestIdentityProviderPlugin(
Context = context; AuthorityPluginContext context,
Capabilities = new AuthorityIdentityProviderCapabilities( bool supportsPassword,
SupportsPassword: supportsPassword, bool supportsMfa,
SupportsMfa: supportsMfa, bool supportsClientProvisioning,
SupportsClientProvisioning: supportsClientProvisioning); bool supportsBootstrap)
} {
Context = context;
Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: supportsPassword,
SupportsMfa: supportsMfa,
SupportsClientProvisioning: supportsClientProvisioning,
SupportsBootstrap: supportsBootstrap);
}
public string Name => Context.Manifest.Name; public string Name => Context.Manifest.Name;
@@ -178,15 +188,16 @@ public class AuthorityIdentityProviderRegistryTests
private sealed class ScopedIdentityProviderPlugin : IIdentityProviderPlugin private sealed class ScopedIdentityProviderPlugin : IIdentityProviderPlugin
{ {
public ScopedIdentityProviderPlugin(AuthorityPluginContext context) public ScopedIdentityProviderPlugin(AuthorityPluginContext context)
{ {
Context = context; Context = context;
InstanceId = Guid.NewGuid(); InstanceId = Guid.NewGuid();
Capabilities = new AuthorityIdentityProviderCapabilities( Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true, SupportsPassword: true,
SupportsMfa: false, SupportsMfa: false,
SupportsClientProvisioning: false); SupportsClientProvisioning: false,
} SupportsBootstrap: false);
}
public Guid InstanceId { get; } public Guid InstanceId { get; }

View File

@@ -96,14 +96,15 @@ public class AuthorityIdentityProviderSelectorTests
private sealed class SelectorTestProvider : IIdentityProviderPlugin private sealed class SelectorTestProvider : IIdentityProviderPlugin
{ {
public SelectorTestProvider(AuthorityPluginContext context, bool supportsPassword) public SelectorTestProvider(AuthorityPluginContext context, bool supportsPassword)
{ {
Context = context; Context = context;
Capabilities = new AuthorityIdentityProviderCapabilities( Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: supportsPassword, SupportsPassword: supportsPassword,
SupportsMfa: false, SupportsMfa: false,
SupportsClientProvisioning: false); SupportsClientProvisioning: false,
} SupportsBootstrap: false);
}
public string Name => Context.Manifest.Name; public string Name => Context.Manifest.Name;

View File

@@ -4623,7 +4623,11 @@ public class ObservabilityIncidentTokenHandlerTests
[Fact] [Fact]
public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope() public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope()
{ {
var handler = new ValidateRefreshTokenGrantHandler(NullLogger<ValidateRefreshTokenGrantHandler>.Instance); var clientStore = new TestClientStore(CreateClient());
var handler = new ValidateRefreshTokenGrantHandler(
clientStore,
new NoopCertificateValidator(),
NullLogger<ValidateRefreshTokenGrantHandler>.Instance);
var transaction = new OpenIddictServerTransaction var transaction = new OpenIddictServerTransaction
{ {
@@ -5139,7 +5143,8 @@ internal static class TestHelpers
new AuthorityIdentityProviderCapabilities( new AuthorityIdentityProviderCapabilities(
SupportsPassword: true, SupportsPassword: true,
SupportsMfa: false, SupportsMfa: false,
SupportsClientProvisioning: supportsClientProvisioning)); SupportsClientProvisioning: supportsClientProvisioning,
SupportsBootstrap: false));
} }
public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins) public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins)

View File

@@ -733,7 +733,7 @@ public class PasswordGrantHandlersTests
Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
Credentials = store; Credentials = store;
ClaimsEnricher = new NoopClaimsEnricher(); 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; } public string Name { get; }

View File

@@ -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<AuthoritySealedModeValidationResult> 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<AuthoritySealedModeEvidenceValidator> logger;
public AuthoritySealedModeEvidenceValidator(
StellaOpsAuthorityOptions options,
IMemoryCache memoryCache,
IHostEnvironment hostEnvironment,
TimeProvider timeProvider,
ILogger<AuthoritySealedModeEvidenceValidator> 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<AuthoritySealedModeValidationResult> 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<AuthoritySealedModeValidationResult> 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<AuthoritySealedModeValidationResult> ValidateAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthoritySealedModeValidationResult.Success(null, null));
}

View File

@@ -13,10 +13,11 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
{ {
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName; private readonly IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers; private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders; private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders; private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders; private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> bootstrapProviders;
public AuthorityIdentityProviderRegistry( public AuthorityIdentityProviderRegistry(
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
@@ -36,7 +37,8 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
var uniqueProviders = new List<AuthorityIdentityProviderMetadata>(orderedProviders.Count); var uniqueProviders = new List<AuthorityIdentityProviderMetadata>(orderedProviders.Count);
var password = new List<AuthorityIdentityProviderMetadata>(); var password = new List<AuthorityIdentityProviderMetadata>();
var mfa = new List<AuthorityIdentityProviderMetadata>(); var mfa = new List<AuthorityIdentityProviderMetadata>();
var clientProvisioning = new List<AuthorityIdentityProviderMetadata>(); var clientProvisioning = new List<AuthorityIdentityProviderMetadata>();
var bootstrap = new List<AuthorityIdentityProviderMetadata>();
var dictionary = new Dictionary<string, AuthorityIdentityProviderMetadata>(StringComparer.OrdinalIgnoreCase); var dictionary = new Dictionary<string, AuthorityIdentityProviderMetadata>(StringComparer.OrdinalIgnoreCase);
@@ -73,22 +75,29 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
mfa.Add(metadata); mfa.Add(metadata);
} }
if (metadata.Capabilities.SupportsClientProvisioning) if (metadata.Capabilities.SupportsClientProvisioning)
{ {
clientProvisioning.Add(metadata); clientProvisioning.Add(metadata);
} }
}
if (metadata.Capabilities.SupportsBootstrap)
{
bootstrap.Add(metadata);
}
}
providersByName = dictionary; providersByName = dictionary;
providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(uniqueProviders); providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(uniqueProviders);
passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(password); passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(password);
mfaProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(mfa); mfaProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(mfa);
clientProvisioningProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(clientProvisioning); clientProvisioningProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(clientProvisioning);
bootstrapProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(bootstrap);
AggregateCapabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: passwordProviders.Count > 0, AggregateCapabilities = new AuthorityIdentityProviderCapabilities(
SupportsMfa: mfaProviders.Count > 0, SupportsPassword: passwordProviders.Count > 0,
SupportsClientProvisioning: clientProvisioningProviders.Count > 0); SupportsMfa: mfaProviders.Count > 0,
SupportsClientProvisioning: clientProvisioningProviders.Count > 0,
SupportsBootstrap: bootstrapProviders.Count > 0);
} }
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers; public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
@@ -97,7 +106,9 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders; public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders; public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders => bootstrapProviders;
public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; } public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }

View File

@@ -22,6 +22,7 @@ internal static class AuthorityOpenIddictConstants
internal const string DpopProofJwtIdProperty = "authority:dpop_jti"; internal const string DpopProofJwtIdProperty = "authority:dpop_jti";
internal const string DpopIssuedAtProperty = "authority:dpop_iat"; internal const string DpopIssuedAtProperty = "authority:dpop_iat";
internal const string DpopConsumedNonceProperty = "authority:dpop_nonce"; internal const string DpopConsumedNonceProperty = "authority:dpop_nonce";
internal const string SealedModeStatusProperty = "authority:sealed_mode";
internal const string ConfirmationClaimType = "cnf"; internal const string ConfirmationClaimType = "cnf";
internal const string SenderConstraintClaimType = "authority_sender_constraint"; internal const string SenderConstraintClaimType = "authority_sender_constraint";
internal const string SenderNonceClaimType = "authority_sender_nonce"; internal const string SenderNonceClaimType = "authority_sender_nonce";

View File

@@ -15,6 +15,7 @@ using OpenIddict.Server;
using OpenIddict.Server.AspNetCore; using OpenIddict.Server.AspNetCore;
using MongoDB.Driver; using MongoDB.Driver;
using StellaOps.Auth.Abstractions; using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.OpenIddict; using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.Mongo.Documents;
@@ -126,6 +127,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
private readonly IHttpContextAccessor httpContextAccessor; private readonly IHttpContextAccessor httpContextAccessor;
private readonly StellaOpsAuthorityOptions authorityOptions; private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly ILogger<ValidateClientCredentialsHandler> logger; private readonly ILogger<ValidateClientCredentialsHandler> logger;
private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator;
private static readonly Regex AttributeValueRegex = new("^[a-z0-9][a-z0-9:_-]{0,127}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); 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, IAuthorityClientCertificateValidator certificateValidator,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
StellaOpsAuthorityOptions authorityOptions, StellaOpsAuthorityOptions authorityOptions,
ILogger<ValidateClientCredentialsHandler> logger) ILogger<ValidateClientCredentialsHandler> logger,
IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null)
{ {
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); 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.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions)); this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance;
} }
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
@@ -220,6 +224,29 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return; 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 var existingSenderConstraint = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var senderConstraintValue) && senderConstraintValue is string existingConstraint
? existingConstraint ? existingConstraint
: null; : 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) && if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountAuditObj) &&
serviceAccountAuditObj is AuthorityServiceAccountDocument auditServiceAccount && serviceAccountAuditObj is AuthorityServiceAccountDocument auditServiceAccount &&
!string.IsNullOrWhiteSpace(auditServiceAccount.AccountId)) !string.IsNullOrWhiteSpace(auditServiceAccount.AccountId))
@@ -2137,4 +2175,19 @@ internal static class ClientCredentialHandlerHelpers
return JsonSerializer.Serialize(current); return JsonSerializer.Serialize(current);
} }
public static bool RequiresAirgapSealConfirmation(IReadOnlyDictionary<string, string?> 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);
}
} }

View File

@@ -11,6 +11,7 @@ using OpenIddict.Extensions;
using OpenIddict.Server; using OpenIddict.Server;
using OpenIddict.Server.AspNetCore; using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Abstractions; using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.OpenIddict; using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting; using StellaOps.Authority.RateLimiting;
@@ -25,10 +26,11 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
private readonly IAuthorityIdentityProviderRegistry registry; private readonly IAuthorityIdentityProviderRegistry registry;
private readonly ActivitySource activitySource; private readonly ActivitySource activitySource;
private readonly IAuthEventSink auditSink; private readonly IAuthEventSink auditSink;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthorityClientStore clientStore; private readonly IAuthorityClientStore clientStore;
private readonly TimeProvider timeProvider; private readonly TimeProvider timeProvider;
private readonly ILogger<ValidatePasswordGrantHandler> logger; private readonly ILogger<ValidatePasswordGrantHandler> logger;
private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator;
public ValidatePasswordGrantHandler( public ValidatePasswordGrantHandler(
IAuthorityIdentityProviderRegistry registry, IAuthorityIdentityProviderRegistry registry,
@@ -37,7 +39,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
IAuthorityRateLimiterMetadataAccessor metadataAccessor, IAuthorityRateLimiterMetadataAccessor metadataAccessor,
IAuthorityClientStore clientStore, IAuthorityClientStore clientStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger<ValidatePasswordGrantHandler> logger) ILogger<ValidatePasswordGrantHandler> logger,
IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null)
{ {
this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
@@ -46,6 +49,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance;
} }
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
@@ -100,9 +104,9 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
return; return;
} }
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
if (clientDocument is null || clientDocument.Disabled) if (clientDocument is null || clientDocument.Disabled)
{ {
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider, timeProvider,
context.Transaction, context.Transaction,
@@ -122,12 +126,61 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The specified client is not permitted."); context.Reject(OpenIddictConstants.Errors.InvalidClient, "The specified client is not permitted.");
logger.LogWarning("Password grant validation failed: client {ClientId} disabled or missing.", clientId); logger.LogWarning("Password grant validation failed: client {ClientId} disabled or missing.", clientId);
return; return;
} }
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument; if (ClientCredentialHandlerHelpers.RequiresAirgapSealConfirmation(clientDocument.Properties))
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId; {
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);
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
sealedResult.FailureDescription ?? "Sealed-mode evidence is missing or stale.",
clientId,
providerName: null,
tenant: null,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: new[]
{
new AuthEventProperty
{
Name = "airgap.sealed",
Value = ClassifiedString.Public($"failure:{failureCode}")
}
});
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, sealedResult.FailureDescription ?? "Sealed-mode evidence is missing or stale.");
logger.LogWarning(
"Password grant 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}";
activity?.SetTag("authority.sealed_mode", "confirmed");
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
var tenant = PasswordGrantAuditHelper.NormalizeTenant(clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null); var tenant = PasswordGrantAuditHelper.NormalizeTenant(clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null);
if (!string.IsNullOrWhiteSpace(tenant)) if (!string.IsNullOrWhiteSpace(tenant))

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -8,6 +9,7 @@ using OpenIddict.Abstractions;
using OpenIddict.Extensions; using OpenIddict.Extensions;
using OpenIddict.Server; using OpenIddict.Server;
using StellaOps.Auth.Abstractions; using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Security; using StellaOps.Authority.Security;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.Mongo.Stores;
@@ -18,15 +20,18 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
private readonly IAuthorityClientStore clientStore; private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityClientCertificateValidator certificateValidator; private readonly IAuthorityClientCertificateValidator certificateValidator;
private readonly ILogger<ValidateRefreshTokenGrantHandler> logger; private readonly ILogger<ValidateRefreshTokenGrantHandler> logger;
private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator;
public ValidateRefreshTokenGrantHandler( public ValidateRefreshTokenGrantHandler(
IAuthorityClientStore clientStore, IAuthorityClientStore clientStore,
IAuthorityClientCertificateValidator certificateValidator, IAuthorityClientCertificateValidator certificateValidator,
ILogger<ValidateRefreshTokenGrantHandler> logger) ILogger<ValidateRefreshTokenGrantHandler> logger,
IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null)
{ {
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator)); this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance;
} }
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
@@ -47,17 +52,48 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
return; 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 ?? "<unknown>",
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); var senderConstraint = refreshPrincipal?.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
if (string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal)) if (string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
{ {
if (!await EnsureMtlsBindingAsync(context, refreshPrincipal!).ConfigureAwait(false)) if (!await EnsureMtlsBindingAsync(context, refreshPrincipal!, clientDocument).ConfigureAwait(false))
{ {
return; return;
} }
} }
} }
private async ValueTask<bool> EnsureMtlsBindingAsync(OpenIddictServerEvents.ValidateTokenRequestContext context, ClaimsPrincipal principal) private async ValueTask<bool> EnsureMtlsBindingAsync(
OpenIddictServerEvents.ValidateTokenRequestContext context,
ClaimsPrincipal principal,
AuthorityClientDocument? clientDocument)
{ {
var clientId = context.ClientId ?? context.Request.ClientId; var clientId = context.ClientId ?? context.Request.ClientId;
if (string.IsNullOrWhiteSpace(clientId)) if (string.IsNullOrWhiteSpace(clientId))
@@ -67,7 +103,7 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
return false; 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) if (clientDocument is null)
{ {
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown client."); context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown client.");
@@ -82,7 +118,7 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
return false; 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 || if (!validation.Succeeded ||
string.IsNullOrWhiteSpace(validation.HexThumbprint) || string.IsNullOrWhiteSpace(validation.HexThumbprint) ||
string.IsNullOrWhiteSpace(validation.ConfirmationThumbprint)) string.IsNullOrWhiteSpace(validation.ConfirmationThumbprint))

View File

@@ -140,6 +140,7 @@ builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, Authorit
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>(); builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
builder.Services.AddSingleton<IAuthorityClientCertificateValidator, AuthorityClientCertificateValidator>(); builder.Services.AddSingleton<IAuthorityClientCertificateValidator, AuthorityClientCertificateValidator>();
builder.Services.TryAddSingleton<IAuthorityAirgapAuditService, AuthorityAirgapAuditService>(); builder.Services.TryAddSingleton<IAuthorityAirgapAuditService, AuthorityAirgapAuditService>();
builder.Services.TryAddSingleton<IAuthoritySealedModeEvidenceValidator, AuthoritySealedModeEvidenceValidator>();
builder.Services.AddSingleton<AuthorityOpenApiDocumentProvider>(); builder.Services.AddSingleton<AuthorityOpenApiDocumentProvider>();
builder.Services.TryAddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>(); builder.Services.TryAddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();
@@ -472,15 +473,21 @@ else
{ {
var caps = provider.Capabilities; var caps = provider.Capabilities;
app.Logger.LogInformation( 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.Name,
provider.Type, provider.Type,
caps.SupportsPassword, caps.SupportsPassword,
caps.SupportsMfa, 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) if (authorityOptions.Bootstrap.Enabled)
{ {
var bootstrapGroup = app.MapGroup("/internal"); 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." }); 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<string>(), inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support bootstrap provisioning." });
}
if (!providerMetadata.Capabilities.SupportsPassword) if (!providerMetadata.Capabilities.SupportsPassword)
{ {
await ReleaseInviteAsync("Selected provider does not support password provisioning."); 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." }); 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<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support bootstrap provisioning." });
}
if (!providerMetadata.Capabilities.SupportsClientProvisioning) if (!providerMetadata.Capabilities.SupportsClientProvisioning)
{ {
await ReleaseInviteAsync("Selected provider does not support client provisioning."); await ReleaseInviteAsync("Selected provider does not support client provisioning.");

View File

@@ -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. | | 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-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. > 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-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. > 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. | | 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. |

View File

@@ -6339,15 +6339,26 @@ internal static class CommandHandlers
console.Write(summary); 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, Border = BoxBorder.Rounded,
Expand = true 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) if (output.Citations.Count > 0)

View File

@@ -89,6 +89,8 @@ internal sealed class AdvisoryPipelineOutputModel
public string Prompt { get; init; } = string.Empty; public string Prompt { get; init; } = string.Empty;
public string Response { get; init; } = string.Empty;
public IReadOnlyList<AdvisoryOutputCitationModel> Citations { get; init; } = Array.Empty<AdvisoryOutputCitationModel>(); public IReadOnlyList<AdvisoryOutputCitationModel> Citations { get; init; } = Array.Empty<AdvisoryOutputCitationModel>();
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal); public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);

View File

@@ -220,8 +220,9 @@ if (authorityConfigured)
} }
else else
{ {
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services
.AddJwtBearer(options => .AddAuthentication(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
{ {
options.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata; options.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata;
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
@@ -237,6 +238,50 @@ if (authorityConfigured)
NameClaimType = StellaOpsClaimTypes.Subject, NameClaimType = StellaOpsClaimTypes.Subject,
RoleClaimType = ClaimTypes.Role RoleClaimType = ClaimTypes.Role
}; };
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
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(); var app = builder.Build();
app.Logger.LogWarning("Authority enabled: {AuthorityEnabled}, test signing secret configured: {HasTestSecret}", authorityConfigured, !string.IsNullOrWhiteSpace(concelierOptions.Authority?.TestSigningSecret));
if (features.NoMergeEnabled) if (features.NoMergeEnabled)
{ {
app.Logger.LogWarning("Legacy merge module disabled via concelier:features:noMergeEnabled; Link-Not-Merge mode active."); app.Logger.LogWarning("Legacy merge module disabled via concelier:features:noMergeEnabled; Link-Not-Merge mode active.");

View File

@@ -11,11 +11,13 @@ using System.Net.Http.Headers;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Logging;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -577,6 +579,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
[Fact] [Fact]
public async Task AdvisoryIngestEndpoint_RejectsCrossTenantWhenAuthenticated() public async Task AdvisoryIngestEndpoint_RejectsCrossTenantWhenAuthenticated()
{ {
IdentityModelEventSource.ShowPII = true;
var environment = new Dictionary<string, string?> var environment = new Dictionary<string, string?>
{ {
["CONCELIER_AUTHORITY__ENABLED"] = "true", ["CONCELIER_AUTHORITY__ENABLED"] = "true",
@@ -604,11 +607,29 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
environment); environment);
using var client = factory.CreateClient(); using var client = factory.CreateClient();
var schemes = await factory.Services.GetRequiredService<IAuthenticationSchemeProvider>().GetAllSchemesAsync();
_output.WriteLine("Schemes => " + string.Join(',', schemes.Select(s => s.Name)));
var token = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest); var token = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest);
_output.WriteLine("token => " + token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-auth"); client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-auth");
var ingestResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:auth-1", "GHSA-AUTH-001")); 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); Assert.Equal(HttpStatusCode.Created, ingestResponse.StatusCode);
client.DefaultRequestHeaders.Remove("X-Stella-Tenant"); client.DefaultRequestHeaders.Remove("X-Stella-Tenant");

View File

@@ -104,6 +104,11 @@ public sealed class StellaOpsAuthorityOptions
/// </summary> /// </summary>
public AuthorityNotificationsOptions Notifications { get; } = new(); public AuthorityNotificationsOptions Notifications { get; } = new();
/// <summary>
/// Air-gap/sealed mode configuration for Authority.
/// </summary>
public AuthorityAirGapOptions AirGap { get; } = new();
/// <summary> /// <summary>
/// Vulnerability explorer integration configuration (workflow CSRF tokens, attachments). /// Vulnerability explorer integration configuration (workflow CSRF tokens, attachments).
/// </summary> /// </summary>
@@ -168,6 +173,7 @@ public sealed class StellaOpsAuthorityOptions
AdvisoryAi.Normalize(); AdvisoryAi.Normalize();
AdvisoryAi.Validate(); AdvisoryAi.Validate();
Notifications.Validate(); Notifications.Validate();
AirGap.Validate();
VulnerabilityExplorer.Validate(); VulnerabilityExplorer.Validate();
ApiLifecycle.Validate(); ApiLifecycle.Validate();
Signing.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);
/// <summary>
/// Enables sealed-mode enforcement for clients that declare the requirement.
/// </summary>
public bool EnforcementEnabled { get; set; }
/// <summary>
/// Path to the latest authority-sealed-ci.json artefact emitted by sealed-mode CI.
/// </summary>
public string EvidencePath { get; set; } = "artifacts/sealed-mode-ci/latest/authority-sealed-ci.json";
/// <summary>
/// Maximum age accepted for the sealed evidence document.
/// </summary>
public TimeSpan MaxEvidenceAge { get; set; } = DefaultMaxEvidenceAge;
/// <summary>
/// Cache lifetime for parsed evidence to avoid re-reading the artefact on every request.
/// </summary>
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 public sealed class AuthoritySecurityOptions
{ {
/// <summary> /// <summary>