Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT License.

using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -76,43 +75,42 @@ public async Task WriteResponseAsync(HttpContext context)
// Global comprehensive Health Check Enabled
if (config.IsHealthEnabled)
{
_healthCheckHelper.StoreIncomingRoleHeader(context);
if (!_healthCheckHelper.IsUserAllowedToAccessHealthCheck(context, config.IsDevelopmentMode(), config.AllowedRolesForHealth))
(string roleHeader, string roleToken) = _healthCheckHelper.ReadRoleHeaders(context);
if (!_healthCheckHelper.IsUserAllowedToAccessHealthCheck(config.IsDevelopmentMode(), config.AllowedRolesForHealth, roleHeader))
{
_logger.LogError("Comprehensive Health Check Report is not allowed: 403 Forbidden due to insufficient permissions.");
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.CompleteAsync();
return;
}

string? response;
// Check if the cache is enabled
if (config.CacheTtlSecondsForHealthReport > 0)
{
ComprehensiveHealthCheckReport? report = null;
try
{
response = await _cache.GetOrSetAsync<string?>(
report = await _cache.GetOrSetAsync<ComprehensiveHealthCheckReport?>(
key: CACHE_KEY,
async (FusionCacheFactoryExecutionContext<string?> ctx, CancellationToken ct) =>
async (FusionCacheFactoryExecutionContext<ComprehensiveHealthCheckReport?> ctx, CancellationToken ct) =>
{
string? response = await ExecuteHealthCheckAsync(config).ConfigureAwait(false);
ComprehensiveHealthCheckReport? r = await _healthCheckHelper.GetHealthCheckResponseAsync(config, roleHeader, roleToken).ConfigureAwait(false);
ctx.Options.SetDuration(TimeSpan.FromSeconds(config.CacheTtlSecondsForHealthReport));
return response;
return r;
});

_logger.LogTrace($"Health check response is fetched from cache with key: {CACHE_KEY} and TTL: {config.CacheTtlSecondsForHealthReport} seconds.");
}
catch (Exception ex)
{
response = null; // Set response to null in case of an error
_logger.LogError($"Error in caching health check response: {ex.Message}");
}

// Ensure cachedResponse is not null before calling WriteAsync
if (response != null)
if (report != null)
{
// Return the cached or newly generated response
await context.Response.WriteAsync(response);
// Set currentRole per-request (not cached) so each caller sees their own role
await context.Response.WriteAsync(SerializeReport(report with { CurrentRole = _healthCheckHelper.GetCurrentRole(roleHeader, roleToken) }));
}
else
{
Expand All @@ -124,9 +122,9 @@ public async Task WriteResponseAsync(HttpContext context)
}
else
{
response = await ExecuteHealthCheckAsync(config).ConfigureAwait(false);
ComprehensiveHealthCheckReport report = await _healthCheckHelper.GetHealthCheckResponseAsync(config, roleHeader, roleToken).ConfigureAwait(false);
// Return the newly generated response
await context.Response.WriteAsync(response);
await context.Response.WriteAsync(SerializeReport(report with { CurrentRole = _healthCheckHelper.GetCurrentRole(roleHeader, roleToken) }));
}
}
else
Expand All @@ -139,13 +137,10 @@ public async Task WriteResponseAsync(HttpContext context)
return;
}

private async Task<string> ExecuteHealthCheckAsync(RuntimeConfig config)
private string SerializeReport(ComprehensiveHealthCheckReport report)
{
ComprehensiveHealthCheckReport dabHealthCheckReport = await _healthCheckHelper.GetHealthCheckResponseAsync(config);
string response = JsonSerializer.Serialize(dabHealthCheckReport, options: new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
_logger.LogTrace($"Health check response writer writing status as: {dabHealthCheckReport.Status}");

return response;
_logger.LogTrace($"Health check response writer writing status as: {report.Status}");
return JsonSerializer.Serialize(report, options: new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
}
}
}
64 changes: 34 additions & 30 deletions src/Service/HealthCheck/HealthCheckHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ public class HealthCheckHelper
// Dependencies
private ILogger<HealthCheckHelper> _logger;
private HttpUtilities _httpUtility;
private string _incomingRoleHeader = string.Empty;
private string _incomingRoleToken = string.Empty;

private const string TIME_EXCEEDED_ERROR_MESSAGE = "The threshold for executing the request has exceeded.";

Expand All @@ -48,8 +46,10 @@ public HealthCheckHelper(ILogger<HealthCheckHelper> logger, HttpUtilities httpUt
/// Serializes the report to JSON and returns the response.
/// </summary>
/// <param name="runtimeConfig">RuntimeConfig</param>
/// <param name="roleHeader">The effective role header for the current request.</param>
/// <param name="roleToken">The bearer token for the current request.</param>
/// <returns>This function returns the comprehensive health report after calculating the response time of each datasource, rest and graphql health queries.</returns>
public async Task<ComprehensiveHealthCheckReport> GetHealthCheckResponseAsync(RuntimeConfig runtimeConfig)
public async Task<ComprehensiveHealthCheckReport> GetHealthCheckResponseAsync(RuntimeConfig runtimeConfig, string roleHeader, string roleToken)
{
// Create a JSON response for the comprehensive health check endpoint using the provided basic health report.
// If the response has already been created, it will be reused.
Expand All @@ -59,13 +59,13 @@ public async Task<ComprehensiveHealthCheckReport> GetHealthCheckResponseAsync(Ru
UpdateVersionAndAppName(ref comprehensiveHealthCheckReport);
UpdateTimestampOfResponse(ref comprehensiveHealthCheckReport);
UpdateDabConfigurationDetails(ref comprehensiveHealthCheckReport, runtimeConfig);
await UpdateHealthCheckDetailsAsync(comprehensiveHealthCheckReport, runtimeConfig);
await UpdateHealthCheckDetailsAsync(comprehensiveHealthCheckReport, runtimeConfig, roleHeader, roleToken);
UpdateOverallHealthStatus(ref comprehensiveHealthCheckReport);
return comprehensiveHealthCheckReport;
}

// Updates the incoming role header with the appropriate value from the request headers.
public void StoreIncomingRoleHeader(HttpContext httpContext)
// Reads the incoming role and token headers from the request and returns them as local values.
public (string roleHeader, string roleToken) ReadRoleHeaders(HttpContext httpContext)
{
StringValues clientRoleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER];
StringValues clientTokenHeader = httpContext.Request.Headers[AuthenticationOptions.CLIENT_PRINCIPAL_HEADER];
Expand All @@ -75,35 +75,39 @@ public void StoreIncomingRoleHeader(HttpContext httpContext)
throw new ArgumentException("Multiple values for the client role or token header are not allowed.");
}

// Role Header is not present in the request, set it to anonymous.
if (clientRoleHeader.Count == 1)
{
_incomingRoleHeader = clientRoleHeader.ToString().ToLowerInvariant();
}
string roleHeader = clientRoleHeader.Count == 1 ? clientRoleHeader.ToString().ToLowerInvariant() : string.Empty;
string roleToken = clientTokenHeader.Count == 1 ? clientTokenHeader.ToString() : string.Empty;
return (roleHeader, roleToken);
}

if (clientTokenHeader.Count == 1)
{
_incomingRoleToken = clientTokenHeader.ToString();
}
// Returns the effective role for the current request.
// Falls back to "authenticated" if a bearer token is present, or "anonymous" otherwise.
public string GetCurrentRole(string roleHeader, string roleToken)
{
return !string.IsNullOrEmpty(roleHeader)
? roleHeader
: !string.IsNullOrEmpty(roleToken)
? AuthorizationResolver.ROLE_AUTHENTICATED
: AuthorizationResolver.ROLE_ANONYMOUS;
}

/// <summary>
/// Checks if the incoming request is allowed to access the health check endpoint.
/// Anonymous requests are only allowed in Development Mode.
/// </summary>
/// <param name="httpContext">HttpContext to get the headers.</param>
/// <param name="hostMode">Compare with the HostMode of DAB</param>
/// <param name="isDevelopmentMode">Compare with the HostMode of DAB</param>
/// <param name="allowedRoles">AllowedRoles in the Runtime.Health config</param>
/// <param name="roleHeader">The effective role header for the current request.</param>
/// <returns></returns>
public bool IsUserAllowedToAccessHealthCheck(HttpContext httpContext, bool isDevelopmentMode, HashSet<string> allowedRoles)
public bool IsUserAllowedToAccessHealthCheck(bool isDevelopmentMode, HashSet<string> allowedRoles, string roleHeader)
{
if (allowedRoles == null || allowedRoles.Count == 0)
{
// When allowedRoles is null or empty, all roles are allowed if Mode = Development.
return isDevelopmentMode;
}

return allowedRoles.Contains(_incomingRoleHeader);
return allowedRoles.Contains(roleHeader);
}

// Updates the overall status by comparing all the internal HealthStatuses in the response.
Expand Down Expand Up @@ -149,11 +153,11 @@ private static void UpdateDabConfigurationDetails(ref ComprehensiveHealthCheckRe
}

// Main function to internally call for data source and entities health check.
private async Task UpdateHealthCheckDetailsAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport, RuntimeConfig runtimeConfig)
private async Task UpdateHealthCheckDetailsAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport, RuntimeConfig runtimeConfig, string roleHeader, string roleToken)
{
comprehensiveHealthCheckReport.Checks = new List<HealthCheckResultEntry>();
await UpdateDataSourceHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig);
await UpdateEntityHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig);
await UpdateEntityHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig, roleHeader, roleToken);
}

