Skip to content

Commit 59de288

Browse files
committed
refactor: improve health checks for Azure services
Transition health checks to use less-privileged roles, enhancing security by reducing permission requirements in Azure Key Vault, Blob Storage, Queue Storage, SQL Server, and Redis.
1 parent 126983c commit 59de288

5 files changed

Lines changed: 75 additions & 13 deletions

File tree

src/ES.FX.Ignite.Azure.Security.KeyVault.Secrets/HealthChecks/SimpleKeyVaultSecretsHealthCheck.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Azure;
12
using Azure.Security.KeyVault.Secrets;
23
using Microsoft.Extensions.Diagnostics.HealthChecks;
34

@@ -6,22 +7,31 @@ namespace ES.FX.Ignite.Azure.Security.KeyVault.Secrets.HealthChecks;
67
/// <summary>
78
/// Azure Key Vault secrets health check.
89
/// </summary>
10+
/// <remarks>
11+
/// Probes the vault by issuing <see cref="SecretClient.GetSecretAsync(string, string, CancellationToken)" />
12+
/// against a sentinel secret name. The check only requires "Get" permission on that one secret name
13+
/// (no List permission needed). A 404 response is treated as healthy because the connection succeeded —
14+
/// the absence of the sentinel secret is intentional and does not require it to exist.
15+
/// </remarks>
916
internal sealed class SimpleKeyVaultSecretsHealthCheck(SecretClient secretClient) : IHealthCheck
1017
{
18+
private const string ProbeSecretName = "AzureKeyVaultSecretsHealthCheck";
19+
1120
/// <inheritdoc />
1221
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
1322
CancellationToken cancellationToken = default)
1423
{
1524
try
1625
{
17-
await secretClient
18-
.GetPropertiesOfSecretsAsync(cancellationToken)
19-
.GetAsyncEnumerator(cancellationToken)
20-
.MoveNextAsync()
26+
await secretClient.GetSecretAsync(ProbeSecretName, cancellationToken: cancellationToken)
2127
.ConfigureAwait(false);
2228

2329
return HealthCheckResult.Healthy();
2430
}
31+
catch (RequestFailedException ex) when (ex.Status == 404)
32+
{
33+
return HealthCheckResult.Healthy();
34+
}
2535
catch (Exception ex)
2636
{
2737
return new HealthCheckResult(context.Registration.FailureStatus, exception: ex);

src/ES.FX.Ignite.Azure.Storage.Blobs/HealthChecks/SimpleBlobServiceHealthCheck.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ namespace ES.FX.Ignite.Azure.Storage.Blobs.HealthChecks;
66
/// <summary>
77
/// Azure Blob Storage health check.
88
/// </summary>
9+
/// <remarks>
10+
/// Uses a page-list probe (page size 1) instead of <c>BlobServiceClient.GetPropertiesAsync</c> so the check
11+
/// works with the least-privileged role assignment ("Storage Blob Data Reader" at storage account level).
12+
/// <c>GetPropertiesAsync</c> requires elevated permissions that "Storage Blob Data Contributor" does not grant.
13+
/// </remarks>
914
internal sealed class SimpleBlobServiceHealthCheck(BlobServiceClient blobServiceClient) : IHealthCheck
1015
{
1116
/// <inheritdoc />
@@ -14,7 +19,13 @@ public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context
1419
{
1520
try
1621
{
17-
await blobServiceClient.GetPropertiesAsync(cancellationToken).ConfigureAwait(false);
22+
await blobServiceClient
23+
.GetBlobContainersAsync(cancellationToken: cancellationToken)
24+
.AsPages(pageSizeHint: 1)
25+
.GetAsyncEnumerator(cancellationToken)
26+
.MoveNextAsync()
27+
.ConfigureAwait(false);
28+
1829
return HealthCheckResult.Healthy();
1930
}
2031
catch (Exception ex)

src/ES.FX.Ignite.Azure.Storage.Queues/HealthChecks/SimpleQueueServiceHealthCheck.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ namespace ES.FX.Ignite.Azure.Storage.Queues.HealthChecks;
66
/// <summary>
77
/// Azure Queue Storage health check.
88
/// </summary>
9+
/// <remarks>
10+
/// Uses a page-list probe (page size 1) instead of <c>QueueServiceClient.GetPropertiesAsync</c> so the check
11+
/// works with the least-privileged role assignment ("Storage Queue Data Reader" at storage account level).
12+
/// <c>GetPropertiesAsync</c> requires elevated permissions that "Storage Queue Data Contributor" does not grant.
13+
/// </remarks>
914
internal sealed class SimpleQueueServiceHealthCheck(QueueServiceClient queueServiceClient) : IHealthCheck
1015
{
1116
/// <inheritdoc />
@@ -14,7 +19,13 @@ public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context
1419
{
1520
try
1621
{
17-
await queueServiceClient.GetPropertiesAsync(cancellationToken).ConfigureAwait(false);
22+
await queueServiceClient
23+
.GetQueuesAsync(cancellationToken: cancellationToken)
24+
.AsPages(pageSizeHint: 1)
25+
.GetAsyncEnumerator(cancellationToken)
26+
.MoveNextAsync()
27+
.ConfigureAwait(false);
28+
1829
return HealthCheckResult.Healthy();
1930
}
2031
catch (Exception ex)

src/ES.FX.Ignite.Microsoft.Data.SqlClient/HealthChecks/SimpleSqlServerHealthCheck.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,23 @@
44
namespace ES.FX.Ignite.Microsoft.Data.SqlClient.HealthChecks;
55

66
/// <summary>
7-
/// SQL Server health check that opens a connection and executes a simple query.
7+
/// SQL Server health check that opens a connection and executes a probe query.
88
/// </summary>
99
internal sealed class SimpleSqlServerHealthCheck(string connectionString) : IHealthCheck
1010
{
11+
private const string HealthQuery = "SELECT 1;";
12+
1113
/// <inheritdoc />
1214
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
1315
CancellationToken cancellationToken = default)
1416
{
1517
try
1618
{
17-
await using var connection = new SqlConnection(connectionString);
19+
using var connection = new SqlConnection(connectionString);
1820
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
19-
await using var command = connection.CreateCommand();
20-
command.CommandText = "SELECT 1;";
21+
22+
using var command = connection.CreateCommand();
23+
command.CommandText = HealthQuery;
2124
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
2225

2326
return HealthCheckResult.Healthy();

src/ES.FX.Ignite.StackExchange.Redis/HealthChecks/SimpleRedisHealthCheck.cs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44
namespace ES.FX.Ignite.StackExchange.Redis.HealthChecks;
55

66
/// <summary>
7-
/// Redis health check that pings the connection multiplexer.
7+
/// Redis health check.
88
/// </summary>
9+
/// <remarks>
10+
/// Iterates every configured endpoint on the multiplexer. For non-cluster servers, both the database
11+
/// and the server endpoint are pinged. For cluster nodes, <c>CLUSTER INFO</c> is executed and the
12+
/// response is inspected for <c>cluster_state:ok</c>.
13+
/// </remarks>
914
internal sealed class SimpleRedisHealthCheck(IConnectionMultiplexer connectionMultiplexer) : IHealthCheck
1015
{
1116
/// <inheritdoc />
@@ -14,8 +19,30 @@ public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context
1419
{
1520
try
1621
{
17-
cancellationToken.ThrowIfCancellationRequested();
18-
await connectionMultiplexer.GetDatabase().PingAsync().ConfigureAwait(false);
22+
foreach (var endPoint in connectionMultiplexer.GetEndPoints(configuredOnly: true))
23+
{
24+
cancellationToken.ThrowIfCancellationRequested();
25+
26+
var server = connectionMultiplexer.GetServer(endPoint);
27+
28+
if (server.ServerType != ServerType.Cluster)
29+
{
30+
await connectionMultiplexer.GetDatabase().PingAsync().ConfigureAwait(false);
31+
await server.PingAsync().ConfigureAwait(false);
32+
continue;
33+
}
34+
35+
var clusterInfo = await server.ExecuteAsync("CLUSTER", "INFO").ConfigureAwait(false);
36+
37+
if (clusterInfo.IsNull)
38+
return new HealthCheckResult(context.Registration.FailureStatus,
39+
$"INFO CLUSTER is null or can't be read for endpoint {endPoint}");
40+
41+
if (!clusterInfo.ToString()!.Contains("cluster_state:ok"))
42+
return new HealthCheckResult(context.Registration.FailureStatus,
43+
$"INFO CLUSTER is not on OK state for endpoint {endPoint}");
44+
}
45+
1946
return HealthCheckResult.Healthy();
2047
}
2148
catch (Exception ex)

0 commit comments

Comments
 (0)