Skip to content

Commit ebbe989

Browse files
sezal98sezalchugAniruddh25RubenCerna2079
authored
Allow access to health endpoint as per defined roles (#2632)
## Why make this change? Closes [#2531](#2531) ## What is this change? This PR involves creating another parameter in the runtime config which defines the **allowed roles** for the health endpoint. + It created `src/Config/Converters/RuntimeHealthOptionsConvertorFactory.cs` which takes care of serialization and deserialization of runtime config custom way. + The incoming role and token are taken up to firct check if the role in the header is matching with the allowed roles in the runtime config. + Further this role and token are passed to execute the REST and GraphQL queries for DAB. Scenarios 1. Allowed Roles are not configured. + Mode is Development - we allow all requests + Mode is Production - we dont allow any requests. We show 403 3. Allowed Roles are configured + Mode is Development or Production - we performt the role check in allowed roles. ## How was this tested? - [ ] Integration Tests - [x] Unit Tests ## Sample Request(s) Roles: ["authenticated"] Mode: Development or Production <img width="499" alt="image" src="https://github.com/user-attachments/assets/a2045fd6-dc3b-4fbc-8c81-87aa763df482" /> <img width="467" alt="image" src="https://github.com/user-attachments/assets/6816f9ff-0aa2-4fcf-99ac-b4c6bec956be" /> Roles: Not Configured Mode: Development <img width="487" alt="image" src="https://github.com/user-attachments/assets/17374e66-c3cb-47cf-8ac8-b58185b46ca5" /> Mode: Production <img width="461" alt="image" src="https://github.com/user-attachments/assets/96f9c201-072b-4c7a-a3d3-2350b245f200" /> Roles: ["admin"] Mode: Development or Production <img width="389" alt="image" src="https://github.com/user-attachments/assets/2523d28d-c950-49bf-8300-1302c5774017" /> --------- Co-authored-by: sezalchug <[email protected]> Co-authored-by: Aniruddh Munde <[email protected]> Co-authored-by: Ruben Cerna <[email protected]> Co-authored-by: RubenCerna2079 <[email protected]>
1 parent 44a9afd commit ebbe989

File tree

12 files changed

+412
-33
lines changed

12 files changed

+412
-33
lines changed

schemas/dab.draft.schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,14 @@
399399
"description": "Enable health check endpoint globally",
400400
"default": true,
401401
"additionalProperties": false
402+
},
403+
"roles": {
404+
"type": "array",
405+
"description": "Allowed Roles for Comprehensive Health Endpoint",
406+
"items": {
407+
"type": "string"
408+
},
409+
"default": null
402410
}
403411
}
404412
}

