Skip to content

Allow access to health endpoint as per defined roles #2632

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 66 commits into from
Apr 4, 2025
Merged
Show file tree
Hide file tree
Changes from 59 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
ee1561d
Modify Config Files according to health check endpoint
Feb 26, 2025
7a1734d
formatting
Feb 26, 2025
49daea3
modify the if condition for comprehensive check
sezal98 Feb 27, 2025
95e4777
Initial commit
Mar 3, 2025
374ce72
resolving comments
Mar 3, 2025
e2b23c4
Merge branch 'dev/sezalchug/healthConfigFiles' of https://github.com/…
Mar 3, 2025
ab2258b
formatting
Mar 4, 2025
6125132
Merge branch 'dev/sezalchug/healthConfigFiles' into dev/sezalchug/hea…
Mar 4, 2025
c7f0823
merging into config changes
Mar 4, 2025
9b6de0b
UTs
Mar 4, 2025
e95d6ae
nit changes and UTs and default value
Mar 11, 2025
e8cd0b5
formatting
Mar 11, 2025
74042e1
response file sample
Mar 11, 2025
e1d51aa
based on discussions in the meeting
Mar 13, 2025
8207dc5
nits
Mar 17, 2025
b7e7218
snapshots
Mar 17, 2025
521221f
Merge branch 'main' of https://github.com/Azure/data-api-builder into…
Mar 17, 2025
b574648
remove value
Mar 17, 2025
cb95c11
format
Mar 17, 2025
9ae3150
build
Mar 17, 2025
b7fbb77
tests
Mar 18, 2025
daa4742
nit comments
Mar 18, 2025
80ab25c
revert add entity snapshots
Mar 20, 2025
c5eb328
module initializer
Mar 20, 2025
bc2adb8
update entity tests revert
Mar 20, 2025
5fc5cc6
end to end tests
Mar 20, 2025
433010d
serialization and deserialization changes
Mar 20, 2025
4cb4fb0
nits
Mar 20, 2025
2c20d1a
Role changes in Health endpoint
Mar 24, 2025
ed07ac0
formatting
Mar 24, 2025
92b23f8
test add jsonignore
Mar 24, 2025
705c6ac
modify modulerinitializer
Mar 24, 2025
8d6e2ed
resolving comments
Mar 24, 2025
c025a6d
add test case to check individual value of true/false
Mar 24, 2025
29d05f0
remove writing null values
Mar 24, 2025
131e1c1
formatting
Mar 24, 2025
176958b
changes with respect to roles
Mar 24, 2025
30e9763
revert datasource default
Mar 24, 2025
6cbeeec
revert snapshots
Mar 24, 2025
ca025dc
add space
Mar 24, 2025
7630986
revert space
Mar 24, 2025
39cbfba
try with encoding
Mar 24, 2025
fa03ded
formatting
Mar 24, 2025
06094c4
formatting
Mar 24, 2025
7b79233
nits
Mar 25, 2025
08f746e
Merge branch 'dev/sezalchug/healthCheckExecution' into dev/sezalchug/…
Mar 25, 2025
f5a999f
UTs
Mar 25, 2025
d56b0c6
formatting
Mar 25, 2025
37612c8
edit error message
Mar 27, 2025
c08a2ae
resolving comments
Mar 28, 2025
8dcce5d
formatting
Mar 28, 2025
5716ab3
resolving comnmnets
Mar 28, 2025
9a19993
formatting
Mar 28, 2025
650b284
error msg
Mar 31, 2025
48bf947
Merge branch 'dev/sezalchug/healthCheckExecution' into dev/sezalchug/…
Mar 31, 2025
404897d
nit comments
Mar 31, 2025
b3bad84
Merge branch 'main' of https://github.com/Azure/data-api-builder into…
Mar 31, 2025
21e4bcd
nits
Mar 31, 2025
94cccae
capital variable
Apr 1, 2025
7bad17f
resolving comments
Apr 3, 2025
3cbe2a5
add test case
Apr 4, 2025
d3190a2
resolving comments
Apr 4, 2025
4844e81
Merge branch 'main' of https://github.com/Azure/data-api-builder into…
Apr 4, 2025
d9fe6e8
Merge branch 'main' into dev/sezalchug/rolesHealthCheck
Aniruddh25 Apr 4, 2025
558b430
Fixed formatting issues
Apr 4, 2025
24ed5bc
Merge branch 'main' into dev/sezalchug/rolesHealthCheck
RubenCerna2079 Apr 4, 2025
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
8 changes: 8 additions & 0 deletions schemas/dab.draft.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,14 @@
"description": "Enable health check endpoint globally",
"default": true,
"additionalProperties": false
},
"roles": {
"type": "array",
"description": "Allowed Roles for Comprehensive Health Endpoint",
"items": {
"type": "string"
},
"default": null
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/Cli.Tests/ModuleInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ public static void Init()
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AllowIntrospection);
// Ignore the EnableAggregation as that's unimportant from a test standpoint.
VerifierSettings.IgnoreMember<RuntimeConfig>(options => options.EnableAggregation);
// Ignore the AllowedRolesForHealth as that's unimportant from a test standpoint.
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AllowedRolesForHealth);
// Ignore the EnableAggregation as that's unimportant from a test standpoint.
VerifierSettings.IgnoreMember<GraphQLRuntimeOptions>(options => options.EnableAggregation);
// Ignore the JSON schema path as that's unimportant from a test standpoint.
Expand Down
140 changes: 140 additions & 0 deletions src/Config/Converters/RuntimeHealthOptionsConvertorFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.DataApiBuilder.Config.ObjectModel;

