feat(crypto): extract crypto providers to overlay compose files + health probe API
- Extract smremote to docker-compose.crypto-provider.smremote.yml - Rename cryptopro/crypto-sim compose files for consistent naming - Add crypto provider health probe endpoint (CP-001) - Add tenant crypto provider preferences API + migration (CP-002) - Update docs and compliance env examples Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
119
devops/compose/docker-compose.crypto-provider.crypto-sim.yml
Normal file
119
devops/compose/docker-compose.crypto-provider.crypto-sim.yml
Normal file
@@ -0,0 +1,119 @@
|
||||
# =============================================================================
|
||||
# STELLA OPS - CRYPTO SIMULATION OVERLAY
|
||||
# =============================================================================
|
||||
# Universal crypto simulation service for testing sovereign crypto without
|
||||
# licensed hardware or certified modules.
|
||||
#
|
||||
# This overlay provides the sim-crypto-service which simulates:
|
||||
# - GOST R 34.10-2012 (Russia): GOST12-256, GOST12-512, ru.magma.sim, ru.kuznyechik.sim
|
||||
# - SM2/SM3/SM4 (China): SM2, sm.sim, sm2.sim
|
||||
# - Post-Quantum: DILITHIUM3, FALCON512, pq.sim
|
||||
# - FIPS/eIDAS/KCMVP: fips.sim, eidas.sim, kcmvp.sim, world.sim
|
||||
#
|
||||
# Usage with China compliance:
|
||||
# docker compose -f docker-compose.stella-ops.yml \
|
||||
# -f docker-compose.compliance-china.yml \
|
||||
# -f docker-compose.crypto-provider.crypto-sim.yml up -d
|
||||
#
|
||||
# Usage with Russia compliance:
|
||||
# docker compose -f docker-compose.stella-ops.yml \
|
||||
# -f docker-compose.compliance-russia.yml \
|
||||
# -f docker-compose.crypto-provider.crypto-sim.yml up -d
|
||||
#
|
||||
# Usage with EU compliance:
|
||||
# docker compose -f docker-compose.stella-ops.yml \
|
||||
# -f docker-compose.compliance-eu.yml \
|
||||
# -f docker-compose.crypto-provider.crypto-sim.yml up -d
|
||||
#
|
||||
# IMPORTANT: This is for TESTING/DEVELOPMENT ONLY.
|
||||
# - Uses deterministic HMAC-SHA256 for SM/GOST/PQ (not real algorithms)
|
||||
# - Uses static ECDSA P-256 key for FIPS/eIDAS/KCMVP
|
||||
# - NOT suitable for production or compliance certification
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
x-crypto-sim-labels: &crypto-sim-labels
|
||||
com.stellaops.component: "crypto-sim"
|
||||
com.stellaops.profile: "simulation"
|
||||
com.stellaops.production: "false"
|
||||
|
||||
x-sim-crypto-env: &sim-crypto-env
|
||||
STELLAOPS_CRYPTO_ENABLE_SIM: "1"
|
||||
STELLAOPS_CRYPTO_SIM_URL: "http://sim-crypto:8080"
|
||||
|
||||
networks:
|
||||
stellaops:
|
||||
external: true
|
||||
name: stellaops
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sim Crypto Service - Universal sovereign crypto simulator
|
||||
# ---------------------------------------------------------------------------
|
||||
sim-crypto:
|
||||
build:
|
||||
context: ../services/crypto/sim-crypto-service
|
||||
dockerfile: Dockerfile
|
||||
image: registry.stella-ops.org/stellaops/sim-crypto:dev
|
||||
container_name: stellaops-sim-crypto
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||
ASPNETCORE_ENVIRONMENT: "Development"
|
||||
ports:
|
||||
- "${SIM_CRYPTO_PORT:-18090}:8080"
|
||||
networks:
|
||||
- stellaops
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/keys"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
labels: *crypto-sim-labels
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override services to use sim-crypto
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Authority - Enable sim crypto
|
||||
authority:
|
||||
environment:
|
||||
<<: *sim-crypto-env
|
||||
labels:
|
||||
com.stellaops.crypto.simulator: "enabled"
|
||||
|
||||
# Signer - Enable sim crypto
|
||||
signer:
|
||||
environment:
|
||||
<<: *sim-crypto-env
|
||||
labels:
|
||||
com.stellaops.crypto.simulator: "enabled"
|
||||
|
||||
# Attestor - Enable sim crypto
|
||||
attestor:
|
||||
environment:
|
||||
<<: *sim-crypto-env
|
||||
labels:
|
||||
com.stellaops.crypto.simulator: "enabled"
|
||||
|
||||
# Scanner Web - Enable sim crypto
|
||||
scanner-web:
|
||||
environment:
|
||||
<<: *sim-crypto-env
|
||||
labels:
|
||||
com.stellaops.crypto.simulator: "enabled"
|
||||
|
||||
# Scanner Worker - Enable sim crypto
|
||||
scanner-worker:
|
||||
environment:
|
||||
<<: *sim-crypto-env
|
||||
labels:
|
||||
com.stellaops.crypto.simulator: "enabled"
|
||||
|
||||
# Excititor - Enable sim crypto
|
||||
excititor:
|
||||
environment:
|
||||
<<: *sim-crypto-env
|
||||
labels:
|
||||
com.stellaops.crypto.simulator: "enabled"
|
||||
149
devops/compose/docker-compose.crypto-provider.cryptopro.yml
Normal file
149
devops/compose/docker-compose.crypto-provider.cryptopro.yml
Normal file
@@ -0,0 +1,149 @@
|
||||
# =============================================================================
|
||||
# STELLA OPS - CRYPTOPRO CSP OVERLAY (Russia)
|
||||
# =============================================================================
|
||||
# CryptoPro CSP licensed provider overlay for compliance-russia.yml.
|
||||
# Adds real CryptoPro CSP service for certified GOST R 34.10-2012 operations.
|
||||
#
|
||||
# IMPORTANT: Requires EULA acceptance before use.
|
||||
#
|
||||
# Usage (MUST be combined with stella-ops AND compliance-russia):
|
||||
# CRYPTOPRO_ACCEPT_EULA=1 docker compose \
|
||||
# -f docker-compose.stella-ops.yml \
|
||||
# -f docker-compose.compliance-russia.yml \
|
||||
# -f docker-compose.crypto-provider.cryptopro.yml up -d
|
||||
#
|
||||
# For development/testing without CryptoPro license, use crypto-sim overlay instead:
|
||||
# docker compose \
|
||||
# -f docker-compose.stella-ops.yml \
|
||||
# -f docker-compose.compliance-russia.yml \
|
||||
# -f docker-compose.crypto-provider.crypto-sim.yml up -d
|
||||
#
|
||||
# Requirements:
|
||||
# - CryptoPro CSP license files in opt/cryptopro/downloads/
|
||||
# - CRYPTOPRO_ACCEPT_EULA=1 environment variable
|
||||
# - CryptoPro container images with GOST engine
|
||||
#
|
||||
# GOST Algorithms Provided:
|
||||
# - GOST R 34.10-2012: Digital signature (256/512-bit)
|
||||
# - GOST R 34.11-2012: Hash function (Streebog, 256/512-bit)
|
||||
# - GOST R 34.12-2015: Block cipher (Kuznyechik, Magma)
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
x-cryptopro-labels: &cryptopro-labels
|
||||
com.stellaops.component: "cryptopro-csp"
|
||||
com.stellaops.crypto.provider: "cryptopro"
|
||||
com.stellaops.crypto.profile: "russia"
|
||||
com.stellaops.crypto.certified: "true"
|
||||
|
||||
x-cryptopro-env: &cryptopro-env
|
||||
STELLAOPS_CRYPTO_PROVIDERS: "cryptopro.gost"
|
||||
STELLAOPS_CRYPTO_CRYPTOPRO_URL: "http://cryptopro-csp:8080"
|
||||
STELLAOPS_CRYPTO_CRYPTOPRO_ENABLED: "true"
|
||||
|
||||
networks:
|
||||
stellaops:
|
||||
external: true
|
||||
name: stellaops
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# CryptoPro CSP - Certified GOST cryptography provider
|
||||
# ---------------------------------------------------------------------------
|
||||
cryptopro-csp:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: devops/services/cryptopro/linux-csp-service/Dockerfile
|
||||
args:
|
||||
CRYPTOPRO_ACCEPT_EULA: "${CRYPTOPRO_ACCEPT_EULA:-0}"
|
||||
image: registry.stella-ops.org/stellaops/cryptopro-csp:2025.10.0
|
||||
container_name: stellaops-cryptopro-csp
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||
CRYPTOPRO_ACCEPT_EULA: "${CRYPTOPRO_ACCEPT_EULA:-0}"
|
||||
# GOST algorithm configuration
|
||||
CRYPTOPRO_GOST_SIGNATURE_ALGORITHM: "GOST R 34.10-2012"
|
||||
CRYPTOPRO_GOST_HASH_ALGORITHM: "GOST R 34.11-2012"
|
||||
# Container and key store settings
|
||||
CRYPTOPRO_CONTAINER_NAME: "${CRYPTOPRO_CONTAINER_NAME:-stellaops-signing}"
|
||||
CRYPTOPRO_USE_MACHINE_STORE: "${CRYPTOPRO_USE_MACHINE_STORE:-true}"
|
||||
CRYPTOPRO_PROVIDER_TYPE: "${CRYPTOPRO_PROVIDER_TYPE:-80}"
|
||||
volumes:
|
||||
- ../../opt/cryptopro/downloads:/opt/cryptopro/downloads:ro
|
||||
- ../../etc/cryptopro:/app/etc/cryptopro:ro
|
||||
# Optional: Mount key containers
|
||||
- cryptopro-keys:/var/opt/cprocsp/keys
|
||||
ports:
|
||||
- "${CRYPTOPRO_PORT:-18080}:8080"
|
||||
networks:
|
||||
- stellaops
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
labels: *cryptopro-labels
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override services to use CryptoPro
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Authority - Use CryptoPro for GOST signatures
|
||||
authority:
|
||||
environment:
|
||||
<<: *cryptopro-env
|
||||
depends_on:
|
||||
- cryptopro-csp
|
||||
labels:
|
||||
com.stellaops.crypto.provider: "cryptopro"
|
||||
|
||||
# Signer - Use CryptoPro for GOST signatures
|
||||
signer:
|
||||
environment:
|
||||
<<: *cryptopro-env
|
||||
depends_on:
|
||||
- cryptopro-csp
|
||||
labels:
|
||||
com.stellaops.crypto.provider: "cryptopro"
|
||||
|
||||
# Attestor - Use CryptoPro for GOST signatures
|
||||
attestor:
|
||||
environment:
|
||||
<<: *cryptopro-env
|
||||
depends_on:
|
||||
- cryptopro-csp
|
||||
labels:
|
||||
com.stellaops.crypto.provider: "cryptopro"
|
||||
|
||||
# Scanner Web - Use CryptoPro for verification
|
||||
scanner-web:
|
||||
environment:
|
||||
<<: *cryptopro-env
|
||||
depends_on:
|
||||
- cryptopro-csp
|
||||
labels:
|
||||
com.stellaops.crypto.provider: "cryptopro"
|
||||
|
||||
# Scanner Worker - Use CryptoPro for verification
|
||||
scanner-worker:
|
||||
environment:
|
||||
<<: *cryptopro-env
|
||||
depends_on:
|
||||
- cryptopro-csp
|
||||
labels:
|
||||
com.stellaops.crypto.provider: "cryptopro"
|
||||
|
||||
# Excititor - Use CryptoPro for VEX signing
|
||||
excititor:
|
||||
environment:
|
||||
<<: *cryptopro-env
|
||||
depends_on:
|
||||
- cryptopro-csp
|
||||
labels:
|
||||
com.stellaops.crypto.provider: "cryptopro"
|
||||
|
||||
volumes:
|
||||
cryptopro-keys:
|
||||
name: stellaops-cryptopro-keys
|
||||
90
devops/compose/docker-compose.crypto-provider.smremote.yml
Normal file
90
devops/compose/docker-compose.crypto-provider.smremote.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
# =============================================================================
|
||||
# STELLA OPS - CRYPTO PROVIDER OVERLAY: SMREMOTE
|
||||
# =============================================================================
|
||||
# ShangMi (SM2/SM3/SM4) crypto microservice overlay.
|
||||
# Extracted from docker-compose.stella-ops.yml (Slot 31) so that the SM Remote
|
||||
# service is opt-in rather than always-on.
|
||||
#
|
||||
# Usage (with main stack):
|
||||
# docker compose -f docker-compose.stella-ops.yml \
|
||||
# -f docker-compose.crypto-provider.smremote.yml up -d
|
||||
#
|
||||
# Usage (with China compliance):
|
||||
# docker compose -f docker-compose.stella-ops.yml \
|
||||
# -f docker-compose.compliance-china.yml \
|
||||
# -f docker-compose.crypto-provider.smremote.yml up -d
|
||||
#
|
||||
# SM Algorithms:
|
||||
# - SM2: Public key cryptography (GM/T 0003-2012)
|
||||
# - SM3: Hash function, 256-bit (GM/T 0004-2012)
|
||||
# - SM4: Block cipher, 128-bit (GM/T 0002-2012)
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
networks:
|
||||
stellaops:
|
||||
external: true
|
||||
name: stellaops
|
||||
frontdoor:
|
||||
external: true
|
||||
name: compose_frontdoor
|
||||
|
||||
services:
|
||||
# --- Slot 31: SmRemote ----------------------------------------------------
|
||||
smremote:
|
||||
image: stellaops/smremote:dev
|
||||
container_name: stellaops-smremote
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ASPNETCORE_URLS: "http://+:8080"
|
||||
Kestrel__Certificates__Default__Path: "/app/etc/certs/kestrel-dev.pfx"
|
||||
Kestrel__Certificates__Default__Password: "devpass"
|
||||
Router__Region: "local"
|
||||
Router__Gateways__0__Host: "router.stella-ops.local"
|
||||
Router__Gateways__0__Port: "9100"
|
||||
Router__Gateways__0__TransportType: "Messaging"
|
||||
Router__OnMissingAuthorization: "${ROUTER_ON_MISSING_AUTHORIZATION:-WarnAndAllow}"
|
||||
Router__TransportPlugins__Directory: "/app/plugins/router/transports"
|
||||
Router__TransportPlugins__SearchPattern: "StellaOps.Router.Transport.*.dll"
|
||||
Router__Messaging__Transport: "valkey"
|
||||
Router__Messaging__PluginDirectory: "/app/plugins/messaging"
|
||||
Router__Messaging__SearchPattern: "StellaOps.Messaging.Transport.*.dll"
|
||||
Router__Messaging__RequestQueueTemplate: "router:requests:{service}"
|
||||
Router__Messaging__ResponseQueueName: "router:responses"
|
||||
Router__Messaging__RequestTimeout: "30s"
|
||||
Router__Messaging__LeaseDuration: "5m"
|
||||
Router__Messaging__BatchSize: "10"
|
||||
Router__Messaging__HeartbeatInterval: "${ROUTER_MESSAGING_HEARTBEAT_INTERVAL:-30s}"
|
||||
Router__RegistrationRefreshIntervalSeconds: "${ROUTER_REGISTRATION_REFRESH_INTERVAL_SECONDS:-30}"
|
||||
Router__Messaging__valkey__ConnectionString: "cache.stella-ops.local:6379"
|
||||
Router__Messaging__valkey__Database: "0"
|
||||
Router__Messaging__valkey__QueueWaitTimeoutSeconds: "${VALKEY_QUEUE_WAIT_TIMEOUT:-0}"
|
||||
Router__IdentityEnvelopeSigningKey: "${STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY}"
|
||||
ConnectionStrings__Default: "Host=db.stella-ops.local;Port=5432;Database=${POSTGRES_DB:-stellaops_platform};Username=${POSTGRES_USER:-stellaops};Password=${POSTGRES_PASSWORD:-stellaops};Maximum Pool Size=50"
|
||||
ConnectionStrings__Redis: "cache.stella-ops.local:6379"
|
||||
Router__Enabled: "${SMREMOTE_ROUTER_ENABLED:-true}"
|
||||
Router__Messaging__ConsumerGroup: "smremote"
|
||||
volumes:
|
||||
- "../../etc/authority/keys:/app/etc/certs:ro"
|
||||
ports:
|
||||
- "127.1.0.31:80:80"
|
||||
networks:
|
||||
stellaops:
|
||||
aliases:
|
||||
- smremote.stella-ops.local
|
||||
frontdoor: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/8080'"]
|
||||
interval: ${HEALTHCHECK_INTERVAL:-60s}
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
labels:
|
||||
com.stellaops.release.version: "2025.10.0"
|
||||
com.stellaops.release.channel: "stable"
|
||||
com.stellaops.profile: "default"
|
||||
219
docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md
Normal file
219
docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Sprint 20260408-001 -- Crypto Provider Picker (UI + Backend)
|
||||
|
||||
## Topic & Scope
|
||||
- Admin-facing UI panel for discovering, monitoring, and selecting crypto providers per tenant.
|
||||
- Platform service backend endpoints for provider health probing and tenant preference persistence.
|
||||
- Integrates with existing `ICryptoProviderRegistry` (in `src/__Libraries/StellaOps.Cryptography/`) to respect tenant-level provider selection at runtime.
|
||||
- Working directory: `src/Web/StellaOps.Web` (Angular UI), `src/Platform/` (backend API).
|
||||
- Expected evidence: UI component rendering, API integration tests, DB migration for tenant crypto preferences.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on crypto provider compose refactor (smremote extracted from main compose; crypto overlays renamed to `docker-compose.crypto-provider.*.yml`). **DONE** as of 2026-04-08.
|
||||
- No upstream sprint blockers. Can run in parallel with other UI or backend work that does not touch Platform settings or Cryptography libraries.
|
||||
- The `docker-compose.sm-remote.yml` (standalone HSM provider) remains unchanged; this sprint only concerns provider *discovery* and *selection*, not provider lifecycle management.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `devops/compose/README.md` -- updated Crypto Provider Overlays section (contains health endpoints and compose commands).
|
||||
- `src/__Libraries/StellaOps.Cryptography/CryptoProviderRegistry.cs` -- current registry implementation with preferred-order resolution.
|
||||
- `src/__Libraries/StellaOps.Cryptography/CryptoProvider.cs` -- `ICryptoProvider` interface contract.
|
||||
- `docs/security/crypto-profile-configuration.md` -- current crypto profile configuration docs.
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### CP-001 - Provider health probe API endpoint
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Backend Developer
|
||||
|
||||
Task description:
|
||||
Add a Platform admin API endpoint `GET /api/v1/admin/crypto-providers/health` that probes each known crypto provider's health endpoint and returns aggregated status.
|
||||
|
||||
Known provider health endpoints:
|
||||
- SmRemote (router microservice): `http://smremote.stella-ops.local:8080/health` (internal router mesh)
|
||||
- SM Remote (standalone HSM): `http://localhost:56080/status` (or configured `SM_REMOTE_PORT`)
|
||||
- CryptoPro CSP: `http://cryptopro-csp:8080/health`
|
||||
- Crypto Simulator: `http://sim-crypto:8080/keys`
|
||||
|
||||
Response schema (JSON):
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"id": "smremote",
|
||||
"name": "SmRemote (SM2/SM3/SM4)",
|
||||
"status": "running",
|
||||
"healthEndpoint": "http://smremote.stella-ops.local:8080/health",
|
||||
"responseTimeMs": 12,
|
||||
"composeOverlay": "docker-compose.crypto-provider.smremote.yml",
|
||||
"startCommand": "docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.smremote.yml up -d smremote"
|
||||
},
|
||||
{
|
||||
"id": "cryptopro",
|
||||
"name": "CryptoPro CSP (GOST)",
|
||||
"status": "unreachable",
|
||||
"healthEndpoint": "http://cryptopro-csp:8080/health",
|
||||
"responseTimeMs": null,
|
||||
"composeOverlay": "docker-compose.crypto-provider.cryptopro.yml",
|
||||
"startCommand": "CRYPTOPRO_ACCEPT_EULA=1 docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.cryptopro.yml up -d cryptopro-csp"
|
||||
},
|
||||
{
|
||||
"id": "crypto-sim",
|
||||
"name": "Crypto Simulator (dev/test)",
|
||||
"status": "stopped",
|
||||
"healthEndpoint": "http://sim-crypto:8080/keys",
|
||||
"responseTimeMs": null,
|
||||
"composeOverlay": "docker-compose.crypto-provider.crypto-sim.yml",
|
||||
"startCommand": "docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.crypto-sim.yml up -d sim-crypto"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint should use `HttpClient` with a short timeout (5s) to probe each provider. Status values: `running`, `stopped`, `unreachable`, `degraded`.
|
||||
|
||||
Provider definitions should be stored in configuration (appsettings or DB), not hardcoded, so that custom providers can be registered.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `GET /api/v1/admin/crypto-providers/health` returns JSON with status for all configured providers
|
||||
- [x] Unreachable providers return `unreachable` status (not 500)
|
||||
- [x] Response includes `startCommand` with the correct compose overlay filename
|
||||
- [x] Endpoint is admin-only (requires `ops.admin` or `crypto:admin` scope)
|
||||
- [ ] Unit test covering probe timeout and mixed healthy/unhealthy scenarios
|
||||
|
||||
### CP-002 - Tenant crypto provider preference API + DB table
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Backend Developer
|
||||
|
||||
Task description:
|
||||
Add a database table and API endpoints for storing per-tenant crypto provider preferences.
|
||||
|
||||
Database table (`platform` schema):
|
||||
```sql
|
||||
CREATE TABLE platform.tenant_crypto_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
|
||||
provider_id VARCHAR(100) NOT NULL,
|
||||
algorithm_scope VARCHAR(100) NOT NULL DEFAULT '*',
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, provider_id, algorithm_scope)
|
||||
);
|
||||
```
|
||||
|
||||
API endpoints:
|
||||
- `GET /api/v1/admin/crypto-providers/preferences` -- list current tenant's provider preferences
|
||||
- `PUT /api/v1/admin/crypto-providers/preferences` -- update provider selection (body: `{ providerId, algorithmScope, priority, isActive }`)
|
||||
- `DELETE /api/v1/admin/crypto-providers/preferences/{id}` -- remove a preference
|
||||
|
||||
The preference should feed into `CryptoProviderRegistry` via `CryptoRegistryProfiles`, allowing per-tenant override of the `preferredProviderOrder`.
|
||||
|
||||
Completion criteria:
|
||||
- [x] SQL migration file added as embedded resource in Platform persistence library
|
||||
- [x] Auto-migration on startup (per repo-wide rule 2.7)
|
||||
- [x] CRUD endpoints work and are admin-scoped
|
||||
- [x] Preferences are tenant-isolated (multi-tenant safe)
|
||||
- [ ] Integration test: set preference, resolve provider, confirm correct provider is selected
|
||||
|
||||
### CP-003 - Angular crypto provider dashboard panel
|
||||
Status: TODO
|
||||
Dependency: CP-001
|
||||
Owners: Frontend Developer
|
||||
|
||||
Task description:
|
||||
Add a "Crypto Providers" panel in the Platform Settings area of the Angular UI. Location: under the existing settings navigation, accessible at `/settings/crypto-providers`.
|
||||
|
||||
Panel layout:
|
||||
1. **Provider List** -- Table/card grid showing each provider with:
|
||||
- Provider name and icon/badge (SM, GOST, SIM, etc.)
|
||||
- Status indicator: green dot (Running), red dot (Stopped/Unreachable)
|
||||
- Health response time (if running)
|
||||
- Last checked timestamp
|
||||
2. **Start Instructions** -- When a provider is stopped or unreachable, show a collapsible section with:
|
||||
- The exact `docker compose` command to start it (from the API response `startCommand`)
|
||||
- Copy-to-clipboard button
|
||||
3. **Refresh Button** -- Re-probe all providers on demand
|
||||
4. **Auto-refresh** -- Poll every 30 seconds when the panel is visible (use `interval` with `switchMap` and `takeUntilDestroyed`)
|
||||
|
||||
Angular implementation:
|
||||
- Component: `src/Web/StellaOps.Web/src/app/features/settings/crypto-providers/`
|
||||
- Service: `CryptoProviderService` calling `GET /api/v1/admin/crypto-providers/health`
|
||||
- Route: Add to settings routing module
|
||||
- Use existing StellaOps design system components (cards, status badges, tables)
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Panel renders provider list with live status from API
|
||||
- [ ] Stopped providers show start command with copy button
|
||||
- [ ] Auto-refresh works and stops when navigating away
|
||||
- [ ] Panel is accessible only to admin users
|
||||
- [ ] Responsive layout (works on tablet and desktop)
|
||||
|
||||
### CP-004 - Active provider selection UI
|
||||
Status: TODO
|
||||
Dependency: CP-002, CP-003
|
||||
Owners: Frontend Developer
|
||||
|
||||
Task description:
|
||||
Extend the crypto provider dashboard panel (CP-003) with an "Active Provider" selection feature.
|
||||
|
||||
UI additions:
|
||||
1. **Active Badge** -- Show which provider is currently selected for the tenant
|
||||
2. **Select Button** -- On each running provider card, show "Set as Active" button
|
||||
3. **Algorithm Scope** -- Optional: dropdown to scope the selection to specific algorithm families (SM, GOST, default, etc.) or apply globally (`*`)
|
||||
4. **Confirmation Dialog** -- Before changing the active provider, show a confirmation dialog explaining the impact on signing operations
|
||||
5. **Priority Ordering** -- Drag-and-drop reordering of provider priority (maps to `CryptoRegistryProfiles.preferredProviderOrder`)
|
||||
|
||||
The selection calls `PUT /api/v1/admin/crypto-providers/preferences` and updates the UI immediately.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Admin can select active provider per tenant
|
||||
- [ ] Selection persists across page refreshes (reads from API)
|
||||
- [ ] Cannot select a provider that is currently stopped/unreachable (button disabled with tooltip)
|
||||
- [ ] Confirmation dialog shown before changing provider
|
||||
- [ ] Priority ordering updates the registry's preferred order
|
||||
|
||||
### CP-005 - ICryptoProviderRegistry tenant-aware resolution
|
||||
Status: TODO
|
||||
Dependency: CP-002
|
||||
Owners: Backend Developer
|
||||
|
||||
Task description:
|
||||
Extend `CryptoProviderRegistry` (or introduce a decorator/wrapper) to consult tenant preferences when resolving providers. Currently the registry uses a static `preferredProviderOrder` set at startup. The enhancement should:
|
||||
|
||||
1. Accept an `IStellaOpsTenantAccessor` to determine the current tenant
|
||||
2. Query the `platform.tenant_crypto_preferences` table (cached, TTL ~60s) for the tenant's preferred order
|
||||
3. Override `CryptoRegistryProfiles.ActiveProfile` based on the tenant preference
|
||||
4. Fall back to the default preferred order if no tenant preference exists
|
||||
|
||||
This must not break existing non-tenant-aware code paths (CLI, background workers). The tenant-aware resolution should be opt-in via DI registration.
|
||||
|
||||
Key files:
|
||||
- `src/__Libraries/StellaOps.Cryptography/CryptoProviderRegistry.cs`
|
||||
- `src/__Libraries/StellaOps.Cryptography/CryptoRegistryProfiles.cs` (if exists)
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Tenant-aware resolution works when tenant accessor is available
|
||||
- [ ] Falls back to default when no tenant context or no preferences set
|
||||
- [ ] Cached query (not per-request DB hit)
|
||||
- [ ] Existing non-tenant code paths unaffected (unit tests pass)
|
||||
- [ ] Integration test: two tenants with different preferences resolve different providers
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-04-08 | Sprint created. Crypto provider compose overlays refactored (smremote extracted, files renamed). | Planning |
|
||||
| 2026-04-08 | CP-001 implemented: CryptoProviderHealthService + CryptoProviderAdminEndpoints (health probe). CP-002 implemented: SQL migration 062, ICryptoProviderPreferenceStore with Postgres and InMemory impls, CRUD endpoints. Both wired in Program.cs. Build verified (0 errors, 0 warnings). Unit tests pending. | Developer |
|
||||
| 2026-04-08 | Compose refactoring confirmed complete: smremote extracted (Slot 31 comment in main compose), overlay files already named `docker-compose.crypto-provider.*.yml`, README Crypto Provider Overlays section up to date, INSTALL_GUIDE.md references correct filenames. No old-named files to rename. | Developer |
|
||||
|
||||
## Decisions & Risks
|
||||
- **Risk: Provider health probing from within containers.** The Platform service runs inside the Docker network; it can reach other containers by DNS alias but cannot determine whether a compose overlay is loaded vs. the container is unhealthy. Mitigation: treat any non-200 response (including DNS resolution failure) as `unreachable`.
|
||||
- **Risk: Tenant preference caching coherence.** If an admin changes the active provider, other service instances may use stale cache. Mitigation: use Valkey pub/sub to broadcast preference changes, or accept eventual consistency with a 60s TTL.
|
||||
- **Decision: `docker-compose.sm-remote.yml` (standalone HSM overlay) remains unchanged.** Only the router-integrated `smremote` microservice was extracted from the main compose. The standalone SM Remote overlay serves a different purpose (HSM integration with build context).
|
||||
- **Decision: Provider definitions should be configurable, not hardcoded.** Seed the initial set from appsettings but allow DB overrides so operators can add custom providers.
|
||||
|
||||
## Next Checkpoints
|
||||
- CP-001 + CP-002 backend endpoints ready for frontend integration.
|
||||
- CP-003 initial panel rendering with mock data for design review.
|
||||
- CP-004 + CP-005 integration testing with live crypto providers.
|
||||
@@ -57,4 +57,8 @@ public static class PlatformPolicies
|
||||
// Script registry policies (script editor / multi-language scripts)
|
||||
public const string ScriptRead = "platform.script.read";
|
||||
public const string ScriptWrite = "platform.script.write";
|
||||
|
||||
// Crypto provider admin policies (CP-001 / CP-002)
|
||||
public const string CryptoProviderRead = "platform.crypto.read";
|
||||
public const string CryptoProviderAdmin = "platform.crypto.admin";
|
||||
}
|
||||
|
||||
@@ -58,4 +58,8 @@ public static class PlatformScopes
|
||||
// Script registry scopes (script editor / multi-language scripts)
|
||||
public const string ScriptRead = "script:read";
|
||||
public const string ScriptWrite = "script:write";
|
||||
|
||||
// Crypto provider admin scopes (CP-001 / CP-002)
|
||||
public const string CryptoProviderRead = "crypto:read";
|
||||
public const string CryptoProviderAdmin = "crypto:admin";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Health status of a single crypto provider, probed via HTTP.
|
||||
/// </summary>
|
||||
public sealed record CryptoProviderHealthStatus(
|
||||
string Id,
|
||||
string Name,
|
||||
string Status,
|
||||
double? LatencyMs,
|
||||
string HealthEndpoint,
|
||||
string ComposeOverlay,
|
||||
string StartCommand,
|
||||
DateTimeOffset CheckedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated response for the crypto provider health probe endpoint.
|
||||
/// </summary>
|
||||
public sealed record CryptoProviderHealthResponse(
|
||||
IReadOnlyList<CryptoProviderHealthStatus> Providers,
|
||||
DateTimeOffset CheckedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Static definition of a crypto provider (seed data / configuration).
|
||||
/// </summary>
|
||||
public sealed record CryptoProviderDefinition(
|
||||
string Id,
|
||||
string Name,
|
||||
string HealthEndpoint,
|
||||
string ComposeOverlay,
|
||||
string StartCommand);
|
||||
|
||||
/// <summary>
|
||||
/// Persisted tenant crypto provider preference row.
|
||||
/// </summary>
|
||||
public sealed record CryptoProviderPreference(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string ProviderId,
|
||||
string AlgorithmScope,
|
||||
int Priority,
|
||||
bool IsActive,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request body for creating/updating a tenant crypto provider preference.
|
||||
/// </summary>
|
||||
public sealed record CryptoProviderPreferenceRequest(
|
||||
string ProviderId,
|
||||
string? AlgorithmScope,
|
||||
int? Priority,
|
||||
bool? IsActive);
|
||||
|
||||
/// <summary>
|
||||
/// Response wrapper for tenant crypto provider preferences.
|
||||
/// </summary>
|
||||
public sealed record CryptoProviderPreferencesResponse(
|
||||
string TenantId,
|
||||
IReadOnlyList<CryptoProviderPreference> Preferences,
|
||||
DateTimeOffset DataAsOf);
|
||||
@@ -0,0 +1,151 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for crypto provider health probing and tenant preference management.
|
||||
/// CP-001: GET /api/v1/admin/crypto-providers/health
|
||||
/// CP-002: GET/PUT/DELETE /api/v1/admin/crypto-providers/preferences
|
||||
/// </summary>
|
||||
public static class CryptoProviderAdminEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCryptoProviderAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/admin/crypto-providers")
|
||||
.WithTags("CryptoProviders")
|
||||
.RequireAuthorization(PlatformPolicies.HealthAdmin)
|
||||
.RequireTenant();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// CP-001: Health probe
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
group.MapGet("/health", async Task<IResult>(
|
||||
CryptoProviderHealthService healthService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await healthService.ProbeAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetCryptoProviderHealth")
|
||||
.WithSummary("Probe crypto provider health")
|
||||
.WithDescription(
|
||||
"Probes each known crypto provider health endpoint and returns aggregated status. " +
|
||||
"Unreachable providers return 'unreachable' status, not an error.");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// CP-002: Preferences CRUD
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
group.MapGet("/preferences", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ICryptoProviderPreferenceStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var rc, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(rc!.TenantId, out var tenantGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_tenant_id" });
|
||||
}
|
||||
|
||||
var preferences = await store.GetByTenantAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new CryptoProviderPreferencesResponse(
|
||||
TenantId: rc.TenantId,
|
||||
Preferences: preferences,
|
||||
DataAsOf: timeProvider.GetUtcNow()));
|
||||
})
|
||||
.WithName("GetCryptoProviderPreferences")
|
||||
.WithSummary("List tenant crypto provider preferences")
|
||||
.WithDescription("Returns all crypto provider preferences for the current tenant, ordered by priority.");
|
||||
|
||||
group.MapPut("/preferences", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ICryptoProviderPreferenceStore store,
|
||||
[FromBody] CryptoProviderPreferenceRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var rc, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ProviderId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "provider_id_required" });
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(rc!.TenantId, out var tenantGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_tenant_id" });
|
||||
}
|
||||
|
||||
var result = await store.UpsertAsync(tenantGuid, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("UpsertCryptoProviderPreference")
|
||||
.WithSummary("Create or update a crypto provider preference")
|
||||
.WithDescription(
|
||||
"Upserts a tenant crypto provider preference. The unique key is (tenantId, providerId, algorithmScope).");
|
||||
|
||||
group.MapDelete("/preferences/{id:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ICryptoProviderPreferenceStore store,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var rc, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(rc!.TenantId, out var tenantGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_tenant_id" });
|
||||
}
|
||||
|
||||
var deleted = await store.DeleteAsync(tenantGuid, id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return deleted
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = "preference_not_found", id });
|
||||
})
|
||||
.WithName("DeleteCryptoProviderPreference")
|
||||
.WithSummary("Delete a crypto provider preference")
|
||||
.WithDescription("Removes a single crypto provider preference by ID. Tenant-isolated.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,8 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.IdentityProviderAdmin, PlatformScopes.IdentityProviderAdmin);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScriptRead, PlatformScopes.ScriptRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScriptWrite, PlatformScopes.ScriptWrite);
|
||||
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.CryptoProviderRead, PlatformScopes.CryptoProviderRead, PlatformScopes.OpsAdmin);
|
||||
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.CryptoProviderAdmin, PlatformScopes.CryptoProviderAdmin, PlatformScopes.OpsAdmin);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
@@ -210,6 +212,16 @@ builder.Services.AddHttpClient(IdentityProviderManagementService.HttpClientName,
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
// Crypto provider health probe client (CP-001)
|
||||
builder.Services.AddHttpClient("CryptoProviderProbe", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
client.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<CryptoProviderHealthService>();
|
||||
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
||||
@@ -300,6 +312,7 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
|
||||
builder.Services.AddSingleton<IPlatformContextStore, PostgresPlatformContextStore>();
|
||||
builder.Services.AddSingleton<ITranslationStore, PostgresTranslationStore>();
|
||||
builder.Services.AddSingleton<PostgresAssistantStore>();
|
||||
builder.Services.AddSingleton<ICryptoProviderPreferenceStore, PostgresCryptoProviderPreferenceStore>();
|
||||
|
||||
// Auto-migrate platform schemas on startup
|
||||
builder.Services.AddStartupMigrations<PlatformServiceOptions>(
|
||||
@@ -316,6 +329,7 @@ else
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, InMemoryAdministrationTrustSigningStore>();
|
||||
builder.Services.AddSingleton<IPlatformContextStore, InMemoryPlatformContextStore>();
|
||||
builder.Services.AddSingleton<ITranslationStore, InMemoryTranslationStore>();
|
||||
builder.Services.AddSingleton<ICryptoProviderPreferenceStore, InMemoryCryptoProviderPreferenceStore>();
|
||||
}
|
||||
|
||||
// Localization: common base + platform-specific embedded bundles + DB overrides
|
||||
@@ -429,6 +443,7 @@ app.MapSeedEndpoints();
|
||||
app.MapMigrationAdminEndpoints();
|
||||
app.MapRegistrySearchEndpoints();
|
||||
app.MapScriptEndpoints();
|
||||
app.MapCryptoProviderAdminEndpoints();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithTags("Health")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Probes known crypto provider health endpoints and returns aggregated status.
|
||||
/// Providers are defined as a static seed list; unreachable providers return "unreachable"
|
||||
/// status (never a 500).
|
||||
/// </summary>
|
||||
public sealed class CryptoProviderHealthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Well-known crypto provider definitions. These match the compose overlays
|
||||
/// in <c>devops/compose/docker-compose.crypto-provider.*.yml</c>.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyList<CryptoProviderDefinition> KnownProviders =
|
||||
[
|
||||
new CryptoProviderDefinition(
|
||||
Id: "smremote",
|
||||
Name: "SmRemote (SM2/SM3/SM4)",
|
||||
HealthEndpoint: "http://smremote.stella-ops.local/health",
|
||||
ComposeOverlay: "docker-compose.crypto-provider.smremote.yml",
|
||||
StartCommand: "docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.smremote.yml up -d smremote"),
|
||||
|
||||
new CryptoProviderDefinition(
|
||||
Id: "cryptopro",
|
||||
Name: "CryptoPro CSP (GOST)",
|
||||
HealthEndpoint: "http://cryptopro-csp:8080/health",
|
||||
ComposeOverlay: "docker-compose.crypto-provider.cryptopro.yml",
|
||||
StartCommand: "CRYPTOPRO_ACCEPT_EULA=1 docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.cryptopro.yml up -d cryptopro-csp"),
|
||||
|
||||
new CryptoProviderDefinition(
|
||||
Id: "crypto-sim",
|
||||
Name: "Crypto Simulator (dev/test)",
|
||||
HealthEndpoint: "http://sim-crypto:8080/keys",
|
||||
ComposeOverlay: "docker-compose.crypto-provider.crypto-sim.yml",
|
||||
StartCommand: "docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.crypto-sim.yml up -d sim-crypto"),
|
||||
];
|
||||
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<CryptoProviderHealthService> logger;
|
||||
|
||||
public CryptoProviderHealthService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CryptoProviderHealthService> logger)
|
||||
{
|
||||
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the static list of known crypto provider definitions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CryptoProviderDefinition> GetKnownProviders() => KnownProviders;
|
||||
|
||||
/// <summary>
|
||||
/// Probes all known crypto providers concurrently and returns health status for each.
|
||||
/// </summary>
|
||||
public async Task<CryptoProviderHealthResponse> ProbeAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var tasks = KnownProviders.Select(provider => ProbeProviderAsync(provider, now, cancellationToken));
|
||||
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
return new CryptoProviderHealthResponse(
|
||||
Providers: results,
|
||||
CheckedAt: now);
|
||||
}
|
||||
|
||||
private async Task<CryptoProviderHealthStatus> ProbeProviderAsync(
|
||||
CryptoProviderDefinition definition,
|
||||
DateTimeOffset checkedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("CryptoProviderProbe");
|
||||
|
||||
try
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, definition.HealthEndpoint);
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
var latencyMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 1);
|
||||
|
||||
var status = response.IsSuccessStatusCode ? "running" : "degraded";
|
||||
|
||||
logger.LogDebug(
|
||||
"Crypto provider {ProviderId} probe: HTTP {StatusCode} in {LatencyMs}ms",
|
||||
definition.Id, (int)response.StatusCode, latencyMs);
|
||||
|
||||
return new CryptoProviderHealthStatus(
|
||||
Id: definition.Id,
|
||||
Name: definition.Name,
|
||||
Status: status,
|
||||
LatencyMs: latencyMs,
|
||||
HealthEndpoint: definition.HealthEndpoint,
|
||||
ComposeOverlay: definition.ComposeOverlay,
|
||||
StartCommand: definition.StartCommand,
|
||||
CheckedAt: checkedAt);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
logger.LogDebug(
|
||||
ex,
|
||||
"Crypto provider {ProviderId} unreachable at {Endpoint}",
|
||||
definition.Id, definition.HealthEndpoint);
|
||||
|
||||
return new CryptoProviderHealthStatus(
|
||||
Id: definition.Id,
|
||||
Name: definition.Name,
|
||||
Status: "unreachable",
|
||||
LatencyMs: null,
|
||||
HealthEndpoint: definition.HealthEndpoint,
|
||||
ComposeOverlay: definition.ComposeOverlay,
|
||||
StartCommand: definition.StartCommand,
|
||||
CheckedAt: checkedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence abstraction for tenant crypto provider preferences.
|
||||
/// </summary>
|
||||
public interface ICryptoProviderPreferenceStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all crypto provider preferences for the given tenant, ordered by priority.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<CryptoProviderPreference>> GetByTenantAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a preference row (upsert on tenant + provider + algorithm scope).
|
||||
/// </summary>
|
||||
Task<CryptoProviderPreference> UpsertAsync(
|
||||
Guid tenantId,
|
||||
CryptoProviderPreferenceRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a preference by its primary key. Returns true if a row was deleted.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid preferenceId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ICryptoProviderPreferenceStore"/> for development
|
||||
/// and environments without a Postgres connection string.
|
||||
/// </summary>
|
||||
public sealed class InMemoryCryptoProviderPreferenceStore : ICryptoProviderPreferenceStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, CryptoProviderPreference> store = new();
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public InMemoryCryptoProviderPreferenceStore(TimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CryptoProviderPreference>> GetByTenantAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = store.Values
|
||||
.Where(p => p.TenantId == tenantId)
|
||||
.OrderBy(p => p.Priority)
|
||||
.ThenBy(p => p.ProviderId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<CryptoProviderPreference>>(items);
|
||||
}
|
||||
|
||||
public Task<CryptoProviderPreference> UpsertAsync(
|
||||
Guid tenantId,
|
||||
CryptoProviderPreferenceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var algorithmScope = request.AlgorithmScope ?? "*";
|
||||
var priority = request.Priority ?? 0;
|
||||
var isActive = request.IsActive ?? true;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
// Find existing by (tenant, provider, algorithmScope)
|
||||
var existing = store.Values.FirstOrDefault(p =>
|
||||
p.TenantId == tenantId &&
|
||||
string.Equals(p.ProviderId, request.ProviderId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(p.AlgorithmScope, algorithmScope, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
var updated = existing with
|
||||
{
|
||||
Priority = priority,
|
||||
IsActive = isActive,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
store[existing.Id] = updated;
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var preference = new CryptoProviderPreference(
|
||||
Id: id,
|
||||
TenantId: tenantId,
|
||||
ProviderId: request.ProviderId,
|
||||
AlgorithmScope: algorithmScope,
|
||||
Priority: priority,
|
||||
IsActive: isActive,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now);
|
||||
|
||||
store[id] = preference;
|
||||
return Task.FromResult(preference);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid preferenceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (store.TryGetValue(preferenceId, out var existing) && existing.TenantId == tenantId)
|
||||
{
|
||||
return Task.FromResult(store.TryRemove(preferenceId, out _));
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Postgres-backed implementation of <see cref="ICryptoProviderPreferenceStore"/>.
|
||||
/// Reads/writes from <c>platform.tenant_crypto_preferences</c>.
|
||||
/// </summary>
|
||||
public sealed class PostgresCryptoProviderPreferenceStore : ICryptoProviderPreferenceStore
|
||||
{
|
||||
private readonly NpgsqlDataSource dataSource;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<PostgresCryptoProviderPreferenceStore> logger;
|
||||
|
||||
public PostgresCryptoProviderPreferenceStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresCryptoProviderPreferenceStore> logger)
|
||||
{
|
||||
this.dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CryptoProviderPreference>> GetByTenantAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, provider_id, algorithm_scope, priority, is_active, created_at, updated_at
|
||||
FROM platform.tenant_crypto_preferences
|
||||
WHERE tenant_id = @tenantId
|
||||
ORDER BY priority, provider_id
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<CryptoProviderPreference>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(ReadRow(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<CryptoProviderPreference> UpsertAsync(
|
||||
Guid tenantId,
|
||||
CryptoProviderPreferenceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var algorithmScope = request.AlgorithmScope ?? "*";
|
||||
var priority = request.Priority ?? 0;
|
||||
var isActive = request.IsActive ?? true;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO platform.tenant_crypto_preferences
|
||||
(tenant_id, provider_id, algorithm_scope, priority, is_active, created_at, updated_at)
|
||||
VALUES
|
||||
(@tenantId, @providerId, @algorithmScope, @priority, @isActive, @now, @now)
|
||||
ON CONFLICT (tenant_id, provider_id, algorithm_scope)
|
||||
DO UPDATE SET
|
||||
priority = @priority,
|
||||
is_active = @isActive,
|
||||
updated_at = @now
|
||||
RETURNING id, tenant_id, provider_id, algorithm_scope, priority, is_active, created_at, updated_at
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
command.Parameters.AddWithValue("providerId", request.ProviderId);
|
||||
command.Parameters.AddWithValue("algorithmScope", algorithmScope);
|
||||
command.Parameters.AddWithValue("priority", priority);
|
||||
command.Parameters.AddWithValue("isActive", isActive);
|
||||
command.Parameters.AddWithValue("now", now);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("Upsert did not return a row");
|
||||
}
|
||||
|
||||
var result = ReadRow(reader);
|
||||
|
||||
logger.LogInformation(
|
||||
"Upserted crypto preference {PreferenceId} for tenant {TenantId}: provider={ProviderId}, scope={Scope}, priority={Priority}",
|
||||
result.Id, tenantId, request.ProviderId, algorithmScope, priority);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid preferenceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM platform.tenant_crypto_preferences
|
||||
WHERE id = @id AND tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("id", preferenceId);
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (affected > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Deleted crypto preference {PreferenceId} for tenant {TenantId}",
|
||||
preferenceId, tenantId);
|
||||
}
|
||||
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
private static CryptoProviderPreference ReadRow(NpgsqlDataReader reader)
|
||||
{
|
||||
return new CryptoProviderPreference(
|
||||
Id: reader.GetGuid(0),
|
||||
TenantId: reader.GetGuid(1),
|
||||
ProviderId: reader.GetString(2),
|
||||
AlgorithmScope: reader.GetString(3),
|
||||
Priority: reader.GetInt32(4),
|
||||
IsActive: reader.GetBoolean(5),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(6),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(7));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
-- SPRINT_20260408_001 / CP-002: Tenant Crypto Provider Preferences
|
||||
-- Stores per-tenant crypto provider selection, priority, and algorithm scope.
|
||||
-- Used by the Platform admin API to let tenants choose which crypto provider
|
||||
-- (SmRemote, CryptoPro, Crypto-Sim, etc.) handles signing/verification.
|
||||
-- Idempotent: uses IF NOT EXISTS.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- TABLE: platform.tenant_crypto_preferences
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.tenant_crypto_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
|
||||
provider_id VARCHAR(100) NOT NULL,
|
||||
algorithm_scope VARCHAR(100) NOT NULL DEFAULT '*',
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ux_tenant_crypto_pref_unique UNIQUE (tenant_id, provider_id, algorithm_scope)
|
||||
);
|
||||
|
||||
-- Index for fast lookup by tenant
|
||||
CREATE INDEX IF NOT EXISTS ix_tenant_crypto_preferences_tenant
|
||||
ON platform.tenant_crypto_preferences (tenant_id);
|
||||
|
||||
-- Comment for documentation
|
||||
COMMENT ON TABLE platform.tenant_crypto_preferences IS
|
||||
'Per-tenant crypto provider preferences. Each row maps a provider + algorithm scope to a priority for the tenant.';
|
||||
Reference in New Issue
Block a user