src/Cli.Tests/ModuleInitializer.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ public static void Init()
7777
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AllowIntrospection);
7878
// Ignore the EnableAggregation as that's unimportant from a test standpoint.
7979
VerifierSettings.IgnoreMember<RuntimeConfig>(options => options.EnableAggregation);
80+
// Ignore the AllowedRolesForHealth as that's unimportant from a test standpoint.
81+
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AllowedRolesForHealth);
8082
// Ignore the EnableAggregation as that's unimportant from a test standpoint.
8183
VerifierSettings.IgnoreMember<GraphQLRuntimeOptions>(options => options.EnableAggregation);
8284
// Ignore the EnableDwNto1JoinOpt as that's unimportant from a test standpoint.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text.Json;
5+
using System.Text.Json.Serialization;
6+
using Azure.DataApiBuilder.Config.ObjectModel;
7+
8+
namespace Azure.DataApiBuilder.Config.Converters;
9+
10+
internal class RuntimeHealthOptionsConvertorFactory : JsonConverterFactory
11+
{
12+
// Determines whether to replace environment variable with its
13+
// value or not while deserializing.
14+
private bool _replaceEnvVar;
15+
16+
/// <inheritdoc/>
17+
public override bool CanConvert(Type typeToConvert)
18+
{
19+
return typeToConvert.IsAssignableTo(typeof(RuntimeHealthCheckConfig));
20+
}
21+
22+
/// <inheritdoc/>
23+
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
24+
{
25+
return new HealthCheckOptionsConverter(_replaceEnvVar);
26+
}
27+
28+
internal RuntimeHealthOptionsConvertorFactory(bool replaceEnvVar)
29+
{
30+
_replaceEnvVar = replaceEnvVar;
31+
}
32+
33+
private class HealthCheckOptionsConverter : JsonConverter<RuntimeHealthCheckConfig>
34+
{
35+
// Determines whether to replace environment variable with its
36+
// value or not while deserializing.
37+
private bool _replaceEnvVar;
38+
39+
/// <param name="replaceEnvVar">Whether to replace environment variable with its
40+
/// value or not while deserializing.</param>
41+
internal HealthCheckOptionsConverter(bool replaceEnvVar)
42+
{
43+
_replaceEnvVar = replaceEnvVar;
44+
}
45+
46+
/// <summary>
47+
/// Defines how DAB reads the runtime's health options and defines which values are
48+
/// used to instantiate RuntimeHealthCheckConfig.
49+
/// </summary>
50+
/// <exception cref="JsonException">Thrown when improperly formatted health check options are provided.</exception>
51+
public override RuntimeHealthCheckConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
52+
{
53+
if (reader.TokenType is JsonTokenType.StartObject)
54+
{
55+
bool? enabled = null;
56+
HashSet<string>? roles = null;
57+
58+
while (reader.Read())
59+
{
60+
if (reader.TokenType is JsonTokenType.EndObject)
61+
{
62+
return new RuntimeHealthCheckConfig(enabled, roles);
63+
}
64+
65+
string? property = reader.GetString();
66+
reader.Read();
67+
68+
switch (property)
69+
{
70+
case "enabled":
71+
if (reader.TokenType is not JsonTokenType.Null)
72+
{
73+
enabled = reader.GetBoolean();
74+
}
75+
76+
break;
77+
case "roles":
78+
if (reader.TokenType is not JsonTokenType.Null)
79+
{
80+
// Check if the token type is an array
81+
if (reader.TokenType == JsonTokenType.StartArray)
82+
{
83+
HashSet<string> stringList = new();
84+
85+
// Read the array elements one by one
86+
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
87+
{
88+
if (reader.TokenType == JsonTokenType.String)
89+
{
90+
string? currentRole = reader.DeserializeString(_replaceEnvVar);
91+
if (!string.IsNullOrEmpty(currentRole))
92+
{
93+
stringList.Add(currentRole);
94+
}
95+
}
96+
}
97+
98+
// After reading the array, assign it to the string[] variable
99+
roles = stringList;
100+
}
101+
else
102+
{
103+
// Handle case where the token is not an array (e.g., throw an exception or handle differently)
104+
throw new JsonException("Expected an array of strings, but the token type is not an array.");
105+
}
106+
}
107+
108+
break;
109+
110+
default:
111+
throw new JsonException($"Unexpected property {property}");
112+
}
113+
}
114+
}
115+
116+
throw new JsonException("Runtime Health Options has a missing }.");
117+
}
118+
119+
public override void Write(Utf8JsonWriter writer, RuntimeHealthCheckConfig value, JsonSerializerOptions options)
120+
{
121+
if (value?.UserProvidedEnabled is true)
122+
{
123+
writer.WriteStartObject();
124+
writer.WritePropertyName("enabled");
125+
JsonSerializer.Serialize(writer, value.Enabled, options);
126+
if (value?.Roles is not null)
127+
{
128+
writer.WritePropertyName("roles");
129+
JsonSerializer.Serialize(writer, value.Roles, options);
130+
}
131+
132+
writer.WriteEndObject();
133+
}
134+
else
135+
{
136+
writer.WriteNullValue();
137+
}
138+
}
139+
}
140+
}

src/Config/HealthCheck/RuntimeHealthCheckConfig.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ public record RuntimeHealthCheckConfig : HealthCheckConfig
88
// TODO: Add support for caching in upcoming PRs
99
// public int cache-ttl-seconds { get; set; };
1010

11-
// TODO: Add support for "roles": ["anonymous", "authenticated"] in upcoming PRs
12-
// public string[] Roles { get; set; };
11+
public HashSet<string>? Roles { get; set; }
1312

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

