feat(crypto): extract crypto providers to overlay compose files + health probe API

- Extract smremote to docker-compose.crypto-provider.smremote.yml
- Rename cryptopro/crypto-sim compose files for consistent naming
- Add crypto provider health probe endpoint (CP-001)
- Add tenant crypto provider preferences API + migration (CP-002)
- Update docs and compliance env examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 13:21:50 +03:00
parent c1ecc75ace
commit 59ba757eaa
14 changed files with 1254 additions and 0 deletions

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
/// <summary>
/// Health status of a single crypto provider, probed via HTTP.
/// </summary>
public sealed record CryptoProviderHealthStatus(
string Id,
string Name,
string Status,
double? LatencyMs,
string HealthEndpoint,
string ComposeOverlay,
string StartCommand,
DateTimeOffset CheckedAt);
/// <summary>
/// Aggregated response for the crypto provider health probe endpoint.
/// </summary>
public sealed record CryptoProviderHealthResponse(
IReadOnlyList<CryptoProviderHealthStatus> Providers,
DateTimeOffset CheckedAt);
/// <summary>
/// Static definition of a crypto provider (seed data / configuration).
/// </summary>
public sealed record CryptoProviderDefinition(
string Id,
string Name,
string HealthEndpoint,
string ComposeOverlay,
string StartCommand);
/// <summary>
/// Persisted tenant crypto provider preference row.
/// </summary>
public sealed record CryptoProviderPreference(
Guid Id,
Guid TenantId,
string ProviderId,
string AlgorithmScope,
int Priority,
bool IsActive,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>
/// Request body for creating/updating a tenant crypto provider preference.
/// </summary>
public sealed record CryptoProviderPreferenceRequest(
string ProviderId,
string? AlgorithmScope,
int? Priority,
bool? IsActive);
/// <summary>
/// Response wrapper for tenant crypto provider preferences.
/// </summary>
public sealed record CryptoProviderPreferencesResponse(
string TenantId,
IReadOnlyList<CryptoProviderPreference> Preferences,
DateTimeOffset DataAsOf);

View File

@@ -0,0 +1,151 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using System;
namespace StellaOps.Platform.WebService.Endpoints;
/// <summary>
/// Admin endpoints for crypto provider health probing and tenant preference management.
/// CP-001: GET /api/v1/admin/crypto-providers/health
/// CP-002: GET/PUT/DELETE /api/v1/admin/crypto-providers/preferences
/// </summary>
public static class CryptoProviderAdminEndpoints
{
public static IEndpointRouteBuilder MapCryptoProviderAdminEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/admin/crypto-providers")
.WithTags("CryptoProviders")
.RequireAuthorization(PlatformPolicies.HealthAdmin)
.RequireTenant();
// ---------------------------------------------------------------
// CP-001: Health probe
// ---------------------------------------------------------------
group.MapGet("/health", async Task<IResult>(
CryptoProviderHealthService healthService,
CancellationToken cancellationToken) =>
{
var result = await healthService.ProbeAllAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
})
.WithName("GetCryptoProviderHealth")
.WithSummary("Probe crypto provider health")
.WithDescription(
"Probes each known crypto provider health endpoint and returns aggregated status. " +
"Unreachable providers return 'unreachable' status, not an error.");
// ---------------------------------------------------------------
// CP-002: Preferences CRUD
// ---------------------------------------------------------------
group.MapGet("/preferences", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ICryptoProviderPreferenceStore store,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var rc, out var failure))
{
return failure!;
}
if (!Guid.TryParse(rc!.TenantId, out var tenantGuid))
{
return Results.BadRequest(new { error = "invalid_tenant_id" });
}
var preferences = await store.GetByTenantAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
return Results.Ok(new CryptoProviderPreferencesResponse(
TenantId: rc.TenantId,
Preferences: preferences,
DataAsOf: timeProvider.GetUtcNow()));
})
.WithName("GetCryptoProviderPreferences")
.WithSummary("List tenant crypto provider preferences")
.WithDescription("Returns all crypto provider preferences for the current tenant, ordered by priority.");
group.MapPut("/preferences", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ICryptoProviderPreferenceStore store,
[FromBody] CryptoProviderPreferenceRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var rc, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(request.ProviderId))
{
return Results.BadRequest(new { error = "provider_id_required" });
}
if (!Guid.TryParse(rc!.TenantId, out var tenantGuid))
{
return Results.BadRequest(new { error = "invalid_tenant_id" });
}
var result = await store.UpsertAsync(tenantGuid, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
})
.WithName("UpsertCryptoProviderPreference")
.WithSummary("Create or update a crypto provider preference")
.WithDescription(
"Upserts a tenant crypto provider preference. The unique key is (tenantId, providerId, algorithmScope).");
group.MapDelete("/preferences/{id:guid}", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ICryptoProviderPreferenceStore store,
Guid id,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var rc, out var failure))
{
return failure!;
}
if (!Guid.TryParse(rc!.TenantId, out var tenantGuid))
{
return Results.BadRequest(new { error = "invalid_tenant_id" });
}
var deleted = await store.DeleteAsync(tenantGuid, id, cancellationToken).ConfigureAwait(false);
return deleted
? Results.NoContent()
: Results.NotFound(new { error = "preference_not_found", id });
})
.WithName("DeleteCryptoProviderPreference")
.WithSummary("Delete a crypto provider preference")
.WithDescription("Removes a single crypto provider preference by ID. Tenant-isolated.");
return app;
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
}

