From 59ba757eaa1c733341d794101d2b310585517b4c Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 8 Apr 2026 13:21:50 +0300 Subject: [PATCH] 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) --- ...ker-compose.crypto-provider.crypto-sim.yml | 119 ++++++++++ ...cker-compose.crypto-provider.cryptopro.yml | 149 ++++++++++++ ...ocker-compose.crypto-provider.smremote.yml | 90 +++++++ ..._20260408_001_FE_crypto_provider_picker.md | 219 ++++++++++++++++++ .../Constants/PlatformPolicies.cs | 4 + .../Constants/PlatformScopes.cs | 4 + .../Contracts/CryptoProviderModels.cs | 64 +++++ .../Endpoints/CryptoProviderAdminEndpoints.cs | 151 ++++++++++++ .../StellaOps.Platform.WebService/Program.cs | 15 ++ .../Services/CryptoProviderHealthService.cs | 133 +++++++++++ .../ICryptoProviderPreferenceStore.cs | 36 +++ .../InMemoryCryptoProviderPreferenceStore.cs | 96 ++++++++ .../PostgresCryptoProviderPreferenceStore.cs | 145 ++++++++++++ .../Release/062_TenantCryptoPreferences.sql | 29 +++ 14 files changed, 1254 insertions(+) create mode 100644 devops/compose/docker-compose.crypto-provider.crypto-sim.yml create mode 100644 devops/compose/docker-compose.crypto-provider.cryptopro.yml create mode 100644 devops/compose/docker-compose.crypto-provider.smremote.yml create mode 100644 docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md create mode 100644 src/Platform/StellaOps.Platform.WebService/Contracts/CryptoProviderModels.cs create mode 100644 src/Platform/StellaOps.Platform.WebService/Endpoints/CryptoProviderAdminEndpoints.cs create mode 100644 src/Platform/StellaOps.Platform.WebService/Services/CryptoProviderHealthService.cs create mode 100644 src/Platform/StellaOps.Platform.WebService/Services/ICryptoProviderPreferenceStore.cs create mode 100644 src/Platform/StellaOps.Platform.WebService/Services/InMemoryCryptoProviderPreferenceStore.cs create mode 100644 src/Platform/StellaOps.Platform.WebService/Services/PostgresCryptoProviderPreferenceStore.cs create mode 100644 src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/062_TenantCryptoPreferences.sql 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.';