21-
public RuntimeHealthCheckConfig(bool? Enabled) : base(Enabled)
20+
public RuntimeHealthCheckConfig(bool? Enabled, HashSet<string>? Roles = null) : base(Enabled)
2221
{
22+
this.Roles = Roles;
2323
}
2424
}

src/Config/ObjectModel/RuntimeConfig.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ Runtime is not null &&
150150
Runtime.GraphQL is not null &&
151151
Runtime.GraphQL.EnableAggregation;
152152

153+
[JsonIgnore]
154+
public HashSet<string> AllowedRolesForHealth =>
155+
Runtime?.Health?.Roles ?? new HashSet<string>();
156+
153157
/// <summary>
154158
/// Retrieves the value of runtime.graphql.dwnto1joinopt.enabled property if present, default is false.
155159
/// </summary>

src/Config/RuntimeConfigLoader.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ public static JsonSerializerOptions GetSerializationOptions(
241241
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
242242
};
243243
options.Converters.Add(new EnumMemberJsonEnumConverterFactory());
244+
options.Converters.Add(new RuntimeHealthOptionsConvertorFactory(replaceEnvVar));
244245
options.Converters.Add(new DataSourceHealthOptionsConvertorFactory(replaceEnvVar));
245246
options.Converters.Add(new EntityHealthOptionsConvertorFactory());
246247
options.Converters.Add(new RestRuntimeOptionsConverterFactory());

src/Service.Tests/Configuration/ConfigurationTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4054,7 +4054,7 @@ public async Task ComprehensiveHealthEndpoint_ValidateContents(bool enableGlobal
40544054
{ "Book", requiredEntity }
40554055
};
40564056

4057-
CreateCustomConfigFile(entityMap, enableGlobalRest, enableGlobalGraphql, enableGlobalHealth, enableDatasourceHealth);
4057+
CreateCustomConfigFile(entityMap, enableGlobalRest, enableGlobalGraphql, enableGlobalHealth, enableDatasourceHealth, HostMode.Development);
40584058

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