// Updates the DataSource Health Check Results in the response.
Expand Down Expand Up @@ -200,7 +204,7 @@ private async Task UpdateDataSourceHealthCheckResultsAsync(ComprehensiveHealthCh
// Updates the Entity Health Check Results in the response.
// Goes through the entities one by one and executes the rest and graphql checks (if enabled).
// Stored procedures are excluded from health checks because they require parameters and are not guaranteed to be deterministic.
private async Task UpdateEntityHealthCheckResultsAsync(ComprehensiveHealthCheckReport report, RuntimeConfig runtimeConfig)
private async Task UpdateEntityHealthCheckResultsAsync(ComprehensiveHealthCheckReport report, RuntimeConfig runtimeConfig, string roleHeader, string roleToken)
{
List<KeyValuePair<string, Entity>> enabledEntities = runtimeConfig.Entities.Entities
.Where(e => e.Value.IsEntityHealthEnabled && e.Value.Source.Type != EntitySourceType.StoredProcedure)
Expand Down Expand Up @@ -232,7 +236,7 @@ private async Task UpdateEntityHealthCheckResultsAsync(ComprehensiveHealthCheckR
Checks = new List<HealthCheckResultEntry>()
};

await PopulateEntityHealthAsync(localReport, entity, runtimeConfig);
await PopulateEntityHealthAsync(localReport, entity, runtimeConfig, roleHeader, roleToken);

if (localReport.Checks != null)
{
Expand All @@ -255,7 +259,7 @@ private async Task UpdateEntityHealthCheckResultsAsync(ComprehensiveHealthCheckR
// Populates the Entity Health Check Results in the response for a particular entity.
// Checks for Rest enabled and executes the rest query.
// Checks for GraphQL enabled and executes the graphql query.
private async Task PopulateEntityHealthAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport, KeyValuePair<string, Entity> entity, RuntimeConfig runtimeConfig)
private async Task PopulateEntityHealthAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport, KeyValuePair<string, Entity> entity, RuntimeConfig runtimeConfig, string roleHeader, string roleToken)
{
// Global Rest and GraphQL Runtime Options
RuntimeOptions? runtimeOptions = runtimeConfig.Runtime;
Expand All @@ -274,7 +278,7 @@ private async Task PopulateEntityHealthAsync(ComprehensiveHealthCheckReport comp
// The path is trimmed to remove the leading '/' character.
// If the path is not present, use the entity key name as the path.
string entityPath = entityValue.Rest.Path != null ? entityValue.Rest.Path.TrimStart('/') : entityKeyName;
(int, string?) response = await ExecuteRestEntityQueryAsync(runtimeConfig.RestPath, entityPath, entityValue.EntityFirst);
(int, string?) response = await ExecuteRestEntityQueryAsync(runtimeConfig.RestPath, entityPath, entityValue.EntityFirst, roleHeader, roleToken);
bool isResponseTimeWithinThreshold = response.Item1 >= 0 && response.Item1 < entityValue.EntityThresholdMs;

// Add Entity Health Check Results
Expand All @@ -296,7 +300,7 @@ private async Task PopulateEntityHealthAsync(ComprehensiveHealthCheckReport comp
{
comprehensiveHealthCheckReport.Checks ??= new List<HealthCheckResultEntry>();

(int, string?) response = await ExecuteGraphQlEntityQueryAsync(runtimeConfig.GraphQLPath, entityValue, entityKeyName);
(int, string?) response = await ExecuteGraphQlEntityQueryAsync(runtimeConfig.GraphQLPath, entityValue, entityKeyName, roleHeader, roleToken);
bool isResponseTimeWithinThreshold = response.Item1 >= 0 && response.Item1 < entityValue.EntityThresholdMs;

comprehensiveHealthCheckReport.Checks.Add(new HealthCheckResultEntry
Expand All @@ -316,14 +320,14 @@ private async Task PopulateEntityHealthAsync(ComprehensiveHealthCheckReport comp
}

// Executes the Rest Entity Query and keeps track of the response time and error message.
private async Task<(int, string?)> ExecuteRestEntityQueryAsync(string restUriSuffix, string entityName, int first)
private async Task<(int, string?)> ExecuteRestEntityQueryAsync(string restUriSuffix, string entityName, int first, string roleHeader, string roleToken)
{
string? errorMessage = null;
if (!string.IsNullOrEmpty(entityName))
{
Stopwatch stopwatch = new();
stopwatch.Start();
errorMessage = await _httpUtility.ExecuteRestQueryAsync(restUriSuffix, entityName, first, _incomingRoleHeader, _incomingRoleToken);
errorMessage = await _httpUtility.ExecuteRestQueryAsync(restUriSuffix, entityName, first, roleHeader, roleToken);
stopwatch.Stop();
return string.IsNullOrEmpty(errorMessage) ? ((int)stopwatch.ElapsedMilliseconds, errorMessage) : (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage);
}
Expand All @@ -332,14 +336,14 @@ private async Task PopulateEntityHealthAsync(ComprehensiveHealthCheckReport comp
}

// Executes the GraphQL Entity Query and keeps track of the response time and error message.
private async Task<(int, string?)> ExecuteGraphQlEntityQueryAsync(string graphqlUriSuffix, Entity entity, string entityName)
private async Task<(int, string?)> ExecuteGraphQlEntityQueryAsync(string graphqlUriSuffix, Entity entity, string entityName, string roleHeader, string roleToken)
{
string? errorMessage = null;
if (entity != null)
{
Stopwatch stopwatch = new();
stopwatch.Start();
errorMessage = await _httpUtility.ExecuteGraphQLQueryAsync(graphqlUriSuffix, entityName, entity, _incomingRoleHeader, _incomingRoleToken);
errorMessage = await _httpUtility.ExecuteGraphQLQueryAsync(graphqlUriSuffix, entityName, entity, roleHeader, roleToken);
stopwatch.Stop();
return string.IsNullOrEmpty(errorMessage) ? ((int)stopwatch.ElapsedMilliseconds, errorMessage) : (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ public record ComprehensiveHealthCheckReport
[JsonPropertyName("timestamp")]
public DateTime TimeStamp { get; set; }

/// <summary>
/// The current role of the user making the request (e.g., "anonymous", "authenticated").
/// </summary>
[JsonPropertyName("currentRole")]
public string? CurrentRole { get; set; }

/// <summary>
/// The configuration details of the dab service.
/// </summary>
Expand Down