View File

@@ -160,6 +160,8 @@ builder.Services.AddAuthorization(options =>
options.AddStellaOpsScopePolicy(PlatformPolicies.IdentityProviderAdmin, PlatformScopes.IdentityProviderAdmin);
options.AddStellaOpsScopePolicy(PlatformPolicies.ScriptRead, PlatformScopes.ScriptRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.ScriptWrite, PlatformScopes.ScriptWrite);
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.CryptoProviderRead, PlatformScopes.CryptoProviderRead, PlatformScopes.OpsAdmin);
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.CryptoProviderAdmin, PlatformScopes.CryptoProviderAdmin, PlatformScopes.OpsAdmin);
});
builder.Services.AddSingleton<PlatformRequestContextResolver>();
@@ -210,6 +212,16 @@ builder.Services.AddHttpClient(IdentityProviderManagementService.HttpClientName,
client.Timeout = TimeSpan.FromSeconds(15);
});
// Crypto provider health probe client (CP-001)
builder.Services.AddHttpClient("CryptoProviderProbe", client =>
{
client.Timeout = TimeSpan.FromSeconds(5);
client.DefaultRequestHeaders.Accept.Add(
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
});
builder.Services.AddSingleton<CryptoProviderHealthService>();
builder.Services.AddSingleton<PlatformMetadataService>();
builder.Services.AddSingleton<PlatformContextService>();
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
@@ -300,6 +312,7 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
builder.Services.AddSingleton<IPlatformContextStore, PostgresPlatformContextStore>();
builder.Services.AddSingleton<ITranslationStore, PostgresTranslationStore>();
builder.Services.AddSingleton<PostgresAssistantStore>();
builder.Services.AddSingleton<ICryptoProviderPreferenceStore, PostgresCryptoProviderPreferenceStore>();
// Auto-migrate platform schemas on startup
builder.Services.AddStartupMigrations<PlatformServiceOptions>(
@@ -316,6 +329,7 @@ else
builder.Services.AddSingleton<IAdministrationTrustSigningStore, InMemoryAdministrationTrustSigningStore>();
builder.Services.AddSingleton<IPlatformContextStore, InMemoryPlatformContextStore>();
builder.Services.AddSingleton<ITranslationStore, InMemoryTranslationStore>();
builder.Services.AddSingleton<ICryptoProviderPreferenceStore, InMemoryCryptoProviderPreferenceStore>();
}
// Localization: common base + platform-specific embedded bundles + DB overrides
@@ -429,6 +443,7 @@ app.MapSeedEndpoints();
app.MapMigrationAdminEndpoints();
app.MapRegistrySearchEndpoints();
app.MapScriptEndpoints();
app.MapCryptoProviderAdminEndpoints();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
.WithTags("Health")

View File

@@ -0,0 +1,133 @@
using Microsoft.Extensions.Logging;
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
/// <summary>
/// Probes known crypto provider health endpoints and returns aggregated status.
/// Providers are defined as a static seed list; unreachable providers return "unreachable"
/// status (never a 500).
/// </summary>
public sealed class CryptoProviderHealthService
{
/// <summary>
/// Well-known crypto provider definitions. These match the compose overlays
/// in <c>devops/compose/docker-compose.crypto-provider.*.yml</c>.
/// </summary>
private static readonly IReadOnlyList<CryptoProviderDefinition> KnownProviders =
[
new CryptoProviderDefinition(
Id: "smremote",
Name: "SmRemote (SM2/SM3/SM4)",
HealthEndpoint: "http://smremote.stella-ops.local/health",
ComposeOverlay: "docker-compose.crypto-provider.smremote.yml",
StartCommand: "docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.smremote.yml up -d smremote"),
new CryptoProviderDefinition(
Id: "cryptopro",
Name: "CryptoPro CSP (GOST)",
HealthEndpoint: "http://cryptopro-csp:8080/health",
ComposeOverlay: "docker-compose.crypto-provider.cryptopro.yml",
StartCommand: "CRYPTOPRO_ACCEPT_EULA=1 docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.cryptopro.yml up -d cryptopro-csp"),
new CryptoProviderDefinition(
Id: "crypto-sim",
Name: "Crypto Simulator (dev/test)",
HealthEndpoint: "http://sim-crypto:8080/keys",
ComposeOverlay: "docker-compose.crypto-provider.crypto-sim.yml",
StartCommand: "docker compose -f docker-compose.stella-ops.yml -f docker-compose.crypto-provider.crypto-sim.yml up -d sim-crypto"),
];
private readonly IHttpClientFactory httpClientFactory;
private readonly TimeProvider timeProvider;
private readonly ILogger<CryptoProviderHealthService> logger;
public CryptoProviderHealthService(
IHttpClientFactory httpClientFactory,
TimeProvider timeProvider,
ILogger<CryptoProviderHealthService> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Returns the static list of known crypto provider definitions.
/// </summary>
public IReadOnlyList<CryptoProviderDefinition> GetKnownProviders() => KnownProviders;
/// <summary>
/// Probes all known crypto providers concurrently and returns health status for each.
/// </summary>
public async Task<CryptoProviderHealthResponse> ProbeAllAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var tasks = KnownProviders.Select(provider => ProbeProviderAsync(provider, now, cancellationToken));
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
return new CryptoProviderHealthResponse(
Providers: results,
CheckedAt: now);
}
private async Task<CryptoProviderHealthStatus> ProbeProviderAsync(
CryptoProviderDefinition definition,
DateTimeOffset checkedAt,
CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient("CryptoProviderProbe");
try
{
var stopwatch = Stopwatch.StartNew();
using var request = new HttpRequestMessage(HttpMethod.Get, definition.HealthEndpoint);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
stopwatch.Stop();
var latencyMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 1);
var status = response.IsSuccessStatusCode ? "running" : "degraded";
logger.LogDebug(
"Crypto provider {ProviderId} probe: HTTP {StatusCode} in {LatencyMs}ms",
definition.Id, (int)response.StatusCode, latencyMs);
return new CryptoProviderHealthStatus(
Id: definition.Id,
Name: definition.Name,
Status: status,
LatencyMs: latencyMs,
HealthEndpoint: definition.HealthEndpoint,
ComposeOverlay: definition.ComposeOverlay,
StartCommand: definition.StartCommand,
CheckedAt: checkedAt);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
{
logger.LogDebug(
ex,
"Crypto provider {ProviderId} unreachable at {Endpoint}",
definition.Id, definition.HealthEndpoint);
return new CryptoProviderHealthStatus(
Id: definition.Id,
Name: definition.Name,
Status: "unreachable",
LatencyMs: null,
HealthEndpoint: definition.HealthEndpoint,
ComposeOverlay: definition.ComposeOverlay,
StartCommand: definition.StartCommand,
CheckedAt: checkedAt);
}
}
}

View File

@@ -0,0 +1,36 @@
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
/// <summary>
/// Persistence abstraction for tenant crypto provider preferences.
/// </summary>
public interface ICryptoProviderPreferenceStore
{
/// <summary>
/// Returns all crypto provider preferences for the given tenant, ordered by priority.
/// </summary>
Task<IReadOnlyList<CryptoProviderPreference>> GetByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Creates or updates a preference row (upsert on tenant + provider + algorithm scope).
/// </summary>
Task<CryptoProviderPreference> UpsertAsync(
Guid tenantId,
CryptoProviderPreferenceRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Deletes a preference by its primary key. Returns true if a row was deleted.
/// </summary>
Task<bool> DeleteAsync(
Guid tenantId,
Guid preferenceId,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,96 @@
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
/// <summary>
/// In-memory implementation of <see cref="ICryptoProviderPreferenceStore"/> for development
/// and environments without a Postgres connection string.
/// </summary>
public sealed class InMemoryCryptoProviderPreferenceStore : ICryptoProviderPreferenceStore
{
private readonly ConcurrentDictionary<Guid, CryptoProviderPreference> store = new();
private readonly TimeProvider timeProvider;
public InMemoryCryptoProviderPreferenceStore(TimeProvider timeProvider)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<IReadOnlyList<CryptoProviderPreference>> GetByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken)
{
var items = store.Values
.Where(p => p.TenantId == tenantId)
.OrderBy(p => p.Priority)
.ThenBy(p => p.ProviderId, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<CryptoProviderPreference>>(items);
}
public Task<CryptoProviderPreference> UpsertAsync(
Guid tenantId,
CryptoProviderPreferenceRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var algorithmScope = request.AlgorithmScope ?? "*";
var priority = request.Priority ?? 0;
var isActive = request.IsActive ?? true;
var now = timeProvider.GetUtcNow();
// Find existing by (tenant, provider, algorithmScope)
var existing = store.Values.FirstOrDefault(p =>
p.TenantId == tenantId &&
string.Equals(p.ProviderId, request.ProviderId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(p.AlgorithmScope, algorithmScope, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
var updated = existing with
{
Priority = priority,
IsActive = isActive,
UpdatedAt = now,
};
store[existing.Id] = updated;
return Task.FromResult(updated);
}
var id = Guid.NewGuid();
var preference = new CryptoProviderPreference(
Id: id,
TenantId: tenantId,
ProviderId: request.ProviderId,
AlgorithmScope: algorithmScope,
Priority: priority,
IsActive: isActive,
CreatedAt: now,
UpdatedAt: now);
store[id] = preference;
return Task.FromResult(preference);
}
public Task<bool> DeleteAsync(
Guid tenantId,
Guid preferenceId,
CancellationToken cancellationToken)
{
if (store.TryGetValue(preferenceId, out var existing) && existing.TenantId == tenantId)
{
return Task.FromResult(store.TryRemove(preferenceId, out _));
}
return Task.FromResult(false);
}
}

View File

@@ -0,0 +1,145 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
/// <summary>
/// Postgres-backed implementation of <see cref="ICryptoProviderPreferenceStore"/>.
/// Reads/writes from <c>platform.tenant_crypto_preferences</c>.
/// </summary>
public sealed class PostgresCryptoProviderPreferenceStore : ICryptoProviderPreferenceStore
{
private readonly NpgsqlDataSource dataSource;
private readonly TimeProvider timeProvider;
private readonly ILogger<PostgresCryptoProviderPreferenceStore> logger;
public PostgresCryptoProviderPreferenceStore(
NpgsqlDataSource dataSource,
TimeProvider timeProvider,
ILogger<PostgresCryptoProviderPreferenceStore> logger)
{
this.dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<CryptoProviderPreference>> GetByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken)
{
const string sql = """
SELECT id, tenant_id, provider_id, algorithm_scope, priority, is_active, created_at, updated_at
FROM platform.tenant_crypto_preferences
WHERE tenant_id = @tenantId
ORDER BY priority, provider_id
""";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenantId", tenantId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<CryptoProviderPreference>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(ReadRow(reader));
}
return results;
}
public async Task<CryptoProviderPreference> UpsertAsync(
Guid tenantId,
CryptoProviderPreferenceRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var algorithmScope = request.AlgorithmScope ?? "*";
var priority = request.Priority ?? 0;
var isActive = request.IsActive ?? true;
var now = timeProvider.GetUtcNow();
const string sql = """
INSERT INTO platform.tenant_crypto_preferences
(tenant_id, provider_id, algorithm_scope, priority, is_active, created_at, updated_at)
VALUES
(@tenantId, @providerId, @algorithmScope, @priority, @isActive, @now, @now)
ON CONFLICT (tenant_id, provider_id, algorithm_scope)
DO UPDATE SET
priority = @priority,
is_active = @isActive,
updated_at = @now
RETURNING id, tenant_id, provider_id, algorithm_scope, priority, is_active, created_at, updated_at
""";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("providerId", request.ProviderId);
command.Parameters.AddWithValue("algorithmScope", algorithmScope);
command.Parameters.AddWithValue("priority", priority);
command.Parameters.AddWithValue("isActive", isActive);
command.Parameters.AddWithValue("now", now);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException("Upsert did not return a row");
}
var result = ReadRow(reader);
logger.LogInformation(
"Upserted crypto preference {PreferenceId} for tenant {TenantId}: provider={ProviderId}, scope={Scope}, priority={Priority}",
result.Id, tenantId, request.ProviderId, algorithmScope, priority);
return result;
}
public async Task<bool> DeleteAsync(
Guid tenantId,
Guid preferenceId,
CancellationToken cancellationToken)
{
const string sql = """
DELETE FROM platform.tenant_crypto_preferences
WHERE id = @id AND tenant_id = @tenantId
""";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("id", preferenceId);
command.Parameters.AddWithValue("tenantId", tenantId);
var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (affected > 0)
{
logger.LogInformation(
"Deleted crypto preference {PreferenceId} for tenant {TenantId}",
preferenceId, tenantId);
}
return affected > 0;
}
private static CryptoProviderPreference ReadRow(NpgsqlDataReader reader)
{
return new CryptoProviderPreference(
Id: reader.GetGuid(0),
TenantId: reader.GetGuid(1),
ProviderId: reader.GetString(2),
AlgorithmScope: reader.GetString(3),
Priority: reader.GetInt32(4),
IsActive: reader.GetBoolean(5),
CreatedAt: reader.GetFieldValue<DateTimeOffset>(6),
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(7));
}
}

View File

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