46794679
RuntimeConfig runtimeConfig = new(
46804680
Schema: string.Empty,
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
#nullable enable
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Net;
8+
using System.Net.Http;
9+
using System.Threading.Tasks;
10+
using Azure.DataApiBuilder.Config.ObjectModel;
11+
using Azure.DataApiBuilder.Core.Authorization;
12+
using Microsoft.AspNetCore.TestHost;
13+
using Microsoft.VisualStudio.TestTools.UnitTesting;
14+
15+
namespace Azure.DataApiBuilder.Service.Tests.Configuration
16+
{
17+
[TestClass]
18+
public class HealthEndpointRolesTests
19+
{
20+
private const string STARTUP_CONFIG_ROLE = "authenticated";
21+
22+
private const string CUSTOM_CONFIG_FILENAME = "custom-config.json";
23+
24+
[TestCleanup]
25+
public void CleanupAfterEachTest()
26+
{
27+
if (File.Exists(CUSTOM_CONFIG_FILENAME))
28+
{
29+
File.Delete(CUSTOM_CONFIG_FILENAME);
30+
}
31+
32+
TestHelper.UnsetAllDABEnvironmentVariables();
33+
}
34+
35+
[TestMethod]
36+
[TestCategory(TestCategory.MSSQL)]
37+
[DataRow(null, null, DisplayName = "Validate Health Report when roles is not configured and HostMode is null.")]
38+
[DataRow(null, HostMode.Development, DisplayName = "Validate Health Report when roles is not configured and HostMode is Development.")]
39+
[DataRow(null, HostMode.Production, DisplayName = "Validate Health Report when roles is not configured and HostMode is Production.")]
40+
[DataRow("authenticated", HostMode.Production, DisplayName = "Validate Health Report when roles is configured to 'authenticated' and HostMode is Production.")]
41+
[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.")]
42+
[DataRow("authenticated", HostMode.Development, DisplayName = "Validate Health Report when roles is configured to 'authenticated' and HostMode is Development.")]
43+
[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.")]
44+
public async Task ComprehensiveHealthEndpoint_RolesTests(string role, HostMode hostMode)
45+
{
46+
// Arrange
47+
// At least one entity is required in the runtime config for the engine to start.
48+
// Even though this entity is not under test, it must be supplied enable successful
49+
// config file creation.
50+
Entity requiredEntity = new(
51+
Health: new(Enabled: true),
52+
Source: new("books", EntitySourceType.Table, null, null),
53+
Rest: new(Enabled: true),
54+
GraphQL: new("book", "books", true),
55+
Permissions: new[] { ConfigurationTests.GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) },
56+
Relationships: null,
57+
Mappings: null);
58+
59+
Dictionary<string, Entity> entityMap = new()
60+
{
61+
{ "Book", requiredEntity }
62+
};
63+
64+
CreateCustomConfigFile(entityMap, role, hostMode);
65+
66+
string[] args = new[]
67+
{
68+
$"--ConfigFileName={CUSTOM_CONFIG_FILENAME}"
69+
};
70+
71+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
72+
using (HttpClient client = server.CreateClient())
73+
{
74+
// Sends a GET request to a protected entity which requires a specific role to access.
75+
// Authorization checks
76+
HttpRequestMessage message = new(method: HttpMethod.Get, requestUri: $"/health");
77+
string swaTokenPayload = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(
78+
addAuthenticated: true,
79+
specificRole: STARTUP_CONFIG_ROLE);
80+
message.Headers.Add(AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, swaTokenPayload);
81+
message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, STARTUP_CONFIG_ROLE);
82+
HttpResponseMessage authorizedResponse = await client.SendAsync(message);
83+
84+
switch (role)
85+
{
86+
case null:
87+
if (hostMode == HostMode.Development)
88+
{
89+
Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode);
90+
}
91+
else
92+
{
93+
Assert.AreEqual(expected: HttpStatusCode.Forbidden, actual: authorizedResponse.StatusCode);
94+
}
95+
96+
break;
97+
case "temp-role":
98+
Assert.AreEqual(expected: HttpStatusCode.Forbidden, actual: authorizedResponse.StatusCode);
99+
break;
100+
101+
default:
102+
Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode);
103+
break;
104+
}
105+
}
106+
}
107+
108+
/// <summary>
109+
/// Helper function to write custom configuration file with minimal REST/GraphQL global settings
110+
/// using the supplied entities.
111+
/// </summary>
112+
/// <param name="entityMap">Collection of entityName -> Entity object.</param>
113+
/// <param name="role">Allowed Roles for comprehensive health endpoint.</param>
114+
private static void CreateCustomConfigFile(Dictionary<string, Entity> entityMap, string? role, HostMode hostMode = HostMode.Production)
115+
{
116+
DataSource dataSource = new(
117+
DatabaseType.MSSQL,
118+
ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL),
119+
Options: null,
120+
Health: new(true));
121+
HostOptions hostOptions = new(Mode: hostMode, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) });
122+
123+
RuntimeConfig runtimeConfig = new(
124+
Schema: string.Empty,
125+
DataSource: dataSource,
126+
Runtime: new(
127+
Health: new(Enabled: true, Roles: role != null ? new HashSet<string> { role } : null),
128+
Rest: new(Enabled: true),
129+
GraphQL: new(Enabled: true),
130+
Host: hostOptions
131+
),
132+
Entities: new(entityMap));
133+
134+
File.WriteAllText(
135+
path: CUSTOM_CONFIG_FILENAME,
136+
contents: runtimeConfig.ToJson());
137+
}
138+
}
139+
}

src/Service.Tests/ModuleInitializer.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ public static void Init()
8383
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AllowIntrospection);
8484
// Ignore the EnableAggregation as that's unimportant from a test standpoint.
8585
VerifierSettings.IgnoreMember<RuntimeConfig>(options => options.EnableAggregation);
86+
// Ignore the AllowedRolesForHealth as that's unimportant from a test standpoint.
87+
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AllowedRolesForHealth);
8688
// Ignore the EnableAggregation as that's unimportant from a test standpoint.
8789
VerifierSettings.IgnoreMember<GraphQLRuntimeOptions>(options => options.EnableAggregation);
8890
// Ignore the EnableDwNto1JoinOpt as that's unimportant from a test standpoint.

0 commit comments

Comments
 (0)