Skip to content

Commit bf18263

Browse files
CopilotJerryNixonCopilotAniruddh25
authored
Add currentRole to Health Summary Report at /health (#3158)
## Why make this change? Adds `currentRole` to the `/health` endpoint response to surface the effective role of the requesting user, enabling easier authentication testing without needing external tooling to inspect request headers. ## What is this change? - **`ComprehensiveHealthCheckReport`**: New `currentRole` string property (JSON: `"currentRole"`) inserted after `timestamp` in the report header. - **`HealthCheckHelper.ReadRoleHeaders(HttpContext)`**: Replaces the former `StoreIncomingRoleHeader` method. Instead of writing to mutable instance fields on the singleton, it returns a `(roleHeader, roleToken)` tuple that the caller holds as request-local values, eliminating a race condition under concurrent requests. - **`HealthCheckHelper.GetCurrentRole(roleHeader, roleToken)`**: Determines the effective role using a three-way fallback: explicit `X-MS-API-ROLE` header → `"authenticated"` (if a bearer token is present via `X-MS-CLIENT-PRINCIPAL`) → `"anonymous"`. This correctly handles authenticated users who provide a bearer token without an explicit role header. - **`HealthCheckHelper.IsUserAllowedToAccessHealthCheck` / `GetHealthCheckResponseAsync`**: Updated to accept `roleHeader`/`roleToken` as explicit parameters; the values flow down the entire private call chain (`UpdateHealthCheckDetailsAsync` → `UpdateEntityHealthCheckResultsAsync` → `PopulateEntityHealthAsync` → `ExecuteRestEntityQueryAsync` / `ExecuteGraphQlEntityQueryAsync`) so no per-request state is stored on the singleton. - **`ComprehensiveHealthReportResponseWriter`**: Reads role headers once per request into locals, passes them through all downstream calls, and stamps `currentRole` per-request **after** cache retrieval (using a non-destructive `with` expression) so cached responses never leak one caller's role to another request. The cache now stores the `ComprehensiveHealthCheckReport` object (without `currentRole`) rather than a serialized string. > **Access control note**: `IsUserAllowedToAccessHealthCheck` checks `allowedRoles` against the explicit `X-MS-API-ROLE` header value only — matching DAB's existing authorization policy where clients must explicitly claim a role via the header. Example response shape: ```json { "status": "Healthy", "version": "1.x.x", "app-name": "dab_oss_1.x.x", "timestamp": "2026-02-24T22:28:30Z", "currentRole": "authenticated", "configuration": { ... }, "checks": [ ... ] } ``` ## How was this tested? - [ ] Integration Tests - [x] Unit Tests Unit tests added to `HealthCheckUtilitiesUnitTests.cs` covering all key scenarios: - `GetCurrentRole_NoHeaders_ReturnsAnonymous` — no auth headers → `anonymous` - `GetCurrentRole_BearerTokenOnly_ReturnsAuthenticated` — bearer token, no role header → `authenticated` - `GetCurrentRole_ExplicitRoleHeader_ReturnsHeaderValue` — explicit `X-MS-API-ROLE` → exact header value - `GetCurrentRole_BothHeaderAndToken_RoleHeaderWins` — role header takes priority over bearer token - `GetCurrentRole_CacheDoesNotLeakRole_DifferentCallersGetDifferentRoles` — sequential calls with different inputs each return the correct role, confirming no cross-request state - `GetCurrentRole_ParallelRequests_NoRoleBleed` — 50 concurrent tasks each with a unique role return exactly their own role with no bleed - `ReadRoleHeaders_WithRoleHeader_ReturnsRoleHeader` / `ReadRoleHeaders_NoHeaders_ReturnsEmpty` — validates header extraction ## Sample Request(s) ```http GET /health X-MS-API-ROLE: myrole ``` Response includes `"currentRole": "myrole"`. ```http GET /health X-MS-CLIENT-PRINCIPAL: <base64-token> ``` Response includes `"currentRole": "authenticated"` (bearer token present, no explicit role header). ```http GET /health ``` Response includes `"currentRole": "anonymous"` (no role header, no bearer token). <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>[Enh]: Add to Health Summary</issue_title> > <issue_description>## What? > > 1. Add to the header part of the Health Summary Report returned at /health a scalar property "currentRole" which returns the current role "anonymous", "authenticated", etc. > > ## Why? > > To help with authentication testing.</issue_description> > > <agent_instructions>with as little new code as possible</agent_instructions> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #3157 <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: JerryNixon <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Aniruddh Munde <[email protected]>
1 parent a7c1be3 commit bf18263

4 files changed

Lines changed: 206 additions & 50 deletions

File tree

src/Service.Tests/UnitTests/HealthCheckUtilitiesUnitTests.cs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
#nullable enable
55

66
using System;
7+
using System.Collections.Generic;
8+
using System.Threading.Tasks;
79
using Azure.DataApiBuilder.Config.ObjectModel;
10+
using Azure.DataApiBuilder.Core.Authorization;
11+
using Azure.DataApiBuilder.Service.HealthCheck;
12+
using Microsoft.AspNetCore.Http;
813
using Microsoft.Extensions.Logging;
914
using Microsoft.VisualStudio.TestTools.UnitTesting;
1015
using Moq;
@@ -150,5 +155,151 @@ public void NormalizeConnectionString_EmptyString_ReturnsEmpty()
150155
// Assert
151156
Assert.AreEqual(string.Empty, result);
152157
}
158+
/// <summary>
159+
/// Tests that GetCurrentRole returns "anonymous" when no auth headers are present.
160+
/// </summary>
161+
[TestMethod]
162+
public void GetCurrentRole_NoHeaders_ReturnsAnonymous()
163+
{
164+
HealthCheckHelper helper = CreateHelper();
165+
string role = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: string.Empty);
166+
Assert.AreEqual(AuthorizationResolver.ROLE_ANONYMOUS, role);
167+
}
168+
169+
/// <summary>
170+
/// Tests that GetCurrentRole returns "authenticated" when a bearer token is present but no role header is supplied.
171+
/// </summary>
172+
[TestMethod]
173+
public void GetCurrentRole_BearerTokenOnly_ReturnsAuthenticated()
174+
{
175+
HealthCheckHelper helper = CreateHelper();
176+
string role = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: "some-bearer-token");
177+
Assert.AreEqual(AuthorizationResolver.ROLE_AUTHENTICATED, role);
178+
}
179+
180+
/// <summary>
181+
/// Tests that GetCurrentRole returns the explicit role value when the X-MS-API-ROLE header is provided.
182+
/// </summary>
183+
[TestMethod]
184+
[DataRow("anonymous", DisplayName = "Explicit anonymous role header")]
185+
[DataRow("authenticated", DisplayName = "Explicit authenticated role header")]
186+
[DataRow("customrole", DisplayName = "Custom role header")]
187+
public void GetCurrentRole_ExplicitRoleHeader_ReturnsHeaderValue(string explicitRole)
188+
{
189+
HealthCheckHelper helper = CreateHelper();
190+
string role = helper.GetCurrentRole(roleHeader: explicitRole, roleToken: string.Empty);
191+
Assert.AreEqual(explicitRole, role);
192+
}
193+
194+
/// <summary>
195+
/// Tests that the role header takes priority over the bearer token when both are present.
196+
/// </summary>
197+
[TestMethod]
198+
public void GetCurrentRole_BothHeaderAndToken_RoleHeaderWins()
199+
{
200+
HealthCheckHelper helper = CreateHelper();
201+
string role = helper.GetCurrentRole(roleHeader: "customrole", roleToken: "some-bearer-token");
202+
Assert.AreEqual("customrole", role);
203+
}
204+
205+
/// <summary>
206+
/// Tests that ReadRoleHeaders correctly reads X-MS-API-ROLE from the request.
207+
/// </summary>
208+
[TestMethod]
209+
public void ReadRoleHeaders_WithRoleHeader_ReturnsRoleHeader()
210+
{
211+
HealthCheckHelper helper = CreateHelper();
212+
DefaultHttpContext context = new();
213+
context.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = "myrole";
214+
215+
(string roleHeader, string roleToken) = helper.ReadRoleHeaders(context);
216+
217+
Assert.AreEqual("myrole", roleHeader);
218+
Assert.AreEqual(string.Empty, roleToken);
219+
}
220+
221+
/// <summary>
222+
/// Tests that ReadRoleHeaders returns empty strings when no headers are present.
223+
/// </summary>
224+
[TestMethod]
225+
public void ReadRoleHeaders_NoHeaders_ReturnsEmpty()
226+
{
227+
HealthCheckHelper helper = CreateHelper();
228+
DefaultHttpContext context = new();
229+
230+
(string roleHeader, string roleToken) = helper.ReadRoleHeaders(context);
231+
232+
Assert.AreEqual(string.Empty, roleHeader);
233+
Assert.AreEqual(string.Empty, roleToken);
234+
}
235+
236+
/// <summary>
237+
/// Tests that the cached health response does not reuse a previous caller's currentRole.
238+
/// GetCurrentRole is a pure function: same input always produces same output,
239+
/// and different inputs (representing different callers) produce different outputs.
240+
/// </summary>
241+
[TestMethod]
242+
public void GetCurrentRole_CacheDoesNotLeakRole_DifferentCallersGetDifferentRoles()
243+
{
244+
HealthCheckHelper helper = CreateHelper();
245+
246+
// Simulate request 1 (anonymous, no headers)
247+
string role1 = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: string.Empty);
248+
249+
// Simulate request 2 (authenticated, with bearer token)
250+
string role2 = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: "bearer-token");
251+
252+
// Simulate request 3 (explicit custom role)
253+
string role3 = helper.GetCurrentRole(roleHeader: "adminrole", roleToken: string.Empty);
254+
255+
Assert.AreEqual(AuthorizationResolver.ROLE_ANONYMOUS, role1);
256+
Assert.AreEqual(AuthorizationResolver.ROLE_AUTHENTICATED, role2);
257+
Assert.AreEqual("adminrole", role3);
258+
}
259+
260+
/// <summary>
261+
/// Tests that parallel calls to GetCurrentRole with different roles do not bleed values across calls.
262+
/// Validates the singleton-safe design (no shared mutable state).
263+
/// </summary>
264+
[TestMethod]
265+
public async Task GetCurrentRole_ParallelRequests_NoRoleBleed()
266+
{
267+
HealthCheckHelper helper = CreateHelper();
268+
269+
// Run many parallel "requests" each with a unique role
270+
int parallelCount = 50;
271+
string[] expectedRoles = new string[parallelCount];
272+
string[] actualRoles = new string[parallelCount];
273+
274+
for (int i = 0; i < parallelCount; i++)
275+
{
276+
expectedRoles[i] = $"role-{i}";
277+
}
278+
279+
List<Task> tasks = new();
280+
for (int i = 0; i < parallelCount; i++)
281+
{
282+
int index = i;
283+
tasks.Add(Task.Run(() =>
284+
{
285+
actualRoles[index] = helper.GetCurrentRole(roleHeader: expectedRoles[index], roleToken: string.Empty);
286+
}));
287+
}
288+
289+
await Task.WhenAll(tasks);
290+
291+
for (int i = 0; i < parallelCount; i++)
292+
{
293+
Assert.AreEqual(expectedRoles[i], actualRoles[i], $"Role bleed detected at index {i}: expected '{expectedRoles[i]}' but got '{actualRoles[i]}'");
294+
}
295+
}
296+
297+
private static HealthCheckHelper CreateHelper()
298+
{
299+
Mock<ILogger<HealthCheckHelper>> loggerMock = new();
300+
// HttpUtilities is not invoked by the methods under test (GetCurrentRole, ReadRoleHeaders),
301+
// so passing null is safe here.
302+
return new HealthCheckHelper(loggerMock.Object, null!);
303+
}
153304
}
154305
}

