diff --git a/src/Service.Tests/UnitTests/HealthCheckUtilitiesUnitTests.cs b/src/Service.Tests/UnitTests/HealthCheckUtilitiesUnitTests.cs
index b94e936f5a..0f9fcf2f5c 100644
--- a/src/Service.Tests/UnitTests/HealthCheckUtilitiesUnitTests.cs
+++ b/src/Service.Tests/UnitTests/HealthCheckUtilitiesUnitTests.cs
@@ -4,7 +4,12 @@
#nullable enable
using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Authorization;
+using Azure.DataApiBuilder.Service.HealthCheck;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
@@ -150,5 +155,151 @@ public void NormalizeConnectionString_EmptyString_ReturnsEmpty()
// Assert
Assert.AreEqual(string.Empty, result);
}
+ ///
+ /// Tests that GetCurrentRole returns "anonymous" when no auth headers are present.
+ ///
+ [TestMethod]
+ public void GetCurrentRole_NoHeaders_ReturnsAnonymous()
+ {
+ HealthCheckHelper helper = CreateHelper();
+ string role = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: string.Empty);
+ Assert.AreEqual(AuthorizationResolver.ROLE_ANONYMOUS, role);
+ }
+
+ ///
+ /// Tests that GetCurrentRole returns "authenticated" when a bearer token is present but no role header is supplied.
+ ///
+ [TestMethod]
+ public void GetCurrentRole_BearerTokenOnly_ReturnsAuthenticated()
+ {
+ HealthCheckHelper helper = CreateHelper();
+ string role = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: "some-bearer-token");
+ Assert.AreEqual(AuthorizationResolver.ROLE_AUTHENTICATED, role);
+ }
+
+ ///
+ /// Tests that GetCurrentRole returns the explicit role value when the X-MS-API-ROLE header is provided.
+ ///
+ [TestMethod]
+ [DataRow("anonymous", DisplayName = "Explicit anonymous role header")]
+ [DataRow("authenticated", DisplayName = "Explicit authenticated role header")]
+ [DataRow("customrole", DisplayName = "Custom role header")]
+ public void GetCurrentRole_ExplicitRoleHeader_ReturnsHeaderValue(string explicitRole)
+ {
+ HealthCheckHelper helper = CreateHelper();
+ string role = helper.GetCurrentRole(roleHeader: explicitRole, roleToken: string.Empty);
+ Assert.AreEqual(explicitRole, role);
+ }
+
+ ///
+ /// Tests that the role header takes priority over the bearer token when both are present.
+ ///
+ [TestMethod]
+ public void GetCurrentRole_BothHeaderAndToken_RoleHeaderWins()
+ {
+ HealthCheckHelper helper = CreateHelper();
+ string role = helper.GetCurrentRole(roleHeader: "customrole", roleToken: "some-bearer-token");
+ Assert.AreEqual("customrole", role);
+ }
+
+ ///
+ /// Tests that ReadRoleHeaders correctly reads X-MS-API-ROLE from the request.
+ ///
+ [TestMethod]
+ public void ReadRoleHeaders_WithRoleHeader_ReturnsRoleHeader()
+ {
+ HealthCheckHelper helper = CreateHelper();
+ DefaultHttpContext context = new();
+ context.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = "myrole";
+
+ (string roleHeader, string roleToken) = helper.ReadRoleHeaders(context);
+
+ Assert.AreEqual("myrole", roleHeader);
+ Assert.AreEqual(string.Empty, roleToken);
+ }
+
+ ///
+ /// Tests that ReadRoleHeaders returns empty strings when no headers are present.
+ ///
+ [TestMethod]
+ public void ReadRoleHeaders_NoHeaders_ReturnsEmpty()
+ {
+ HealthCheckHelper helper = CreateHelper();
+ DefaultHttpContext context = new();
+
+ (string roleHeader, string roleToken) = helper.ReadRoleHeaders(context);
+
+ Assert.AreEqual(string.Empty, roleHeader);
+ Assert.AreEqual(string.Empty, roleToken);
+ }
+
+ ///
+ /// Tests that the cached health response does not reuse a previous caller's currentRole.
+ /// GetCurrentRole is a pure function: same input always produces same output,
+ /// and different inputs (representing different callers) produce different outputs.
+ ///
+ [TestMethod]
+ public void GetCurrentRole_CacheDoesNotLeakRole_DifferentCallersGetDifferentRoles()
+ {
+ HealthCheckHelper helper = CreateHelper();
+
+ // Simulate request 1 (anonymous, no headers)
+ string role1 = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: string.Empty);
+
+ // Simulate request 2 (authenticated, with bearer token)
+ string role2 = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: "bearer-token");
+
+ // Simulate request 3 (explicit custom role)
+ string role3 = helper.GetCurrentRole(roleHeader: "adminrole", roleToken: string.Empty);
+
+ Assert.AreEqual(AuthorizationResolver.ROLE_ANONYMOUS, role1);
+ Assert.AreEqual(AuthorizationResolver.ROLE_AUTHENTICATED, role2);
+ Assert.AreEqual("adminrole", role3);
+ }
+
+ ///
+ /// Tests that parallel calls to GetCurrentRole with different roles do not bleed values across calls.
+ /// Validates the singleton-safe design (no shared mutable state).
+ ///
+ [TestMethod]
+ public async Task GetCurrentRole_ParallelRequests_NoRoleBleed()
+ {
+ HealthCheckHelper helper = CreateHelper();
+
+ // Run many parallel "requests" each with a unique role
+ int parallelCount = 50;
+ string[] expectedRoles = new string[parallelCount];
+ string[] actualRoles = new string[parallelCount];
+
+ for (int i = 0; i < parallelCount; i++)
+ {
+ expectedRoles[i] = $"role-{i}";
+ }
+
+ List tasks = new();
+ for (int i = 0; i < parallelCount; i++)
+ {
+ int index = i;
+ tasks.Add(Task.Run(() =>
+ {
+ actualRoles[index] = helper.GetCurrentRole(roleHeader: expectedRoles[index], roleToken: string.Empty);
+ }));
+ }
+
+ await Task.WhenAll(tasks);
+
+ for (int i = 0; i < parallelCount; i++)
+ {
+ Assert.AreEqual(expectedRoles[i], actualRoles[i], $"Role bleed detected at index {i}: expected '{expectedRoles[i]}' but got '{actualRoles[i]}'");
+ }
+ }
+
+ private static HealthCheckHelper CreateHelper()
+ {
+ Mock> loggerMock = new();
+ // HttpUtilities is not invoked by the methods under test (GetCurrentRole, ReadRoleHeaders),
+ // so passing null is safe here.
+ return new HealthCheckHelper(loggerMock.Object, null!);
+ }
}
}
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.
///