namespace Azure.DataApiBuilder.Config.Converters;

internal class RuntimeHealthOptionsConvertorFactory : JsonConverterFactory
{
// Determines whether to replace environment variable with its
// value or not while deserializing.
private bool _replaceEnvVar;

/// <inheritdoc/>
public override bool CanConvert(Type typeToConvert)
{
return typeToConvert.IsAssignableTo(typeof(RuntimeHealthCheckConfig));
}

/// <inheritdoc/>
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
return new HealthCheckOptionsConverter(_replaceEnvVar);
}

internal RuntimeHealthOptionsConvertorFactory(bool replaceEnvVar)
{
_replaceEnvVar = replaceEnvVar;
}

private class HealthCheckOptionsConverter : JsonConverter<RuntimeHealthCheckConfig>
{
// Determines whether to replace environment variable with its
// value or not while deserializing.
private bool _replaceEnvVar;

/// <param name="replaceEnvVar">Whether to replace environment variable with its
/// value or not while deserializing.</param>
internal HealthCheckOptionsConverter(bool replaceEnvVar)
{
_replaceEnvVar = replaceEnvVar;
}

/// <summary>
/// Defines how DAB reads the runtime's health options and defines which values are
/// used to instantiate RuntimeHealthCheckConfig.
/// </summary>
/// <exception cref="JsonException">Thrown when improperly formatted health check options are provided.</exception>
public override RuntimeHealthCheckConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.StartObject)
{
bool? enabled = null;
List<string>? roles = null;

while (reader.Read())
{
if (reader.TokenType is JsonTokenType.EndObject)
{
return new RuntimeHealthCheckConfig(enabled, roles);
}

string? property = reader.GetString();
reader.Read();

switch (property)
{
case "enabled":
if (reader.TokenType is not JsonTokenType.Null)
{
enabled = reader.GetBoolean();
}

break;
case "roles":
if (reader.TokenType is not JsonTokenType.Null)
{
// Check if the token type is an array
if (reader.TokenType == JsonTokenType.StartArray)
{
List<string> stringList = new();

// Read the array elements one by one
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.String)
{
string? currentRole = reader.DeserializeString(_replaceEnvVar);
if (!string.IsNullOrEmpty(currentRole))
{
stringList.Add(currentRole);
}
}
}

// After reading the array, assign it to the string[] variable
roles = stringList;
}
else
{
// Handle case where the token is not an array (e.g., throw an exception or handle differently)
throw new JsonException("Expected an array of strings, but the token type is not an array.");
}
}

break;

default:
throw new JsonException($"Unexpected property {property}");
}
}
}

throw new JsonException("Runtime Health Options has a missing }.");
}

public override void Write(Utf8JsonWriter writer, RuntimeHealthCheckConfig value, JsonSerializerOptions options)
{
if (value?.UserProvidedEnabled is true)
{
writer.WriteStartObject();
writer.WritePropertyName("enabled");
JsonSerializer.Serialize(writer, value.Enabled, options);
if (value?.Roles is not null)
{
writer.WritePropertyName("roles");
JsonSerializer.Serialize(writer, value.Roles, options);
}

writer.WriteEndObject();
}
else
{
writer.WriteNullValue();
}
}
}
}
6 changes: 3 additions & 3 deletions src/Config/HealthCheck/RuntimeHealthCheckConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ public record RuntimeHealthCheckConfig : HealthCheckConfig
// TODO: Add support for caching in upcoming PRs
// public int cache-ttl-seconds { get; set; };

// TODO: Add support for "roles": ["anonymous", "authenticated"] in upcoming PRs
// public string[] Roles { get; set; };
public List<string>? Roles { get; set; }