src/Service/HealthCheck/ComprehensiveHealthReportResponseWriter.cs

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Licensed under the MIT License.
33

44
using System;
5-
using System.IO;
65
using System.Text.Json;
76
using System.Threading;
87
using System.Threading.Tasks;
@@ -76,43 +75,42 @@ public async Task WriteResponseAsync(HttpContext context)
7675
// Global comprehensive Health Check Enabled
7776
if (config.IsHealthEnabled)
7877
{
79-
_healthCheckHelper.StoreIncomingRoleHeader(context);
80-
if (!_healthCheckHelper.IsUserAllowedToAccessHealthCheck(context, config.IsDevelopmentMode(), config.AllowedRolesForHealth))
78+
(string roleHeader, string roleToken) = _healthCheckHelper.ReadRoleHeaders(context);
79+
if (!_healthCheckHelper.IsUserAllowedToAccessHealthCheck(config.IsDevelopmentMode(), config.AllowedRolesForHealth, roleHeader))
8180
{
8281
_logger.LogError("Comprehensive Health Check Report is not allowed: 403 Forbidden due to insufficient permissions.");
8382
context.Response.StatusCode = StatusCodes.Status403Forbidden;
8483
await context.Response.CompleteAsync();
8584
return;
8685
}
8786

88-
string? response;
8987
// Check if the cache is enabled
9088
if (config.CacheTtlSecondsForHealthReport > 0)
9189
{
90+
ComprehensiveHealthCheckReport? report = null;
9291
try
9392
{
94-
response = await _cache.GetOrSetAsync<string?>(
93+
report = await _cache.GetOrSetAsync<ComprehensiveHealthCheckReport?>(
9594
key: CACHE_KEY,
96-
async (FusionCacheFactoryExecutionContext<string?> ctx, CancellationToken ct) =>
95+
async (FusionCacheFactoryExecutionContext<ComprehensiveHealthCheckReport?> ctx, CancellationToken ct) =>
9796
{
98-
string? response = await ExecuteHealthCheckAsync(config).ConfigureAwait(false);
97+
ComprehensiveHealthCheckReport? r = await _healthCheckHelper.GetHealthCheckResponseAsync(config, roleHeader, roleToken).ConfigureAwait(false);
9998
ctx.Options.SetDuration(TimeSpan.FromSeconds(config.CacheTtlSecondsForHealthReport));
100-
return response;
99+
return r;
101100
});
102101

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

111109
// Ensure cachedResponse is not null before calling WriteAsync
112-
if (response != null)
110+
if (report != null)
113111
{
114-
// Return the cached or newly generated response
115-
await context.Response.WriteAsync(response);
112+
// Set currentRole per-request (not cached) so each caller sees their own role
113+
await context.Response.WriteAsync(SerializeReport(report with { CurrentRole = _healthCheckHelper.GetCurrentRole(roleHeader, roleToken) }));
116114
}
117115
else
118116
{
@@ -124,9 +122,9 @@ public async Task WriteResponseAsync(HttpContext context)
124122
}
125123
else
126124
{
127-
response = await ExecuteHealthCheckAsync(config).ConfigureAwait(false);
125+
ComprehensiveHealthCheckReport report = await _healthCheckHelper.GetHealthCheckResponseAsync(config, roleHeader, roleToken).ConfigureAwait(false);
128126
// Return the newly generated response
129-
await context.Response.WriteAsync(response);
127+
await context.Response.WriteAsync(SerializeReport(report with { CurrentRole = _healthCheckHelper.GetCurrentRole(roleHeader, roleToken) }));
130128
}
131129
}
132130
else
@@ -139,13 +137,10 @@ public async Task WriteResponseAsync(HttpContext context)
139137
return;
140138
}
141139

142-
private async Task<string> ExecuteHealthCheckAsync(RuntimeConfig config)
140+
private string SerializeReport(ComprehensiveHealthCheckReport report)
143141
{
144-
ComprehensiveHealthCheckReport dabHealthCheckReport = await _healthCheckHelper.GetHealthCheckResponseAsync(config);
145-
string response = JsonSerializer.Serialize(dabHealthCheckReport, options: new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
146-
_logger.LogTrace($"Health check response writer writing status as: {dabHealthCheckReport.Status}");
147-
148-
return response;
142+
_logger.LogTrace($"Health check response writer writing status as: {report.Status}");
143+
return JsonSerializer.Serialize(report, options: new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
149144
}
150145
}
151146
}

0 commit comments

Comments
 (0)