diff --git a/src/Service/HealthCheck/ComprehensiveHealthReportResponseWriter.cs b/src/Service/HealthCheck/ComprehensiveHealthReportResponseWriter.cs index 2555890791..5027c5e059 100644 --- a/src/Service/HealthCheck/ComprehensiveHealthReportResponseWriter.cs +++ b/src/Service/HealthCheck/ComprehensiveHealthReportResponseWriter.cs @@ -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; @@ -76,8 +75,8 @@ 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; @@ -85,34 +84,33 @@ public async Task WriteResponseAsync(HttpContext context) return; } - string? response; // Check if the cache is enabled if (config.CacheTtlSecondsForHealthReport > 0) { + ComprehensiveHealthCheckReport? report = null; try { - response = await _cache.GetOrSetAsync( + report = await _cache.GetOrSetAsync( key: CACHE_KEY, - async (FusionCacheFactoryExecutionContext ctx, CancellationToken ct) => + async (FusionCacheFactoryExecutionContext 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 { @@ -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 @@ -139,13 +137,10 @@ public async Task WriteResponseAsync(HttpContext context) return; } - private async Task 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 }); } } } diff --git a/src/Service/HealthCheck/HealthCheckHelper.cs b/src/Service/HealthCheck/HealthCheckHelper.cs index 991e39983f..2a5f6f5ddf 100644 --- a/src/Service/HealthCheck/HealthCheckHelper.cs +++ b/src/Service/HealthCheck/HealthCheckHelper.cs @@ -27,8 +27,6 @@ public class HealthCheckHelper // Dependencies private ILogger _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."; @@ -48,8 +46,10 @@ public HealthCheckHelper(ILogger logger, HttpUtilities httpUt /// Serializes the report to JSON and returns the response. /// /// RuntimeConfig + /// The effective role header for the current request. + /// The bearer token for the current request. /// This function returns the comprehensive health report after calculating the response time of each datasource, rest and graphql health queries. - public async Task GetHealthCheckResponseAsync(RuntimeConfig runtimeConfig) + public async Task 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. @@ -59,13 +59,13 @@ public async Task 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]; @@ -75,27 +75,31 @@ 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; } /// /// Checks if the incoming request is allowed to access the health check endpoint. /// Anonymous requests are only allowed in Development Mode. /// - /// HttpContext to get the headers. - /// Compare with the HostMode of DAB + /// Compare with the HostMode of DAB /// AllowedRoles in the Runtime.Health config + /// The effective role header for the current request. /// - public bool IsUserAllowedToAccessHealthCheck(HttpContext httpContext, bool isDevelopmentMode, HashSet allowedRoles) + public bool IsUserAllowedToAccessHealthCheck(bool isDevelopmentMode, HashSet allowedRoles, string roleHeader) { if (allowedRoles == null || allowedRoles.Count == 0) { @@ -103,7 +107,7 @@ public bool IsUserAllowedToAccessHealthCheck(HttpContext httpContext, bool isDev return isDevelopmentMode; } - return allowedRoles.Contains(_incomingRoleHeader); + return allowedRoles.Contains(roleHeader); } // Updates the overall status by comparing all the internal HealthStatuses in the response. @@ -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(); await UpdateDataSourceHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig); - await UpdateEntityHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig); + await UpdateEntityHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig, roleHeader, roleToken); } // Updates the DataSource Health Check Results in the response. @@ -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> enabledEntities = runtimeConfig.Entities.Entities .Where(e => e.Value.IsEntityHealthEnabled && e.Value.Source.Type != EntitySourceType.StoredProcedure) @@ -232,7 +236,7 @@ private async Task UpdateEntityHealthCheckResultsAsync(ComprehensiveHealthCheckR Checks = new List() }; - await PopulateEntityHealthAsync(localReport, entity, runtimeConfig); + await PopulateEntityHealthAsync(localReport, entity, runtimeConfig, roleHeader, roleToken); if (localReport.Checks != null) { @@ -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 entity, RuntimeConfig runtimeConfig) + private async Task PopulateEntityHealthAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport, KeyValuePair entity, RuntimeConfig runtimeConfig, string roleHeader, string roleToken) { // Global Rest and GraphQL Runtime Options RuntimeOptions? runtimeOptions = runtimeConfig.Runtime; @@ -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 @@ -296,7 +300,7 @@ private async Task PopulateEntityHealthAsync(ComprehensiveHealthCheckReport comp { comprehensiveHealthCheckReport.Checks ??= new List(); - (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 @@ -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); } @@ -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); } diff --git a/src/Service/HealthCheck/Model/ComprehensiveHealthCheckReport.cs b/src/Service/HealthCheck/Model/ComprehensiveHealthCheckReport.cs index b649a6bfc7..26a260af47 100644 --- a/src/Service/HealthCheck/Model/ComprehensiveHealthCheckReport.cs +++ b/src/Service/HealthCheck/Model/ComprehensiveHealthCheckReport.cs @@ -43,6 +43,12 @@ public record ComprehensiveHealthCheckReport [JsonPropertyName("timestamp")] public DateTime TimeStamp { get; set; } + /// + /// The current role of the user making the request (e.g., "anonymous", "authenticated"). + /// + [JsonPropertyName("currentRole")] + public string? CurrentRole { get; set; } + /// /// The configuration details of the dab service. ///