// TODO: Add support for parallel stream to run the health check query in upcoming PRs
// public int MaxDop { get; set; } = 1; // Parallelized streams to run Health Check (Default: 1)
Expand All @@ -18,7 +17,8 @@ public RuntimeHealthCheckConfig() : base()
{
}

public RuntimeHealthCheckConfig(bool? Enabled) : base(Enabled)
public RuntimeHealthCheckConfig(bool? Enabled, List<string>? Roles = null) : base(Enabled)
{
this.Roles = Roles;
}
}
4 changes: 4 additions & 0 deletions src/Config/ObjectModel/RuntimeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ Runtime is not null &&
Runtime.GraphQL is not null &&
Runtime.GraphQL.EnableAggregation;

[JsonIgnore]
public List<string> AllowedRolesForHealth =>
Runtime?.Health?.Roles ?? new List<string>();

private Dictionary<string, DataSource> _dataSourceNameToDataSource;

private Dictionary<string, string> _entityNameToDataSourceName = new();
Expand Down
1 change: 1 addition & 0 deletions src/Config/RuntimeConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ public static JsonSerializerOptions GetSerializationOptions(
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
options.Converters.Add(new EnumMemberJsonEnumConverterFactory());
options.Converters.Add(new RuntimeHealthOptionsConvertorFactory(replaceEnvVar));
options.Converters.Add(new DataSourceHealthOptionsConvertorFactory(replaceEnvVar));
options.Converters.Add(new EntityHealthOptionsConvertorFactory());
options.Converters.Add(new RestRuntimeOptionsConverterFactory());
Expand Down
6 changes: 3 additions & 3 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3893,7 +3893,7 @@ public async Task ComprehensiveHealthEndpoint_ValidateContents(bool enableGlobal
{ "Book", requiredEntity }
};

CreateCustomConfigFile(entityMap, enableGlobalRest, enableGlobalGraphql, enableGlobalHealth, enableDatasourceHealth);
CreateCustomConfigFile(entityMap, enableGlobalRest, enableGlobalGraphql, enableGlobalHealth, enableDatasourceHealth, HostMode.Development);

string[] args = new[]
{
Expand Down Expand Up @@ -4506,14 +4506,14 @@ public async Task TestNoDepthLimitOnGrahQLInNonHostedMode(int? depthLimit)
/// </summary>
/// <param name="entityMap">Collection of entityName -> Entity object.</param>
/// <param name="enableGlobalRest">flag to enable or disabled REST globally.</param>
private static void CreateCustomConfigFile(Dictionary<string, Entity> entityMap, bool enableGlobalRest = true, bool enableGlobalGraphql = true, bool enableGlobalHealth = true, bool enableDatasourceHealth = true)
private static void CreateCustomConfigFile(Dictionary<string, Entity> entityMap, bool enableGlobalRest = true, bool enableGlobalGraphql = true, bool enableGlobalHealth = true, bool enableDatasourceHealth = true, HostMode hostMode = HostMode.Production)
{
DataSource dataSource = new(
DatabaseType.MSSQL,
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL),
Options: null,
Health: new(enableDatasourceHealth));
HostOptions hostOptions = new(Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) });
HostOptions hostOptions = new(Mode: hostMode, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) });

