diff --git a/devops/compose/docker-compose.crypto-provider.crypto-sim.yml b/devops/compose/docker-compose.crypto-provider.crypto-sim.yml
new file mode 100644
index 000000000..46eeb8841
--- /dev/null
+++ b/devops/compose/docker-compose.crypto-provider.crypto-sim.yml
@@ -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"
diff --git a/devops/compose/docker-compose.crypto-provider.cryptopro.yml b/devops/compose/docker-compose.crypto-provider.cryptopro.yml
new file mode 100644
index 000000000..962619b2f
--- /dev/null
+++ b/devops/compose/docker-compose.crypto-provider.cryptopro.yml
@@ -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
diff --git a/devops/compose/docker-compose.crypto-provider.smremote.yml b/devops/compose/docker-compose.crypto-provider.smremote.yml
new file mode 100644
index 000000000..c8216714d
--- /dev/null
+++ b/devops/compose/docker-compose.crypto-provider.smremote.yml
@@ -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"
diff --git a/docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md b/docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md
new file mode 100644
index 000000000..35f7b9490
--- /dev/null
+++ b/docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md
@@ -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.
diff --git a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs
index c7dbb18d3..90d317aeb 100644
--- a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs
+++ b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs
@@ -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";
}
diff --git a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs
index 83bc1f863..4b4133683 100644
--- a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs
+++ b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs
@@ -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";
}
diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/CryptoProviderModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/CryptoProviderModels.cs
new file mode 100644
index 000000000..6b3f7f33b
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Contracts/CryptoProviderModels.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Collections.Generic;
+
+namespace StellaOps.Platform.WebService.Contracts;
+
+///
+/// Health status of a single crypto provider, probed via HTTP.
+///
+public sealed record CryptoProviderHealthStatus(
+ string Id,
+ string Name,
+ string Status,
+ double? LatencyMs,
+ string HealthEndpoint,
+ string ComposeOverlay,
+ string StartCommand,
+ DateTimeOffset CheckedAt);
+
+///
+/// Aggregated response for the crypto provider health probe endpoint.
+///
+public sealed record CryptoProviderHealthResponse(
+ IReadOnlyList Providers,
+ DateTimeOffset CheckedAt);
+
+///
+/// Static definition of a crypto provider (seed data / configuration).
+///
+public sealed record CryptoProviderDefinition(
+ string Id,
+ string Name,
+ string HealthEndpoint,
+ string ComposeOverlay,
+ string StartCommand);
+
+///
+/// Persisted tenant crypto provider preference row.
+///
+public sealed record CryptoProviderPreference(
+ Guid Id,
+ Guid TenantId,
+ string ProviderId,
+ string AlgorithmScope,
+ int Priority,
+ bool IsActive,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+///
+/// Request body for creating/updating a tenant crypto provider preference.
+///
+public sealed record CryptoProviderPreferenceRequest(
+ string ProviderId,
+ string? AlgorithmScope,
+ int? Priority,
+ bool? IsActive);
+
+///
+/// Response wrapper for tenant crypto provider preferences.
+///
+public sealed record CryptoProviderPreferencesResponse(
+ string TenantId,
+ IReadOnlyList Preferences,
+ DateTimeOffset DataAsOf);
diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/CryptoProviderAdminEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/CryptoProviderAdminEndpoints.cs
new file mode 100644
index 000000000..7624efced
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/CryptoProviderAdminEndpoints.cs
@@ -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;
+
+///
+/// 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
+///
+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(
+ 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(
+ 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(
+ 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(
+ 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;
+ }
+}
diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs
index cff7a2613..4ac88cd9f 100644
--- a/src/Platform/StellaOps.Platform.WebService/Program.cs
+++ b/src/Platform/StellaOps.Platform.WebService/Program.cs
@@ -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();
@@ -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();
+
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton(sp => sp.GetRequiredService());
@@ -300,6 +312,7 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
// Auto-migrate platform schemas on startup
builder.Services.AddStartupMigrations(
@@ -316,6 +329,7 @@ else
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
}
// 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")
diff --git a/src/Platform/StellaOps.Platform.WebService/Services/CryptoProviderHealthService.cs b/src/Platform/StellaOps.Platform.WebService/Services/CryptoProviderHealthService.cs
new file mode 100644
index 000000000..dda1c5df5
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Services/CryptoProviderHealthService.cs
@@ -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;
+
+///
+/// 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).
+///
+public sealed class CryptoProviderHealthService
+{
+ ///
+ /// Well-known crypto provider definitions. These match the compose overlays
+ /// in devops/compose/docker-compose.crypto-provider.*.yml.
+ ///
+ private static readonly IReadOnlyList 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 logger;
+
+ public CryptoProviderHealthService(
+ IHttpClientFactory httpClientFactory,
+ TimeProvider timeProvider,
+ ILogger 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));
+ }
+
+ ///
+ /// Returns the static list of known crypto provider definitions.
+ ///
+ public IReadOnlyList GetKnownProviders() => KnownProviders;
+
+ ///
+ /// Probes all known crypto providers concurrently and returns health status for each.
+ ///
+ public async Task 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 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);
+ }
+ }
+}
diff --git a/src/Platform/StellaOps.Platform.WebService/Services/ICryptoProviderPreferenceStore.cs b/src/Platform/StellaOps.Platform.WebService/Services/ICryptoProviderPreferenceStore.cs
new file mode 100644
index 000000000..f4a25d15c
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Services/ICryptoProviderPreferenceStore.cs
@@ -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;
+
+///
+/// Persistence abstraction for tenant crypto provider preferences.
+///
+public interface ICryptoProviderPreferenceStore
+{
+ ///
+ /// Returns all crypto provider preferences for the given tenant, ordered by priority.
+ ///
+ Task> GetByTenantAsync(
+ Guid tenantId,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Creates or updates a preference row (upsert on tenant + provider + algorithm scope).
+ ///
+ Task UpsertAsync(
+ Guid tenantId,
+ CryptoProviderPreferenceRequest request,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Deletes a preference by its primary key. Returns true if a row was deleted.
+ ///
+ Task DeleteAsync(
+ Guid tenantId,
+ Guid preferenceId,
+ CancellationToken cancellationToken);
+}
diff --git a/src/Platform/StellaOps.Platform.WebService/Services/InMemoryCryptoProviderPreferenceStore.cs b/src/Platform/StellaOps.Platform.WebService/Services/InMemoryCryptoProviderPreferenceStore.cs
new file mode 100644
index 000000000..916465019
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Services/InMemoryCryptoProviderPreferenceStore.cs
@@ -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;
+
+///
+/// In-memory implementation of for development
+/// and environments without a Postgres connection string.
+///
+public sealed class InMemoryCryptoProviderPreferenceStore : ICryptoProviderPreferenceStore
+{
+ private readonly ConcurrentDictionary store = new();
+ private readonly TimeProvider timeProvider;
+
+ public InMemoryCryptoProviderPreferenceStore(TimeProvider timeProvider)
+ {
+ this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ }
+
+ public Task> 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>(items);
+ }
+
+ public Task 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 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);
+ }
+}
diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PostgresCryptoProviderPreferenceStore.cs b/src/Platform/StellaOps.Platform.WebService/Services/PostgresCryptoProviderPreferenceStore.cs
new file mode 100644
index 000000000..a08d601c4
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Services/PostgresCryptoProviderPreferenceStore.cs
@@ -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;
+
+///
+/// Postgres-backed implementation of .
+/// Reads/writes from platform.tenant_crypto_preferences.
+///
+public sealed class PostgresCryptoProviderPreferenceStore : ICryptoProviderPreferenceStore
+{
+ private readonly NpgsqlDataSource dataSource;
+ private readonly TimeProvider timeProvider;
+ private readonly ILogger logger;
+
+ public PostgresCryptoProviderPreferenceStore(
+ NpgsqlDataSource dataSource,
+ TimeProvider timeProvider,
+ ILogger 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> 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();
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ results.Add(ReadRow(reader));
+ }
+
+ return results;
+ }
+
+ public async Task 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 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(6),
+ UpdatedAt: reader.GetFieldValue(7));
+ }
+}
diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/062_TenantCryptoPreferences.sql b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/062_TenantCryptoPreferences.sql
new file mode 100644
index 000000000..1652f1815
--- /dev/null
+++ b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/062_TenantCryptoPreferences.sql
@@ -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.';