RuntimeConfig runtimeConfig = new(
Schema: string.Empty,
Expand Down
129 changes: 129 additions & 0 deletions src/Service.Tests/Configuration/HealthEndpointRolesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

#nullable enable
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Authorization;
using Microsoft.AspNetCore.TestHost;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Azure.DataApiBuilder.Service.Tests.Configuration
{
[TestClass]
public class HealthEndpointRolesTests
{
private const string STARTUP_CONFIG_ROLE = "authenticated";

private const string CUSTOM_CONFIG_FILENAME = "custom-config.json";

[TestMethod]
[TestCategory(TestCategory.MSSQL)]
[DataRow(null, null, DisplayName = "Validate Health Report when roles is not configured and HostMode is null.")]
[DataRow(null, HostMode.Development, DisplayName = "Validate Health Report when roles is not configured and HostMode is Development.")]
[DataRow(null, HostMode.Production, DisplayName = "Validate Health Report when roles is not configured and HostMode is Production.")]
[DataRow("authenticated", HostMode.Production, DisplayName = "Validate Health Report when roles is configured to 'authenticated' and HostMode is Production.")]
[DataRow("temp-role", HostMode.Production, DisplayName = "Validate Health Report when roles is configured to 'temp-role' which is not in token and HostMode is Production.")]
[DataRow("authenticated", HostMode.Development, DisplayName = "Validate Health Report when roles is configured to 'authenticated' and HostMode is Development.")]
[DataRow("temp-role", HostMode.Development, DisplayName = "Validate Health Report when roles is configured to 'temp-role' which is not in token and HostMode is Development.")]
public async Task ComprehensiveHealthEndpoint_RolesTests(string role, HostMode hostMode)
{
// Arrange
// At least one entity is required in the runtime config for the engine to start.
// Even though this entity is not under test, it must be supplied enable successful
// config file creation.
Entity requiredEntity = new(
Health: new(Enabled: true),
Source: new("books", EntitySourceType.Table, null, null),
Rest: new(Enabled: true),
GraphQL: new("book", "books", true),
Permissions: new[] { ConfigurationTests.GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) },
Relationships: null,
Mappings: null);

Dictionary<string, Entity> entityMap = new()
{
{ "Book", requiredEntity }
};

CreateCustomConfigFile(entityMap, role, hostMode);

string[] args = new[]
{
$"--ConfigFileName={CUSTOM_CONFIG_FILENAME}"
};

using (TestServer server = new(Program.CreateWebHostBuilder(args)))
using (HttpClient client = server.CreateClient())
{
// Sends a GET request to a protected entity which requires a specific role to access.
// Authorization checks
HttpRequestMessage message = new(method: HttpMethod.Get, requestUri: $"/health");
string swaTokenPayload = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(
addAuthenticated: true,
specificRole: STARTUP_CONFIG_ROLE);
message.Headers.Add(AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, swaTokenPayload);
message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, STARTUP_CONFIG_ROLE);
HttpResponseMessage authorizedResponse = await client.SendAsync(message);

switch (role)
{
case null:
if (hostMode == HostMode.Development)
{
Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode);
}
else
{
Assert.AreEqual(expected: HttpStatusCode.Forbidden, actual: authorizedResponse.StatusCode);
}

break;
case "temp-role":
Assert.AreEqual(expected: HttpStatusCode.Forbidden, actual: authorizedResponse.StatusCode);
break;

default:
Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode);
break;
}

}
}

/// <summary>
/// Helper function to write custom configuration file with minimal REST/GraphQL global settings
/// using the supplied entities.
/// </summary>
/// <param name="entityMap">Collection of entityName -> Entity object.</param>
/// <param name="role">Allowed Roles for comprehensive health endpoint.</param>
private static void CreateCustomConfigFile(Dictionary<string, Entity> entityMap, string? role, HostMode hostMode = HostMode.Production)
{
DataSource dataSource = new(
DatabaseType.MSSQL,
ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL),
Options: null,
Health: new(true));
HostOptions hostOptions = new(Mode: hostMode, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) });

RuntimeConfig runtimeConfig = new(
Schema: string.Empty,
DataSource: dataSource,
Runtime: new(
Health: new(Enabled: true, Roles: role != null ? new List<string> { role } : null),
Rest: new(Enabled: true),
GraphQL: new(Enabled: true),
Host: hostOptions
),
Entities: new(entityMap));

File.WriteAllText(
path: CUSTOM_CONFIG_FILENAME,
contents: runtimeConfig.ToJson());
}
}
}
2 changes: 2 additions & 0 deletions src/Service.Tests/ModuleInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ public static void Init()
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AllowIntrospection);
// Ignore the EnableAggregation as that's unimportant from a test standpoint.
VerifierSettings.IgnoreMember<RuntimeConfig>(options => options.EnableAggregation);
// Ignore the AllowedRolesForHealth as that's unimportant from a test standpoint.
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AllowedRolesForHealth);
// Ignore the EnableAggregation as that's unimportant from a test standpoint.
VerifierSettings.IgnoreMember<GraphQLRuntimeOptions>(options => options.EnableAggregation);
// Ignore the message as that's not serialized in our config file anyway.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ public Task WriteResponse(HttpContext context)
// Global comprehensive Health Check Enabled
if (config.IsHealthEnabled)
{
_healthCheckHelper.UpdateIncomingRoleHeader(context);
if (!_healthCheckHelper.IsUserAllowedToAccessHealthCheck(context, config.IsDevelopmentMode(), config.AllowedRolesForHealth))
{
LogTrace("Comprehensive Health Check Report is not allowed: 403 Forbidden due to insufficient permissions.");
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return context.Response.CompleteAsync();
}

ComprehensiveHealthCheckReport dabHealthCheckReport = _healthCheckHelper.GetHealthCheckResponse(context, config);
string response = JsonSerializer.Serialize(dabHealthCheckReport, options: new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
LogTrace($"Health check response writer writing status as: {dabHealthCheckReport.Status}");
Expand Down
Loading
Loading