From 66e2112715f7f1636834b4471f5a4545fb66dc77 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 20:19:52 +0000
Subject: [PATCH 01/19] Initial plan
From 22f67146d623767fd33d7ba9f02a3263d2c915ba Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 20:44:44 +0000
Subject: [PATCH 02/19] Implement role inheritance and
show-effective-permissions CLI command with a-z entity ordering
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
---
src/Cli/Commands/ConfigureOptions.cs | 22 ++++-
src/Cli/ConfigGenerator.cs | 57 ++++++++++++
.../Authorization/AuthorizationResolver.cs | 45 +++++++++
.../GraphQLAuthorizationHandler.cs | 14 ++-
.../AuthorizationResolverUnitTests.cs | 93 ++++++++++++++++++-
5 files changed, 225 insertions(+), 6 deletions(-)
diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs
index 14234d24d7..d0b25b1957 100644
--- a/src/Cli/Commands/ConfigureOptions.cs
+++ b/src/Cli/Commands/ConfigureOptions.cs
@@ -73,6 +73,7 @@ public ConfigureOptions(
RollingInterval? fileSinkRollingInterval = null,
int? fileSinkRetainedFileCountLimit = null,
long? fileSinkFileSizeLimitBytes = null,
+ bool showEffectivePermissions = false,
string? config = null)
: base(config)
{
@@ -137,6 +138,7 @@ public ConfigureOptions(
FileSinkRollingInterval = fileSinkRollingInterval;
FileSinkRetainedFileCountLimit = fileSinkRetainedFileCountLimit;
FileSinkFileSizeLimitBytes = fileSinkFileSizeLimitBytes;
+ ShowEffectivePermissions = showEffectivePermissions;
}
[Option("data-source.database-type", Required = false, HelpText = "Database type. Allowed values: MSSQL, PostgreSQL, CosmosDB_NoSQL, MySQL.")]
@@ -292,11 +294,27 @@ public ConfigureOptions(
[Option("runtime.telemetry.file.file-size-limit-bytes", Required = false, HelpText = "Configure maximum file size limit in bytes. Default: 1048576")]
public long? FileSinkFileSizeLimitBytes { get; }
+ [Option("show-effective-permissions", Required = false, HelpText = "Display effective permissions for all entities, including inherited permissions. Entities are listed in alphabetical order.")]
+ public bool ShowEffectivePermissions { get; }
+
public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
{
logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion());
- bool isSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem);
- if (isSuccess)
+
+ if (ShowEffectivePermissions)
+ {
+ bool isSuccess = ConfigGenerator.TryShowEffectivePermissions(this, loader, fileSystem);
+ if (!isSuccess)
+ {
+ logger.LogError("Failed to display effective permissions.");
+ return CliReturnCode.GENERAL_ERROR;
+ }
+
+ return CliReturnCode.SUCCESS;
+ }
+
+ bool configSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem);
+ if (configSuccess)
{
logger.LogInformation("Successfully updated runtime settings in the config file.");
return CliReturnCode.SUCCESS;
diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs
index 9a3401a55a..fdaf44992c 100644
--- a/src/Cli/ConfigGenerator.cs
+++ b/src/Cli/ConfigGenerator.cs
@@ -585,6 +585,63 @@ public static bool TryCreateSourceObjectForNewEntity(
return true;
}
+
+ ///
+ /// Displays the effective permissions for all entities defined in the config, listed alphabetically by entity name.
+ /// Effective permissions include explicitly configured roles as well as inherited permissions:
+ /// - anonymous → authenticated (when authenticated is not explicitly configured)
+ /// - authenticated → any named role not explicitly configured for the entity
+ ///
+ /// True if the effective permissions were successfully displayed; otherwise, false.
+ public static bool TryShowEffectivePermissions(ConfigureOptions options, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
+ {
+ if (!TryGetConfigFileBasedOnCliPrecedence(loader, options.Config, out string runtimeConfigFile))
+ {
+ return false;
+ }
+
+ if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig))
+ {
+ _logger.LogError("Failed to read the config file: {runtimeConfigFile}.", runtimeConfigFile);
+ return false;
+ }
+
+ const string ROLE_ANONYMOUS = "anonymous";
+ const string ROLE_AUTHENTICATED = "authenticated";
+
+ // Iterate entities sorted a-z by name.
+ foreach ((string entityName, Entity entity) in runtimeConfig.Entities.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Entity: {entityName}", entityName);
+
+ bool hasAnonymous = entity.Permissions.Any(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase));
+ bool hasAuthenticated = entity.Permissions.Any(p => p.Role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase));
+
+ foreach (EntityPermission permission in entity.Permissions.OrderBy(p => p.Role, StringComparer.OrdinalIgnoreCase))
+ {
+ string actions = string.Join(", ", permission.Actions.Select(a => a.Action.ToString()));
+ _logger.LogInformation(" Role: {role} | Actions: {actions}", permission.Role, actions);
+ }
+
+ // Show inherited authenticated permissions when authenticated is not explicitly configured.
+ if (hasAnonymous && !hasAuthenticated)
+ {
+ EntityPermission anonPermission = entity.Permissions.First(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase));
+ string inheritedActions = string.Join(", ", anonPermission.Actions.Select(a => a.Action.ToString()));
+ _logger.LogInformation(" Role: {role} | Actions: {actions} (inherited from: {source})", ROLE_AUTHENTICATED, inheritedActions, ROLE_ANONYMOUS);
+ }
+
+ // Show inheritance note for named roles.
+ string inheritSource = hasAuthenticated ? ROLE_AUTHENTICATED : (hasAnonymous ? ROLE_ANONYMOUS : string.Empty);
+ if (!string.IsNullOrEmpty(inheritSource))
+ {
+ _logger.LogInformation(" Any unconfigured named role inherits from: {inheritSource}", inheritSource);
+ }
+ }
+
+ return true;
+ }
+
///
/// Tries to update the runtime settings based on the provided runtime options.
///
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index 0f22b9cd28..ab9805bc51 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -119,6 +119,7 @@ public bool IsValidRoleContext(HttpContext httpContext)
///
public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string roleName, EntityActionOperation operation)
{
+ roleName = GetEffectiveRoleName(entityIdentifier, roleName);
if (EntityPermissionsMap.TryGetValue(entityIdentifier, out EntityMetadata? valueOfEntityToRole))
{
if (valueOfEntityToRole.RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? valueOfRoleToOperation))
@@ -135,6 +136,7 @@ public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string
public bool IsStoredProcedureExecutionPermitted(string entityName, string roleName, SupportedHttpVerb httpVerb)
{
+ roleName = GetEffectiveRoleName(entityName, roleName);
bool executionPermitted = EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata)
&& entityMetadata is not null
&& entityMetadata.RoleToOperationMap.TryGetValue(roleName, out _);
@@ -144,6 +146,7 @@ public bool IsStoredProcedureExecutionPermitted(string entityName, string roleNa
///
public bool AreColumnsAllowedForOperation(string entityName, string roleName, EntityActionOperation operation, IEnumerable columns)
{
+ roleName = GetEffectiveRoleName(entityName, roleName);
string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName);
ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName);
@@ -210,6 +213,7 @@ public string ProcessDBPolicy(string entityName, string roleName, EntityActionOp
///
public string GetDBPolicyForRequest(string entityName, string roleName, EntityActionOperation operation)
{
+ roleName = GetEffectiveRoleName(entityName, roleName);
if (!EntityPermissionsMap[entityName].RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? roleMetadata))
{
return string.Empty;
@@ -426,6 +430,46 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole(
}
}
+ ///
+ /// Returns the effective role name for permission lookups, implementing role inheritance.
+ /// System roles (anonymous, authenticated) always resolve to themselves.
+ /// For any other named role not explicitly configured for the entity, this method falls back
+ /// to the 'authenticated' role if it is present (which itself may already inherit from 'anonymous').
+ /// Inheritance chain: named-role → authenticated → anonymous → none.
+ ///
+ /// Name of the entity being accessed.
+ /// Role name from the request.
+ /// The role name whose permissions should apply for this request.
+ private string GetEffectiveRoleName(string entityName, string roleName)
+ {
+ // System roles always resolve to themselves; they do not inherit from other roles.
+ if (roleName.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) ||
+ roleName.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))
+ {
+ return roleName;
+ }
+
+ if (!EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata))
+ {
+ return roleName;
+ }
+
+ // Named role explicitly configured: use its own permissions.
+ if (entityMetadata.RoleToOperationMap.ContainsKey(roleName))
+ {
+ return roleName;
+ }
+
+ // Named role not configured: inherit from 'authenticated' if present.
+ // Note: 'authenticated' itself may already inherit from 'anonymous' via setup-time copy.
+ if (entityMetadata.RoleToOperationMap.ContainsKey(ROLE_AUTHENTICATED))
+ {
+ return ROLE_AUTHENTICATED;
+ }
+
+ return roleName;
+ }
+
///
/// Returns a list of all possible operations depending on the provided EntitySourceType.
/// Stored procedures only support Operation.Execute.
@@ -474,6 +518,7 @@ private static void PopulateAllowedExposedColumns(
///
public IEnumerable GetAllowedExposedColumns(string entityName, string roleName, EntityActionOperation operation)
{
+ roleName = GetEffectiveRoleName(entityName, roleName);
return EntityPermissionsMap[entityName].RoleToOperationMap[roleName].OperationToColumnMap[operation].AllowedExposedColumns;
}
diff --git a/src/Core/Authorization/GraphQLAuthorizationHandler.cs b/src/Core/Authorization/GraphQLAuthorizationHandler.cs
index 2760777e94..ed11b6d13c 100644
--- a/src/Core/Authorization/GraphQLAuthorizationHandler.cs
+++ b/src/Core/Authorization/GraphQLAuthorizationHandler.cs
@@ -134,10 +134,13 @@ private static bool TryGetApiRoleHeader(IDictionary contextData
/// The runtime's GraphQLSchemaBuilder will not add an @authorize directive without any roles defined,
/// however, since the Roles property of HotChocolate's AuthorizeDirective object is nullable,
/// handle the possible null gracefully.
+ /// Supports role inheritance: a named role not explicitly listed is permitted when 'authenticated'
+ /// is listed in the directive roles, implementing the chain: named-role → authenticated → anonymous.
///
/// Role defined in request HTTP Header, X-MS-API-ROLE
/// Roles defined on the @authorize directive. Case insensitive.
- /// True when the authenticated user's explicitly defined role is present in the authorize directive role list. Otherwise, false.
+ /// True when the authenticated user's explicitly defined role is present in the authorize directive role list,
+ /// or when the role inherits permissions from 'authenticated'. Otherwise, false.
private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyList? roles)
{
if (roles is null || roles.Count == 0)
@@ -150,6 +153,15 @@ private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyL
return true;
}
+ // Role inheritance: named roles (any role other than anonymous) inherit from 'authenticated'.
+ // If 'authenticated' is in the directive's roles and the requesting role is not 'anonymous',
+ // allow access because named roles inherit from 'authenticated'.
+ if (!clientRoleHeader.Equals(AuthorizationResolver.ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
+ roles.Any(role => role.Equals(AuthorizationResolver.ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
return false;
}
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index 0dff3ac016..d854ba618e 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -326,9 +326,9 @@ public void TestAuthenticatedRoleWhenAnonymousRoleIsDefined()
}
}
- // Anonymous role's permissions are copied over for authenticated role only.
- // Assert by checking for an arbitrary role.
- Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(AuthorizationHelpers.TEST_ENTITY,
+ // With role inheritance, named roles inherit from authenticated (which inherited from anonymous).
+ // Assert that an arbitrary named role now effectively has the Create operation via inheritance.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(AuthorizationHelpers.TEST_ENTITY,
AuthorizationHelpers.TEST_ROLE, EntityActionOperation.Create));
// Assert that the create operation has both anonymous, authenticated roles.
@@ -479,6 +479,93 @@ public void TestAuthenticatedRoleWhenBothAnonymousAndAuthenticatedAreDefined()
CollectionAssert.AreEquivalent(expectedRolesForUpdateCol1, actualRolesForUpdateCol1.ToList());
}
+ ///
+ /// Validates role inheritance for named roles: when a named role is not configured for an entity
+ /// but 'authenticated' is configured (or inherited from 'anonymous'), the named role inherits
+ /// the permissions of 'authenticated'.
+ /// Inheritance chain: named-role → authenticated → anonymous → none.
+ ///
+ [TestMethod]
+ public void TestNamedRoleInheritsFromAuthenticatedRole()
+ {
+ RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig(
+ entityName: AuthorizationHelpers.TEST_ENTITY,
+ roleName: AuthorizationResolver.ROLE_AUTHENTICATED,
+ operation: EntityActionOperation.Read);
+
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ // Named role (TEST_ROLE = "Writer") is not configured but should inherit from 'authenticated'.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Read));
+
+ // Named role should NOT have operations that 'authenticated' does not have.
+ Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Create));
+ }
+
+ ///
+ /// Validates that when neither 'anonymous' nor 'authenticated' is configured for an entity,
+ /// a named role that is also not configured inherits nothing (rule 5).
+ ///
+ [TestMethod]
+ public void TestNamedRoleInheritsNothingWhenNoSystemRolesDefined()
+ {
+ const string CONFIGURED_NAMED_ROLE = "admin";
+ RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig(
+ entityName: AuthorizationHelpers.TEST_ENTITY,
+ roleName: CONFIGURED_NAMED_ROLE,
+ operation: EntityActionOperation.Create);
+
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ // The configured 'admin' role has Create permission.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ CONFIGURED_NAMED_ROLE,
+ EntityActionOperation.Create));
+
+ // TEST_ROLE ("Writer") is not configured and neither anonymous nor authenticated is configured,
+ // so it inherits nothing (rule 5).
+ Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Create));
+ }
+
+ ///
+ /// Validates that a named role inherits from 'authenticated', which in turn has already
+ /// inherited from 'anonymous' at setup time (when anonymous is configured but authenticated is not).
+ /// Inheritance chain: named-role → authenticated (inherited from anonymous).
+ ///
+ [TestMethod]
+ public void TestNamedRoleInheritsFromAnonymousViaAuthenticated()
+ {
+ // Only 'anonymous' is configured; 'authenticated' will inherit from it at setup time.
+ RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig(
+ entityName: AuthorizationHelpers.TEST_ENTITY,
+ roleName: AuthorizationResolver.ROLE_ANONYMOUS,
+ operation: EntityActionOperation.Read);
+
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ // Named role ("Writer") should inherit Read via: Writer → authenticated → anonymous.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Read));
+
+ // Named role should NOT have operations that anonymous does not have.
+ Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Create));
+ }
+
///
/// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName.
/// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc.
From dab4c6fa81977cfdf1a6791ec63740529f6678dc Mon Sep 17 00:00:00 2001
From: aaron burtle
Date: Thu, 26 Feb 2026 13:55:12 -0800
Subject: [PATCH 03/19] de-couple graphQL auth and auth resolver for one source
of truth
---
src/Auth/IAuthorizationResolver.cs | 37 ++++++++++
.../GraphQLAuthorizationHandler.cs | 47 +++----------
.../AuthorizationResolverUnitTests.cs | 69 +++++++++++++++++--
3 files changed, 110 insertions(+), 43 deletions(-)
diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs
index a17f61ade5..eaef1eb777 100644
--- a/src/Auth/IAuthorizationResolver.cs
+++ b/src/Auth/IAuthorizationResolver.cs
@@ -137,4 +137,41 @@ public static IEnumerable GetRolesForOperation(
return new List();
}
+
+ ///
+ /// Determines whether a given client role should be allowed through the GraphQL
+ /// schema-level authorization gate for a specific set of directive roles.
+ /// Centralizes the role inheritance logic so that callers (e.g. GraphQLAuthorizationHandler)
+ /// do not need to duplicate inheritance rules.
+ ///
+ /// Inheritance chain: named-role → authenticated → anonymous → none.
+ /// - If the role is explicitly listed in the directive roles, return true.
+ /// - If the role is not 'anonymous' and 'authenticated' is listed, return true (inheritance).
+ /// - Otherwise, return false.
+ ///
+ /// The role from the X-MS-API-ROLE header.
+ /// The roles listed on the @authorize directive.
+ /// True if the client role should be allowed through the gate.
+ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles)
+ {
+ if (directiveRoles is null || directiveRoles.Count == 0)
+ {
+ return false;
+ }
+
+ // Explicit match — role is directly listed.
+ if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ // Role inheritance: any non-anonymous role inherits from 'authenticated'.
+ if (!clientRole.Equals("anonymous", StringComparison.OrdinalIgnoreCase) &&
+ directiveRoles.Any(role => role.Equals("authenticated", StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ return false;
+ }
}
diff --git a/src/Core/Authorization/GraphQLAuthorizationHandler.cs b/src/Core/Authorization/GraphQLAuthorizationHandler.cs
index ed11b6d13c..54a6362c68 100644
--- a/src/Core/Authorization/GraphQLAuthorizationHandler.cs
+++ b/src/Core/Authorization/GraphQLAuthorizationHandler.cs
@@ -17,6 +17,13 @@ namespace Azure.DataApiBuilder.Core.Authorization;
///
public class GraphQLAuthorizationHandler : IAuthorizationHandler
{
+ private readonly Azure.DataApiBuilder.Auth.IAuthorizationResolver _authorizationResolver;
+
+ public GraphQLAuthorizationHandler(Azure.DataApiBuilder.Auth.IAuthorizationResolver authorizationResolver)
+ {
+ _authorizationResolver = authorizationResolver;
+ }
+
///
/// Authorize access to field based on contents of @authorize directive.
/// Validates that the requestor is authenticated, and that the
@@ -44,7 +51,7 @@ public ValueTask AuthorizeAsync(
// Schemas defining authorization policies are not supported, even when roles are defined appropriately.
// Requests will be short circuited and rejected (authorization forbidden).
- if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && IsInHeaderDesignatedRole(clientRole, directive.Roles))
+ if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles))
{
if (!string.IsNullOrEmpty(directive.Policy))
{
@@ -83,7 +90,7 @@ public ValueTask AuthorizeAsync(
{
// Schemas defining authorization policies are not supported, even when roles are defined appropriately.
// Requests will be short circuited and rejected (authorization forbidden).
- if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && IsInHeaderDesignatedRole(clientRole, directive.Roles))
+ if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles))
{
if (!string.IsNullOrEmpty(directive.Policy))
{
@@ -129,42 +136,6 @@ private static bool TryGetApiRoleHeader(IDictionary contextData
return false;
}
- ///
- /// Checks the pre-validated clientRoleHeader value against the roles listed in @authorize directive's roles.
- /// The runtime's GraphQLSchemaBuilder will not add an @authorize directive without any roles defined,
- /// however, since the Roles property of HotChocolate's AuthorizeDirective object is nullable,
- /// handle the possible null gracefully.
- /// Supports role inheritance: a named role not explicitly listed is permitted when 'authenticated'
- /// is listed in the directive roles, implementing the chain: named-role → authenticated → anonymous.
- ///
- /// Role defined in request HTTP Header, X-MS-API-ROLE
- /// Roles defined on the @authorize directive. Case insensitive.
- /// True when the authenticated user's explicitly defined role is present in the authorize directive role list,
- /// or when the role inherits permissions from 'authenticated'. Otherwise, false.
- private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyList? roles)
- {
- if (roles is null || roles.Count == 0)
- {
- return false;
- }
-
- if (roles.Any(role => role.Equals(clientRoleHeader, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- // Role inheritance: named roles (any role other than anonymous) inherit from 'authenticated'.
- // If 'authenticated' is in the directive's roles and the requesting role is not 'anonymous',
- // allow access because named roles inherit from 'authenticated'.
- if (!clientRoleHeader.Equals(AuthorizationResolver.ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
- roles.Any(role => role.Equals(AuthorizationResolver.ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- return false;
- }
-
///
/// Returns whether the ClaimsPrincipal in the HotChocolate IMiddlewareContext.ContextData is authenticated.
/// To be authenticated, at least one ClaimsIdentity in ClaimsPrincipal.Identities must be authenticated.
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index d854ba618e..4c051a1842 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -566,6 +566,65 @@ public void TestNamedRoleInheritsFromAnonymousViaAuthenticated()
EntityActionOperation.Create));
}
+ ///
+ /// SECURITY: Validates that a named role that IS explicitly configured for an entity
+ /// does NOT inherit broader permissions from 'authenticated'. This prevents privilege
+ /// escalation when a config author intentionally restricts a named role's permissions.
+ /// Example: authenticated has CRUD, but 'restricted' is configured with only Read.
+ /// A request from 'restricted' for Create must be denied.
+ ///
+ [TestMethod]
+ public void TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions()
+ {
+ // 'authenticated' gets Read + Create; 'restricted' gets only Read.
+ EntityActionFields fieldsForRole = new(
+ Include: new HashSet { "col1" },
+ Exclude: new());
+
+ EntityAction readAction = new(
+ Action: EntityActionOperation.Read,
+ Fields: fieldsForRole,
+ Policy: new(null, null));
+
+ EntityAction createAction = new(
+ Action: EntityActionOperation.Create,
+ Fields: fieldsForRole,
+ Policy: new(null, null));
+
+ EntityPermission authenticatedPermission = new(
+ Role: AuthorizationResolver.ROLE_AUTHENTICATED,
+ Actions: new[] { readAction, createAction });
+
+ EntityPermission restrictedPermission = new(
+ Role: "restricted",
+ Actions: new[] { readAction });
+
+ EntityPermission[] permissions = new[] { authenticatedPermission, restrictedPermission };
+ RuntimeConfig runtimeConfig = BuildTestRuntimeConfig(permissions, AuthorizationHelpers.TEST_ENTITY);
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ // 'restricted' is explicitly configured, so it should use its OWN permissions only.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ "restricted",
+ EntityActionOperation.Read),
+ "Explicitly configured 'restricted' role should have Read permission.");
+
+ // CRITICAL: 'restricted' must NOT inherit Create from 'authenticated'.
+ Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ "restricted",
+ EntityActionOperation.Create),
+ "Explicitly configured 'restricted' role must NOT inherit Create from 'authenticated'.");
+
+ // Verify 'authenticated' still has Create (sanity check).
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationResolver.ROLE_AUTHENTICATED,
+ EntityActionOperation.Create),
+ "'authenticated' should retain its own Create permission.");
+ }
+
///
/// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName.
/// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc.
@@ -1006,7 +1065,7 @@ public void AreColumnsAllowedForOperationWithRoleWithDifferentCasing(
DisplayName = "Valid policy parsing test for string and int64 claimvaluetypes.")]
[DataRow("(@claims.isemployee eq @item.col1 and @item.col2 ne @claims.user_email) or" +
"('David' ne @item.col3 and @claims.contact_no ne @item.col3)", "(true eq col1 and col2 ne 'xyz@microsoft.com') or" +
- "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetype.")]
+ "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetypes.")]
[DataRow("(@item.rating gt @claims.emprating) and (@claims.isemployee eq true)",
"(rating gt 4.2) and (true eq true)", DisplayName = "Valid policy parsing test for double and boolean claimvaluetypes.")]
[DataRow("@item.rating eq @claims.emprating)", "rating eq 4.2)", DisplayName = "Valid policy parsing test for double claimvaluetype.")]
@@ -1385,11 +1444,11 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage()
};
//Add identity object to the Mock context object.
- ClaimsIdentity identityWithClientRoleHeaderClaim = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE);
- identityWithClientRoleHeaderClaim.AddClaims(claims);
+ ClaimsIdentity identity = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE);
+ identity.AddClaims(claims);
ClaimsPrincipal principal = new();
- principal.AddIdentity(identityWithClientRoleHeaderClaim);
+ principal.AddIdentity(identity);
context.Setup(x => x.User).Returns(principal);
context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE);
@@ -1403,7 +1462,7 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage()
Assert.AreEqual(expected: "Aa_0RISCzzZ-abC1De2fGHIjKLMNo123pQ4rStUVWXY", actual: claimsInRequestContext["sub"], message: "Expected the sub claim to be present.");
Assert.AreEqual(expected: "55296aad-ea7f-4c44-9a4c-bb1e8d43a005", actual: claimsInRequestContext["oid"], message: "Expected the oid claim to be present.");
Assert.AreEqual(claimsInRequestContext[AuthenticationOptions.ROLE_CLAIM_TYPE], actual: TEST_ROLE, message: "The roles claim should have the value:" + TEST_ROLE);
- Assert.AreEqual(expected: "[\"" + TEST_ROLE + "\",\"ROLE2\",\"ROLE3\"]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
+ Assert.AreEqual(expected: @"[""ROLE2"",""ROLE3""]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
}
///
From 04819a632a3a827b2fc13efc24e2aeb0f5d1de44 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Feb 2026 22:53:13 +0000
Subject: [PATCH 04/19] Fix incorrect ORIGINAL_ROLE_CLAIM_TYPE assertion in
UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage test
Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com>
---
.../Authorization/AuthorizationResolverUnitTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index 4c051a1842..0795efc8da 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -1462,7 +1462,7 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage()
Assert.AreEqual(expected: "Aa_0RISCzzZ-abC1De2fGHIjKLMNo123pQ4rStUVWXY", actual: claimsInRequestContext["sub"], message: "Expected the sub claim to be present.");
Assert.AreEqual(expected: "55296aad-ea7f-4c44-9a4c-bb1e8d43a005", actual: claimsInRequestContext["oid"], message: "Expected the oid claim to be present.");
Assert.AreEqual(claimsInRequestContext[AuthenticationOptions.ROLE_CLAIM_TYPE], actual: TEST_ROLE, message: "The roles claim should have the value:" + TEST_ROLE);
- Assert.AreEqual(expected: @"[""ROLE2"",""ROLE3""]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
+ Assert.AreEqual(expected: "[\"" + TEST_ROLE + "\",\"ROLE2\",\"ROLE3\"]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
}
///
From 80b18e6b91d6fbb896e686c5d535926b55e45323 Mon Sep 17 00:00:00 2001
From: aaron burtle
Date: Thu, 5 Mar 2026 10:41:09 -0800
Subject: [PATCH 05/19] addressing comments, deep copy
---
src/Auth/AuthorizationMetadataHelpers.cs | 32 +++++++++++++++++++
.../Authorization/AuthorizationResolver.cs | 9 ++++--
.../UnitTests/RequestParserUnitTests.cs | 4 ++-
3 files changed, 41 insertions(+), 4 deletions(-)
diff --git a/src/Auth/AuthorizationMetadataHelpers.cs b/src/Auth/AuthorizationMetadataHelpers.cs
index d26e6af447..1ef13e663e 100644
--- a/src/Auth/AuthorizationMetadataHelpers.cs
+++ b/src/Auth/AuthorizationMetadataHelpers.cs
@@ -55,6 +55,23 @@ public class RoleMetadata
/// Given the key (operation) returns the associated OperationMetadata object.
///
public Dictionary OperationToColumnMap { get; set; } = new();
+
+ ///
+ /// Creates a deep clone of this RoleMetadata instance so that mutations
+ /// to the clone do not affect the original (and vice versa).
+ /// This is critical when copying permissions from one role to another
+ /// (e.g., anonymous → authenticated) to prevent shared mutable state.
+ ///
+ public RoleMetadata DeepClone()
+ {
+ RoleMetadata clone = new();
+ foreach ((EntityActionOperation operation, OperationMetadata metadata) in OperationToColumnMap)
+ {
+ clone.OperationToColumnMap[operation] = metadata.DeepClone();
+ }
+
+ return clone;
+ }
}
///
@@ -68,4 +85,19 @@ public class OperationMetadata
public HashSet Included { get; set; } = new();
public HashSet Excluded { get; set; } = new();
public HashSet AllowedExposedColumns { get; set; } = new();
+
+ ///
+ /// Creates a deep clone of this OperationMetadata instance so that
+ /// mutations to the clone do not affect the original (and vice versa).
+ ///
+ public OperationMetadata DeepClone()
+ {
+ return new OperationMetadata
+ {
+ DatabasePolicy = DatabasePolicy,
+ Included = new HashSet(Included),
+ Excluded = new HashSet(Excluded),
+ AllowedExposedColumns = new HashSet(AllowedExposedColumns)
+ };
+ }
}
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index ab9805bc51..3030e172c7 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -395,6 +395,8 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig)
///
/// Helper method to copy over permissions from anonymous role to authenticated role in the case
/// when anonymous role is defined for an entity in the config but authenticated role is not.
+ /// Uses deep cloning to ensure the authenticated role's RoleMetadata is a separate instance
+ /// from anonymous, preventing shared mutable state between the two roles.
///
/// The EntityMetadata for the entity for which we want to copy permissions
/// from anonymous to authenticated role.
@@ -403,9 +405,10 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole(
EntityMetadata entityToRoleMap,
HashSet allowedColumnsForAnonymousRole)
{
- // Using assignment operator overrides the existing value for the key /
- // adds a new entry for (key,value) pair if absent, to the map.
- entityToRoleMap.RoleToOperationMap[ROLE_AUTHENTICATED] = entityToRoleMap.RoleToOperationMap[ROLE_ANONYMOUS];
+ // Deep clone the RoleMetadata so that anonymous and authenticated roles
+ // do not share mutable OperationMetadata instances. Without deep cloning,
+ // any future mutation of one role's permissions would silently affect the other.
+ entityToRoleMap.RoleToOperationMap[ROLE_AUTHENTICATED] = entityToRoleMap.RoleToOperationMap[ROLE_ANONYMOUS].DeepClone();
// Copy over OperationToRolesMap for authenticated role from anonymous role.
Dictionary allowedOperationMap =
diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
index 4da3266271..6cc6b6b7ad 100644
--- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
+++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+#nullable enable
+
using Azure.DataApiBuilder.Core.Parsers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -52,7 +54,7 @@ public void ExtractRawQueryParameter_PreservesEncoding(string queryString, strin
public void ExtractRawQueryParameter_ReturnsNull_WhenParameterNotFound(string? queryString, string parameterName)
{
// Call the internal method directly (no reflection needed)
- string? result = RequestParser.ExtractRawQueryParameter(queryString, parameterName);
+ string? result = RequestParser.ExtractRawQueryParameter(queryString!, parameterName);
Assert.IsNull(result,
$"Expected null but got '{result}' for parameter '{parameterName}' in query '{queryString}'");
From 7d31cf5244322f567d9d65104b91b4e92aee5724 Mon Sep 17 00:00:00 2001
From: aaron burtle
Date: Thu, 5 Mar 2026 10:49:55 -0800
Subject: [PATCH 06/19] add missing test coverage
---
src/Cli.Tests/ConfigureOptionsTests.cs | 137 ++++++++++++++++++++++---
1 file changed, 120 insertions(+), 17 deletions(-)
diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs
index b368227a75..1dc67e9936 100644
--- a/src/Cli.Tests/ConfigureOptionsTests.cs
+++ b/src/Cli.Tests/ConfigureOptionsTests.cs
@@ -1052,31 +1052,134 @@ public void TestUpdateDataSourceHealthName(string healthName)
Assert.AreEqual(2000, config.DataSource.Health.ThresholdMs);
}
- /// Tests that running "dab configure --runtime.mcp.description {value}" on a config with various values results
- /// in runtime config update. Takes in updated value for mcp.description and
- /// validates whether the runtime config reflects those updated values
+ ///
+ /// Validates that `dab configure --show-effective-permissions` correctly displays
+ /// effective permissions without modifying the config file.
+ /// Covers:
+ /// 1. Entities are listed alphabetically.
+ /// 2. Explicitly configured roles show their actions.
+ /// 3. When only anonymous is configured, authenticated inherits from anonymous.
+ /// 4. An inheritance note is emitted for unconfigured named roles.
+ /// 5. The config file is not modified.
///
[DataTestMethod]
- [DataRow("This MCP provides access to the Products database and should be used to answer product-related or inventory-related questions from the user.", DisplayName = "Set MCP description.")]
- [DataRow("Use this server for customer data queries.", DisplayName = "Set MCP description with short text.")]
- public void TestConfigureDescriptionForMcpSettings(string descriptionValue)
+ [DataRow(
+ true, false,
+ "authenticated", "Read (inherited from: anonymous)",
+ "Any unconfigured named role inherits from: anonymous",
+ DisplayName = "Only anonymous defined: authenticated inherits from anonymous.")]
+ [DataRow(
+ true, true,
+ null, null,
+ "Any unconfigured named role inherits from: authenticated",
+ DisplayName = "Both anonymous and authenticated defined: named roles inherit from authenticated.")]
+ public void TestShowEffectivePermissions(
+ bool hasAnonymous,
+ bool hasAuthenticated,
+ string? expectedInheritedRole,
+ string? expectedInheritedActionsSubstring,
+ string expectedInheritanceNote)
{
- // Arrange -> all the setup which includes creating options.
- SetupFileSystemWithInitialConfig(INITIAL_CONFIG);
+ // Arrange: build a config with two entities (Zebra before Alpha to verify sorting)
+ // and the specified role combinations.
+ string permissionsJson = "";
+ List perms = new();
+ if (hasAnonymous)
+ {
+ perms.Add(@"{ ""role"": ""anonymous"", ""actions"": [""read""] }");
+ }
+
+ if (hasAuthenticated)
+ {
+ perms.Add(@"{ ""role"": ""authenticated"", ""actions"": [""create"", ""read""] }");
+ }
+
+ permissionsJson = string.Join(",", perms);
- // Act: Attempts to update mcp.description value
+ string configJson = @"
+ {
+ ""$schema"": ""test"",
+ ""data-source"": {
+ ""database-type"": ""mssql"",
+ ""connection-string"": ""testconnectionstring""
+ },
+ ""runtime"": {
+ ""rest"": { ""enabled"": true, ""path"": ""/api"" },
+ ""graphql"": { ""enabled"": true, ""path"": ""/graphql"", ""allow-introspection"": true },
+ ""host"": {
+ ""mode"": ""development"",
+ ""cors"": { ""origins"": [], ""allow-credentials"": false },
+ ""authentication"": { ""provider"": ""StaticWebApps"" }
+ }
+ },
+ ""entities"": {
+ ""Zebra"": {
+ ""source"": ""ZebraTable"",
+ ""permissions"": [" + permissionsJson + @"]
+ },
+ ""Alpha"": {
+ ""source"": ""AlphaTable"",
+ ""permissions"": [" + permissionsJson + @"]
+ }
+ }
+ }";
+
+ _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(configJson));
+ string configBefore = _fileSystem.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
+
+ // Capture logger output via a StringWriter on Console
+ StringWriter writer = new();
+ Console.SetOut(writer);
+
+ // Act
ConfigureOptions options = new(
- runtimeMcpDescription: descriptionValue,
+ showEffectivePermissions: true,
config: TEST_RUNTIME_CONFIG_FILE
);
- bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
+ bool isSuccess = ConfigGenerator.TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert: operation succeeded
+ Assert.IsTrue(isSuccess, "TryShowEffectivePermissions should return true.");
+
+ // Assert: config file is unchanged (read-only operation)
+ string configAfter = _fileSystem.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
+ Assert.AreEqual(configBefore, configAfter, "Config file should not be modified by --show-effective-permissions.");
+
+ // Note: TryShowEffectivePermissions uses ILogger (not Console), so we verify
+ // behavior indirectly by re-checking the logic via the RuntimeConfig.
+ // Parse config and verify the expected inheritance rules hold.
+ Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config));
+
+ // Verify alphabetical entity ordering
+ string[] entityNames = config!.Entities.Select(e => e.Key).ToArray();
+ string[] sortedNames = entityNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
+ CollectionAssert.AreEqual(sortedNames, new[] { "Alpha", "Zebra" },
+ "Entities should be listed alphabetically.");
+
+ // Verify entity permission structure matches expectations
+ Entity firstEntity = config.Entities[sortedNames[0]];
+ bool configHasAnonymous = firstEntity.Permissions.Any(p => p.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase));
+ bool configHasAuthenticated = firstEntity.Permissions.Any(p => p.Role.Equals("authenticated", StringComparison.OrdinalIgnoreCase));
+ Assert.AreEqual(hasAnonymous, configHasAnonymous);
+ Assert.AreEqual(hasAuthenticated, configHasAuthenticated);
+
+ // When only anonymous is defined, verify inherited role line would be generated
+ if (hasAnonymous && !hasAuthenticated)
+ {
+ Assert.IsNotNull(expectedInheritedRole, "Expected inherited role should be 'authenticated'.");
+ Assert.AreEqual("authenticated", expectedInheritedRole);
- // Assert: Validate the Description is updated
- Assert.IsTrue(isSuccess);
- string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
- Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? runtimeConfig));
- Assert.IsNotNull(runtimeConfig.Runtime?.Mcp?.Description);
- Assert.AreEqual(descriptionValue, runtimeConfig.Runtime.Mcp.Description);
+ // Verify the anonymous actions would be inherited
+ EntityPermission anonPerm = firstEntity.Permissions.First(p => p.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase));
+ string inheritedActions = string.Join(", ", anonPerm.Actions.Select(a => a.Action.ToString()));
+ Assert.AreEqual("Read", inheritedActions, "Inherited actions should match anonymous role's actions.");
+ }
+
+ // When authenticated is explicitly defined, no inheritance line for authenticated
+ if (hasAuthenticated)
+ {
+ Assert.IsNull(expectedInheritedRole, "No inherited role line when authenticated is explicitly configured.");
+ }
}
///
From c5b50557da35c091d40b120b41677c1868e93d21 Mon Sep 17 00:00:00 2001
From: aaron burtle
Date: Thu, 5 Mar 2026 10:55:55 -0800
Subject: [PATCH 07/19] move logic to concrete class
---
src/Auth/IAuthorizationResolver.cs | 23 +---------------
.../Authorization/AuthorizationResolver.cs | 26 +++++++++++++++++++
2 files changed, 27 insertions(+), 22 deletions(-)
diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs
index eaef1eb777..14e845d20a 100644
--- a/src/Auth/IAuthorizationResolver.cs
+++ b/src/Auth/IAuthorizationResolver.cs
@@ -152,26 +152,5 @@ public static IEnumerable GetRolesForOperation(
/// The role from the X-MS-API-ROLE header.
/// The roles listed on the @authorize directive.
/// True if the client role should be allowed through the gate.
- public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles)
- {
- if (directiveRoles is null || directiveRoles.Count == 0)
- {
- return false;
- }
-
- // Explicit match — role is directly listed.
- if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- // Role inheritance: any non-anonymous role inherits from 'authenticated'.
- if (!clientRole.Equals("anonymous", StringComparison.OrdinalIgnoreCase) &&
- directiveRoles.Any(role => role.Equals("authenticated", StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- return false;
- }
+ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles);
}
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index 3030e172c7..e6bbcf971a 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -326,6 +326,8 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig)
// When a wildcard (*) is defined for Excluded columns, all of the table's
// columns must be resolved and placed in the operationToColumn Key/Value store.
+ // This is especially relevant for delete requests, where the operation may not include
+ // any columns, but the policy still needs to be evaluated.
if (entityAction.Fields.Exclude is null ||
(entityAction.Fields.Exclude.Count == 1 && entityAction.Fields.Exclude.Contains(WILDCARD)))
{
@@ -805,6 +807,30 @@ public IEnumerable GetRolesForEntity(string entityName)
return EntityPermissionsMap[entityName].RoleToOperationMap.Keys;
}
+ ///
+ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles)
+ {
+ if (directiveRoles is null || directiveRoles.Count == 0)
+ {
+ return false;
+ }
+
+ // Explicit match — role is directly listed.
+ if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ // Role inheritance: any non-anonymous role inherits from 'authenticated'.
+ if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
+ directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
///
/// Returns the collection of roles which can perform {operation} the provided field.
/// Applicable to GraphQL field directive @authorize on ObjectType fields.
From 6b64910914ca82d63dbfffd1768de3f368011adb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Mar 2026 19:21:25 +0000
Subject: [PATCH 08/19] Move IsRoleAllowedByDirective from default interface
method to concrete AuthorizationResolver; add CLI tests for
--show-effective-permissions
Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com>
---
src/Auth/IAuthorizationResolver.cs | 2 +-
src/Cli.Tests/ConfigureOptionsTests.cs | 279 ++++++++++++++++++
.../Authorization/AuthorizationResolver.cs | 24 ++
3 files changed, 304 insertions(+), 1 deletion(-)
diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs
index 14e845d20a..514cab13a2 100644
--- a/src/Auth/IAuthorizationResolver.cs
+++ b/src/Auth/IAuthorizationResolver.cs
@@ -143,7 +143,7 @@ public static IEnumerable GetRolesForOperation(
/// schema-level authorization gate for a specific set of directive roles.
/// Centralizes the role inheritance logic so that callers (e.g. GraphQLAuthorizationHandler)
/// do not need to duplicate inheritance rules.
- ///
+ ///
/// Inheritance chain: named-role → authenticated → anonymous → none.
/// - If the role is explicitly listed in the directive roles, return true.
/// - If the role is not 'anonymous' and 'authenticated' is listed, return true (inheritance).
diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs
index 1dc67e9936..8dfe55a70e 100644
--- a/src/Cli.Tests/ConfigureOptionsTests.cs
+++ b/src/Cli.Tests/ConfigureOptionsTests.cs
@@ -1182,6 +1182,257 @@ public void TestShowEffectivePermissions(
}
}
+ ///
+ /// Validates that --show-effective-permissions returns true and outputs entities sorted a-z by name.
+ ///
+ [TestMethod]
+ public void TestShowEffectivePermissions_EntitiesSortedAlphabetically()
+ {
+ // Arrange: Config with "Zebra" entity before "Apple" entity (insertion order reversed).
+ string config = $@"{{
+ {SAMPLE_SCHEMA_DATA_SOURCE},
+ {RUNTIME_SECTION},
+ ""entities"": {{
+ ""Zebra"": {{
+ ""source"": ""dbo.Zebra"",
+ ""permissions"": [
+ {{ ""role"": ""anonymous"", ""actions"": [""read""] }}
+ ]
+ }},
+ ""Apple"": {{
+ ""source"": ""dbo.Apple"",
+ ""permissions"": [
+ {{ ""role"": ""anonymous"", ""actions"": [""read""] }}
+ ]
+ }}
+ }}
+ }}";
+
+ List logMessages = new();
+ ListLogger logger = new(logMessages);
+ SetLoggerForCliConfigGenerator(logger);
+ _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config));
+
+ ConfigureOptions options = new(
+ config: TEST_RUNTIME_CONFIG_FILE,
+ showEffectivePermissions: true
+ );
+
+ // Act
+ bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+ int appleIndex = logMessages.FindIndex(m => m.Contains("Apple"));
+ int zebraIndex = logMessages.FindIndex(m => m.Contains("Zebra"));
+ Assert.IsTrue(appleIndex >= 0, "Expected 'Apple' entity in output.");
+ Assert.IsTrue(zebraIndex >= 0, "Expected 'Zebra' entity in output.");
+ Assert.IsTrue(appleIndex < zebraIndex, "Expected 'Apple' to appear before 'Zebra' in output.");
+ }
+
+ ///
+ /// Validates that --show-effective-permissions outputs roles sorted a-z within each entity.
+ ///
+ [TestMethod]
+ public void TestShowEffectivePermissions_RolesSortedAlphabeticallyWithinEntity()
+ {
+ // Arrange: Config with roles "zebra-role" before "admin" (insertion order reversed).
+ string config = $@"{{
+ {SAMPLE_SCHEMA_DATA_SOURCE},
+ {RUNTIME_SECTION},
+ ""entities"": {{
+ ""Book"": {{
+ ""source"": ""dbo.Book"",
+ ""permissions"": [
+ {{ ""role"": ""zebra-role"", ""actions"": [""read""] }},
+ {{ ""role"": ""admin"", ""actions"": [""create"", ""read"", ""update"", ""delete""] }}
+ ]
+ }}
+ }}
+ }}";
+
+ List logMessages = new();
+ ListLogger logger = new(logMessages);
+ SetLoggerForCliConfigGenerator(logger);
+ _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config));
+
+ ConfigureOptions options = new(
+ config: TEST_RUNTIME_CONFIG_FILE,
+ showEffectivePermissions: true
+ );
+
+ // Act
+ bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+
+ // Within the entity, "admin" should appear before "zebra-role".
+ int adminIndex = logMessages.FindIndex(m => m.Contains("admin"));
+ int zebraRoleIndex = logMessages.FindIndex(m => m.Contains("zebra-role"));
+ Assert.IsTrue(adminIndex >= 0, "Expected 'admin' role in output.");
+ Assert.IsTrue(zebraRoleIndex >= 0, "Expected 'zebra-role' role in output.");
+ Assert.IsTrue(adminIndex < zebraRoleIndex, "Expected 'admin' to appear before 'zebra-role' in output.");
+ }
+
+ ///
+ /// Validates that --show-effective-permissions shows the authenticated-inherits-anonymous line
+ /// when anonymous is configured but authenticated is not.
+ ///
+ [TestMethod]
+ public void TestShowEffectivePermissions_AuthenticatedInheritsAnonymousNote()
+ {
+ // Arrange: anonymous defined, authenticated not defined.
+ string config = $@"{{
+ {SAMPLE_SCHEMA_DATA_SOURCE},
+ {RUNTIME_SECTION},
+ ""entities"": {{
+ ""Book"": {{
+ ""source"": ""dbo.Book"",
+ ""permissions"": [
+ {{ ""role"": ""anonymous"", ""actions"": [""read""] }}
+ ]
+ }}
+ }}
+ }}";
+
+ List logMessages = new();
+ ListLogger logger = new(logMessages);
+ SetLoggerForCliConfigGenerator(logger);
+ _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config));
+
+ ConfigureOptions options = new(
+ config: TEST_RUNTIME_CONFIG_FILE,
+ showEffectivePermissions: true
+ );
+
+ // Act
+ bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+
+ // Should show "authenticated" inheriting from "anonymous".
+ bool hasAuthenticatedInheritedLine = logMessages.Any(m =>
+ m.Contains("authenticated") && m.Contains("inherited from") && m.Contains("anonymous"));
+ Assert.IsTrue(hasAuthenticatedInheritedLine, "Expected a line showing authenticated inherits from anonymous.");
+
+ // Should show inheritance note for unconfigured named roles.
+ // When only anonymous is defined, the note points to "anonymous" (since authenticated
+ // is itself shown as inheriting from anonymous via the line above).
+ bool hasInheritanceNote = logMessages.Any(m =>
+ m.Contains("unconfigured named role") && m.Contains("anonymous"));
+ Assert.IsTrue(hasInheritanceNote, "Expected an inheritance note pointing to 'anonymous'.");
+ }
+
+ ///
+ /// Validates that --show-effective-permissions does not show an authenticated-inherits-anonymous
+ /// line when authenticated is explicitly configured for the entity.
+ ///
+ [TestMethod]
+ public void TestShowEffectivePermissions_NoInheritanceNoteWhenAuthenticatedExplicitlyConfigured()
+ {
+ // Arrange: Both anonymous and authenticated explicitly defined.
+ string config = $@"{{
+ {SAMPLE_SCHEMA_DATA_SOURCE},
+ {RUNTIME_SECTION},
+ ""entities"": {{
+ ""Book"": {{
+ ""source"": ""dbo.Book"",
+ ""permissions"": [
+ {{ ""role"": ""anonymous"", ""actions"": [""read""] }},
+ {{ ""role"": ""authenticated"", ""actions"": [""read"", ""create""] }}
+ ]
+ }}
+ }}
+ }}";
+
+ List logMessages = new();
+ ListLogger logger = new(logMessages);
+ SetLoggerForCliConfigGenerator(logger);
+ _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config));
+
+ ConfigureOptions options = new(
+ config: TEST_RUNTIME_CONFIG_FILE,
+ showEffectivePermissions: true
+ );
+
+ // Act
+ bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+
+ // Should NOT show an "authenticated inherits from anonymous" line.
+ bool hasUnexpectedInheritanceLine = logMessages.Any(m =>
+ m.Contains("authenticated") && m.Contains("inherited from") && m.Contains("anonymous"));
+ Assert.IsFalse(hasUnexpectedInheritanceLine,
+ "Should not show authenticated-inherits-anonymous when authenticated is explicitly configured.");
+ }
+
+ ///
+ /// Validates that --show-effective-permissions does not modify the config file.
+ ///
+ [TestMethod]
+ public void TestShowEffectivePermissions_DoesNotModifyConfigFile()
+ {
+ // Arrange
+ string config = $@"{{
+ {SAMPLE_SCHEMA_DATA_SOURCE},
+ {RUNTIME_SECTION},
+ ""entities"": {{
+ ""Book"": {{
+ ""source"": ""dbo.Book"",
+ ""permissions"": [
+ {{ ""role"": ""anonymous"", ""actions"": [""read""] }}
+ ]
+ }}
+ }}
+ }}";
+
+ List logMessages = new();
+ ListLogger logger = new(logMessages);
+ SetLoggerForCliConfigGenerator(logger);
+ _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config));
+ string originalContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
+
+ ConfigureOptions options = new(
+ config: TEST_RUNTIME_CONFIG_FILE,
+ showEffectivePermissions: true
+ );
+
+ // Act
+ bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+ string afterContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
+ Assert.AreEqual(originalContent, afterContent, "Config file should not be modified by --show-effective-permissions.");
+ }
+
+ ///
+ /// Validates that --show-effective-permissions returns false when the config file does not exist.
+ ///
+ [TestMethod]
+ public void TestShowEffectivePermissions_ReturnsFalseWhenConfigMissing()
+ {
+ // Arrange: no config file added to the file system.
+ List logMessages = new();
+ ListLogger logger = new(logMessages);
+ SetLoggerForCliConfigGenerator(logger);
+
+ ConfigureOptions options = new(
+ config: "nonexistent-config.json",
+ showEffectivePermissions: true
+ );
+
+ // Act
+ bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsFalse(isSuccess);
+ }
+
///
/// Sets up the mock file system with an initial configuration file.
/// This method adds a config file to the mock file system and verifies its existence.
@@ -1197,5 +1448,33 @@ private void SetupFileSystemWithInitialConfig(string jsonConfig)
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? config));
Assert.IsNotNull(config.Runtime);
}
+
+ ///
+ /// A simple ILogger implementation that records all log messages to a list,
+ /// enabling tests to assert on log output without redirecting console streams.
+ ///
+ private sealed class ListLogger : ILogger
+ {
+ private readonly List _messages;
+
+ public ListLogger(List messages)
+ {
+ _messages = messages;
+ }
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ _messages.Add(formatter(state, exception));
+ }
+ }
}
}
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index e6bbcf971a..2f1c690018 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -435,6 +435,30 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole(
}
}
+ ///
+ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles)
+ {
+ if (directiveRoles is null || directiveRoles.Count == 0)
+ {
+ return false;
+ }
+
+ // Explicit match — role is directly listed.
+ if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ // Role inheritance: any non-anonymous role inherits from 'authenticated'.
+ if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
+ directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
///
/// Returns the effective role name for permission lookups, implementing role inheritance.
/// System roles (anonymous, authenticated) always resolve to themselves.
From e8158d2d49c88c1ae87ca02cb3c612b0380b51a5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Mar 2026 19:25:20 +0000
Subject: [PATCH 09/19] Remove duplicate IsRoleAllowedByDirective
implementation from AuthorizationResolver
Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com>
---
.../Authorization/AuthorizationResolver.cs | 31 +------------------
1 file changed, 1 insertion(+), 30 deletions(-)
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index 2f1c690018..689617f711 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -820,41 +820,12 @@ private static string GetClaimValue(Claim claim)
}
}
- ///
- /// Get list of roles defined for entity within runtime configuration.. This is applicable for GraphQL when creating authorization
- /// directive on Object type.
- ///
- /// Name of entity.
- /// Collection of role names.
+ ///
public IEnumerable GetRolesForEntity(string entityName)
{
return EntityPermissionsMap[entityName].RoleToOperationMap.Keys;
}
- ///
- public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles)
- {
- if (directiveRoles is null || directiveRoles.Count == 0)
- {
- return false;
- }
-
- // Explicit match — role is directly listed.
- if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- // Role inheritance: any non-anonymous role inherits from 'authenticated'.
- if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
- directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- return false;
- }
-
///
/// Returns the collection of roles which can perform {operation} the provided field.
/// Applicable to GraphQL field directive @authorize on ObjectType fields.
From 6a52e373134e8624ab46563bfc0f2ebc905817d6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Mar 2026 22:56:37 +0000
Subject: [PATCH 10/19] Remove duplicate
TestShowEffectivePermissions_DoesNotModifyConfigFile test
Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
---
src/Cli.Tests/ConfigureOptionsTests.cs | 40 --------------------------
1 file changed, 40 deletions(-)
diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs
index 775d7a909e..07dc4d86d4 100644
--- a/src/Cli.Tests/ConfigureOptionsTests.cs
+++ b/src/Cli.Tests/ConfigureOptionsTests.cs
@@ -1370,46 +1370,6 @@ public void TestShowEffectivePermissions_NoInheritanceNoteWhenAuthenticatedExpli
"Should not show authenticated-inherits-anonymous when authenticated is explicitly configured.");
}
- ///
- /// Validates that --show-effective-permissions does not modify the config file.
- ///
- [TestMethod]
- public void TestShowEffectivePermissions_DoesNotModifyConfigFile()
- {
- // Arrange
- string config = $@"{{
- {SAMPLE_SCHEMA_DATA_SOURCE},
- {RUNTIME_SECTION},
- ""entities"": {{
- ""Book"": {{
- ""source"": ""dbo.Book"",
- ""permissions"": [
- {{ ""role"": ""anonymous"", ""actions"": [""read""] }}
- ]
- }}
- }}
- }}";
-
- List logMessages = new();
- ListLogger logger = new(logMessages);
- SetLoggerForCliConfigGenerator(logger);
- _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config));
- string originalContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
-
- ConfigureOptions options = new(
- config: TEST_RUNTIME_CONFIG_FILE,
- showEffectivePermissions: true
- );
-
- // Act
- bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!);
-
- // Assert
- Assert.IsTrue(isSuccess);
- string afterContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
- Assert.AreEqual(originalContent, afterContent, "Config file should not be modified by --show-effective-permissions.");
- }
-
///
/// Validates that --show-effective-permissions returns false when the config file does not exist.
///
From b412a4eb2fbfba0a1e6b88b60344525f48b75e40 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:10:59 +0000
Subject: [PATCH 11/19] =?UTF-8?q?Fix=20IsRoleAllowedByDirective=20to=20inc?=
=?UTF-8?q?lude=20authenticated=E2=86=92anonymous=20inheritance;=20add=20u?=
=?UTF-8?q?nit=20tests?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
---
.../Authorization/AuthorizationResolver.cs | 7 +++--
.../AuthorizationResolverUnitTests.cs | 31 +++++++++++++++++++
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index 689617f711..74f9a63e09 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -449,9 +449,12 @@ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? d
return true;
}
- // Role inheritance: any non-anonymous role inherits from 'authenticated'.
+ // Role inheritance: 'authenticated' inherits from 'anonymous', and named roles inherit from
+ // 'authenticated' (which itself may inherit from 'anonymous'). Any non-anonymous role is
+ // therefore allowed when either 'authenticated' or 'anonymous' is listed in the directive.
if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
- directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)))
+ (directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) ||
+ directiveRoles.Any(role => role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase))))
{
return true;
}
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index 0795efc8da..bae8484cf1 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -625,6 +625,37 @@ public void TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions()
"'authenticated' should retain its own Create permission.");
}
+ ///
+ /// Tests for IsRoleAllowedByDirective covering the full role inheritance chain at the
+ /// GraphQL @authorize directive gate.
+ /// Inheritance chain: named-role inherits from 'authenticated'; 'authenticated' inherits
+ /// from 'anonymous'. So any non-anonymous role is allowed when 'authenticated' OR 'anonymous'
+ /// is listed in the directive roles.
+ ///
+ [DataTestMethod]
+ [DataRow(null, "admin", false, DisplayName = "Null directive roles — deny all")]
+ [DataRow(new string[0], "admin", false, DisplayName = "Empty directive roles — deny all")]
+ [DataRow(new[] { "admin" }, "admin", true, DisplayName = "Explicit match — allowed")]
+ [DataRow(new[] { "admin" }, "other", false, DisplayName = "No match, no system roles — denied")]
+ [DataRow(new[] { "authenticated" }, "Writer", true, DisplayName = "Named role inherits from authenticated")]
+ [DataRow(new[] { "authenticated" }, "anonymous", false, DisplayName = "anonymous does NOT inherit from authenticated")]
+ [DataRow(new[] { "anonymous" }, "authenticated", true, DisplayName = "authenticated inherits from anonymous")]
+ [DataRow(new[] { "anonymous" }, "Writer", true, DisplayName = "Named role inherits from anonymous via authenticated")]
+ [DataRow(new[] { "anonymous" }, "anonymous", true, DisplayName = "anonymous explicit match when anonymous listed")]
+ [DataRow(new[] { "ANONYMOUS" }, "authenticated", true, DisplayName = "Case-insensitive: ANONYMOUS directive allows authenticated")]
+ [DataRow(new[] { "AUTHENTICATED" }, "Writer", true, DisplayName = "Case-insensitive: AUTHENTICATED directive allows named role")]
+ public void TestIsRoleAllowedByDirective(string[]? directiveRoles, string clientRole, bool expected)
+ {
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(
+ AuthorizationHelpers.InitRuntimeConfig(
+ entityName: AuthorizationHelpers.TEST_ENTITY,
+ roleName: AuthorizationResolver.ROLE_ANONYMOUS,
+ operation: EntityActionOperation.Read));
+
+ bool actual = authZResolver.IsRoleAllowedByDirective(clientRole, directiveRoles);
+ Assert.AreEqual(expected, actual);
+ }
+
///
/// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName.
/// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc.
From 31a87562b46d8dd7ed9cf800117103d71c9350dc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:15:49 +0000
Subject: [PATCH 12/19] Revert unrelated change to RequestParserUnitTests.cs
Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
---
src/Service.Tests/UnitTests/RequestParserUnitTests.cs | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
index 6cc6b6b7ad..4da3266271 100644
--- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
+++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-#nullable enable
-
using Azure.DataApiBuilder.Core.Parsers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -54,7 +52,7 @@ public void ExtractRawQueryParameter_PreservesEncoding(string queryString, strin
public void ExtractRawQueryParameter_ReturnsNull_WhenParameterNotFound(string? queryString, string parameterName)
{
// Call the internal method directly (no reflection needed)
- string? result = RequestParser.ExtractRawQueryParameter(queryString!, parameterName);
+ string? result = RequestParser.ExtractRawQueryParameter(queryString, parameterName);
Assert.IsNull(result,
$"Expected null but got '{result}' for parameter '{parameterName}' in query '{queryString}'");
From 340f9a84baa32b40fecec59f09dcfe6ba2aa9822 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 01:07:39 +0000
Subject: [PATCH 13/19] Fix CI failures: restrict IsRoleAllowedByDirective
inheritance to unconfigured named roles only
Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
---
src/Auth/IAuthorizationResolver.cs | 7 ++-
.../Authorization/AuthorizationResolver.cs | 51 +++++++++++++++++--
.../AuthorizationResolverUnitTests.cs | 40 ++++++++++++---
3 files changed, 86 insertions(+), 12 deletions(-)
diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs
index 514cab13a2..3a961ece4d 100644
--- a/src/Auth/IAuthorizationResolver.cs
+++ b/src/Auth/IAuthorizationResolver.cs
@@ -146,8 +146,11 @@ public static IEnumerable GetRolesForOperation(
///
/// Inheritance chain: named-role → authenticated → anonymous → none.
/// - If the role is explicitly listed in the directive roles, return true.
- /// - If the role is not 'anonymous' and 'authenticated' is listed, return true (inheritance).
- /// - Otherwise, return false.
+ /// - If the role is 'authenticated' and 'anonymous' is listed, return true (inheritance).
+ /// - If the role is an unconfigured named role (not in any entity's explicit permissions)
+ /// and either 'authenticated' or 'anonymous' is listed, return true (inheritance).
+ /// - Explicitly configured named roles use strict matching only, to prevent unintended
+ /// access to operations outside their explicitly scoped permissions.
///
/// The role from the X-MS-API-ROLE header.
/// The roles listed on the @authorize directive.
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index 74f9a63e09..7c3c2fedb2 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -38,6 +38,13 @@ public class AuthorizationResolver : IAuthorizationResolver
public Dictionary EntityPermissionsMap { get; private set; } = new();
+ ///
+ /// Cached set of named roles that are explicitly configured in at least one entity's permissions.
+ /// Used by to determine whether a named role
+ /// should use strict directive matching vs. inheritance at the GraphQL @authorize gate.
+ ///
+ private HashSet _explicitlyConfiguredNamedRoles = new(StringComparer.OrdinalIgnoreCase);
+
public AuthorizationResolver(
RuntimeConfigProvider runtimeConfigProvider,
IMetadataProviderFactory metadataProviderFactory,
@@ -263,6 +270,9 @@ public static string GetRoleOfGraphQLRequest(IMiddlewareContext context)
///
private void SetEntityPermissionMap(RuntimeConfig runtimeConfig)
{
+ Dictionary newEntityPermissionsMap = new();
+ HashSet newExplicitlyConfiguredNamedRoles = new(StringComparer.OrdinalIgnoreCase);
+
foreach ((string entityName, Entity entity) in runtimeConfig.Entities)
{
EntityMetadata entityToRoleMap = new();
@@ -390,8 +400,21 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig)
CopyOverPermissionsFromAnonymousToAuthenticatedRole(entityToRoleMap, allowedColumnsForAnonymousRole);
}
- EntityPermissionsMap[entityName] = entityToRoleMap;
+ newEntityPermissionsMap[entityName] = entityToRoleMap;
+
+ // Collect all named roles (non-system) that are explicitly configured for this entity.
+ foreach (string roleName in entityToRoleMap.RoleToOperationMap.Keys)
+ {
+ if (!roleName.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
+ !roleName.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))
+ {
+ newExplicitlyConfiguredNamedRoles.Add(roleName);
+ }
+ }
}
+
+ EntityPermissionsMap = newEntityPermissionsMap;
+ _explicitlyConfiguredNamedRoles = newExplicitlyConfiguredNamedRoles;
}
///
@@ -449,10 +472,20 @@ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? d
return true;
}
- // Role inheritance: 'authenticated' inherits from 'anonymous', and named roles inherit from
- // 'authenticated' (which itself may inherit from 'anonymous'). Any non-anonymous role is
- // therefore allowed when either 'authenticated' or 'anonymous' is listed in the directive.
+ // 'authenticated' inherits from 'anonymous': allow authenticated when anonymous is in the directive.
+ if (clientRole.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase) &&
+ directiveRoles.Any(role => role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ // For named roles (non-system), only apply inheritance if the role is not explicitly
+ // configured in any entity. Explicitly configured roles have their own permission scopes
+ // and should only pass directives that list them (or a system role they'd inherit from)
+ // explicitly, preventing unintended access to operations outside their configured scope.
if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
+ !clientRole.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase) &&
+ !IsNamedRoleExplicitlyConfigured(clientRole) &&
(directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) ||
directiveRoles.Any(role => role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase))))
{
@@ -462,6 +495,16 @@ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? d
return false;
}
+ ///
+ /// Returns true if the given named role appears in the explicit permissions configuration of
+ /// any entity. Roles that are explicitly configured have their own permission scopes and
+ /// should not inherit permissions from system roles at the GraphQL directive level.
+ ///
+ private bool IsNamedRoleExplicitlyConfigured(string roleName)
+ {
+ return _explicitlyConfiguredNamedRoles.Contains(roleName);
+ }
+
///
/// Returns the effective role name for permission lookups, implementing role inheritance.
/// System roles (anonymous, authenticated) always resolve to themselves.
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index bae8484cf1..c16d362268 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -628,22 +628,23 @@ public void TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions()
///
/// Tests for IsRoleAllowedByDirective covering the full role inheritance chain at the
/// GraphQL @authorize directive gate.
- /// Inheritance chain: named-role inherits from 'authenticated'; 'authenticated' inherits
- /// from 'anonymous'. So any non-anonymous role is allowed when 'authenticated' OR 'anonymous'
- /// is listed in the directive roles.
+ /// Unconfigured named roles inherit: named-role inherits from 'authenticated'; 'authenticated'
+ /// inherits from 'anonymous'. Any unconfigured non-anonymous role is allowed when 'authenticated'
+ /// OR 'anonymous' is listed in the directive roles.
+ /// Explicitly configured named roles use strict matching only to prevent privilege escalation.
///
[DataTestMethod]
[DataRow(null, "admin", false, DisplayName = "Null directive roles — deny all")]
[DataRow(new string[0], "admin", false, DisplayName = "Empty directive roles — deny all")]
[DataRow(new[] { "admin" }, "admin", true, DisplayName = "Explicit match — allowed")]
[DataRow(new[] { "admin" }, "other", false, DisplayName = "No match, no system roles — denied")]
- [DataRow(new[] { "authenticated" }, "Writer", true, DisplayName = "Named role inherits from authenticated")]
+ [DataRow(new[] { "authenticated" }, "Writer", true, DisplayName = "Unconfigured named role inherits from authenticated")]
[DataRow(new[] { "authenticated" }, "anonymous", false, DisplayName = "anonymous does NOT inherit from authenticated")]
[DataRow(new[] { "anonymous" }, "authenticated", true, DisplayName = "authenticated inherits from anonymous")]
- [DataRow(new[] { "anonymous" }, "Writer", true, DisplayName = "Named role inherits from anonymous via authenticated")]
+ [DataRow(new[] { "anonymous" }, "Writer", true, DisplayName = "Unconfigured named role inherits from anonymous via authenticated")]
[DataRow(new[] { "anonymous" }, "anonymous", true, DisplayName = "anonymous explicit match when anonymous listed")]
[DataRow(new[] { "ANONYMOUS" }, "authenticated", true, DisplayName = "Case-insensitive: ANONYMOUS directive allows authenticated")]
- [DataRow(new[] { "AUTHENTICATED" }, "Writer", true, DisplayName = "Case-insensitive: AUTHENTICATED directive allows named role")]
+ [DataRow(new[] { "AUTHENTICATED" }, "Writer", true, DisplayName = "Case-insensitive: AUTHENTICATED directive allows unconfigured named role")]
public void TestIsRoleAllowedByDirective(string[]? directiveRoles, string clientRole, bool expected)
{
AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(
@@ -656,6 +657,33 @@ public void TestIsRoleAllowedByDirective(string[]? directiveRoles, string client
Assert.AreEqual(expected, actual);
}
+ ///
+ /// Tests that explicitly configured named roles use strict directive matching.
+ /// A role that is explicitly configured for any entity (even with restricted permissions)
+ /// will NOT inherit from system roles at the @authorize directive level, preventing
+ /// unintended access to operations outside its configured permission scope.
+ ///
+ [DataTestMethod]
+ [DataRow(new[] { "authenticated" }, "Writer", false, DisplayName = "Configured role does NOT inherit from authenticated when not in directive")]
+ [DataRow(new[] { "anonymous" }, "Writer", false, DisplayName = "Configured role does NOT inherit from anonymous when not in directive")]
+ [DataRow(new[] { "Writer" }, "Writer", true, DisplayName = "Configured role passes when explicitly listed in directive")]
+ [DataRow(new[] { "anonymous", "authenticated" }, "Writer", false, DisplayName = "Configured role denied even when both system roles in directive")]
+ public void TestIsRoleAllowedByDirective_ExplicitlyConfiguredRoleUsesStrictMatching(
+ string[] directiveRoles, string clientRole, bool expected)
+ {
+ // Configure 'Writer' as an explicitly restricted role (read-only) on the test entity.
+ // Even though 'authenticated' or 'anonymous' may be in the directive, 'Writer' should
+ // not inherit because it is an explicitly configured role with its own permission scope.
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(
+ AuthorizationHelpers.InitRuntimeConfig(
+ entityName: AuthorizationHelpers.TEST_ENTITY,
+ roleName: "Writer",
+ operation: EntityActionOperation.Read));
+
+ bool actual = authZResolver.IsRoleAllowedByDirective(clientRole, directiveRoles);
+ Assert.AreEqual(expected, actual);
+ }
+
///
/// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName.
/// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc.
From 876e15c9a818bd51452061e90f5d8810b2e68ebd Mon Sep 17 00:00:00 2001
From: aaron burtle
Date: Mon, 9 Mar 2026 14:03:52 -0700
Subject: [PATCH 14/19] test fix
---
.../Configuration/ConfigurationTests.cs | 6323 ++++++-----------
1 file changed, 2361 insertions(+), 3962 deletions(-)
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index aa12a7d465..3aba3c5fba 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -709,7 +709,21 @@ public void CleanupAfterEachTest()
{
if (File.Exists(CUSTOM_CONFIG_FILENAME))
{
- File.Delete(CUSTOM_CONFIG_FILENAME);
+ // Retry file deletion to handle cases where a TestServer or file watcher
+ // from the test hasn't fully released the file handle yet.
+ int maxRetries = 5;
+ for (int i = 0; i < maxRetries; i++)
+ {
+ try
+ {
+ File.Delete(CUSTOM_CONFIG_FILENAME);
+ break;
+ }
+ catch (IOException) when (i < maxRetries - 1)
+ {
+ Thread.Sleep(200 * (i + 1));
+ }
+ }
}
TestHelper.UnsetAllDABEnvironmentVariables();
@@ -906,7 +920,7 @@ public void MsSqlConnStringSupplementedWithAppNameProperty(
/// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.)
[DataTestMethod]
[DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", false, DisplayName = "[PGSQL]:DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'ApplicationName']")]
- [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", true, DisplayName = "[PGSQL]:DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'ApplicationName'.]")]
+ [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", true, DisplayName = "[PGSQL]:DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'ApplicationName'.")]
[DataRow("Host=foo;Username=testuser;Application Name=UserAppName", "Host=foo;Username=testuser;Application Name=UserAppName,", false, DisplayName = "[PGSQL]:DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.]")]
[DataRow("Host=foo;Username=testuser;Application Name=UserAppName", "Host=foo;Username=testuser;Application Name=UserAppName,", true, DisplayName = "[PGSQL]:DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'ApplicationName' property.]")]
public void PgSqlConnStringSupplementedWithAppNameProperty(
@@ -995,6 +1009,10 @@ public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName(
RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(databaseType, configProvidedConnString);
+ // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants.
+ string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent();
+ expectedDabModifiedConnString += resolvedAssemblyVersion;
+
// Act
bool configParsed = RuntimeConfigLoader.TryParseConfig(
json: runtimeConfig.ToJson(),
@@ -1012,396 +1030,116 @@ public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName(
message: "DAB did not properly set the 'Application Name' connection string property.");
}
- [TestMethod("Validates that once the configuration is set, the config controller isn't reachable."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestConflictAlreadySetConfiguration(string configurationEndpoint)
- {
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
-
- _ = await httpClient.PostAsync(configurationEndpoint, content);
- ValidateCosmosDbSetup(server);
-
- HttpResponseMessage result = await httpClient.PostAsync(configurationEndpoint, content);
- Assert.AreEqual(HttpStatusCode.Conflict, result.StatusCode);
- }
-
- [TestMethod("Validates that the config controller returns a conflict when using local configuration."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestConflictLocalConfiguration(string configurationEndpoint)
- {
- Environment.SetEnvironmentVariable
- (ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- ValidateCosmosDbSetup(server);
-
- JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
-
- HttpResponseMessage result =
- await httpClient.PostAsync(configurationEndpoint, content);
- Assert.AreEqual(HttpStatusCode.Conflict, result.StatusCode);
- }
-
- [TestMethod("Validates setting the configuration at runtime."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestSettingConfigurations(string configurationEndpoint)
- {
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
-
- HttpResponseMessage postResult =
- await httpClient.PostAsync(configurationEndpoint, content);
- Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode);
- }
-
- [TestMethod("Validates an invalid configuration returns a bad request."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestInvalidConfigurationAtRuntime(string configurationEndpoint)
- {
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint, "invalidString");
-
- HttpResponseMessage postResult =
- await httpClient.PostAsync(configurationEndpoint, content);
- Assert.AreEqual(HttpStatusCode.BadRequest, postResult.StatusCode);
- }
-
- [TestMethod("Validates a failure in one of the config updated handlers returns a bad request."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestSettingFailureConfigurations(string configurationEndpoint)
- {
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
-
- RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService();
- runtimeConfigProvider.RuntimeConfigLoadedHandlers.Add((_, _) =>
- {
- return Task.FromResult(false);
- });
-
- HttpResponseMessage postResult =
- await httpClient.PostAsync(configurationEndpoint, content);
-
- Assert.AreEqual(HttpStatusCode.BadRequest, postResult.StatusCode);
- }
-
- [TestMethod("Validates that the configuration endpoint doesn't return until all configuration loaded handlers have executed."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestLongRunningConfigUpdatedHandlerConfigurations(string configurationEndpoint)
- {
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
-
- RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService();
- bool taskHasCompleted = false;
- runtimeConfigProvider.RuntimeConfigLoadedHandlers.Add(async (_, _) =>
- {
- await Task.Delay(1000);
- taskHasCompleted = true;
- return true;
- });
-
- HttpResponseMessage postResult =
- await httpClient.PostAsync(configurationEndpoint, content);
-
- Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode);
- Assert.IsTrue(taskHasCompleted);
- }
-
///
- /// Tests that sending configuration to the DAB engine post-startup will properly hydrate
- /// the AuthorizationResolver by:
- /// 1. Validate that pre-configuration hydration requests result in 503 Service Unavailable
- /// 2. Validate that custom configuration hydration succeeds.
- /// 3. Validate that request to protected entity without role membership triggers Authorization Resolver
- /// to reject the request with HTTP 403 Forbidden.
- /// 4. Validate that request to protected entity with required role membership passes authorization requirements
- /// and succeeds with HTTP 200 OK.
- /// Note: This test is database engine agnostic, though requires denoting a database environment to fetch a usable
- /// connection string to complete the test. Most applicable to CI/CD test execution.
+ /// Validates that DAB supplements the MongoDB database connection strings with the property "ApplicationName" and
+ /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set.
+ /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted".
+ /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.)
+ /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names.
///
- [TestCategory(TestCategory.MSSQL)]
- [TestMethod("Validates setting the AuthN/Z configuration post-startup during runtime.")]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestSqlSettingPostStartupConfigurations(string configurationEndpoint)
+ /// connection string provided in the config.
+ /// Updated connection string with Application Name.
+ /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.)
+ [DataTestMethod]
+ [DataRow("mongodb://foo:27017" , "mongodb://foo:27017;Application Name=" , false, DisplayName = "[MONGODB]: DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'Application Name'.")]
+ [DataRow("mongodb://foo:27017;Application Name=CustAppName;" , "mongodb://foo:27017;Application Name=CustAppName," , false, DisplayName = "[MONGODB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.")]
+ [DataRow("mongodb://foo:27017;App=CustAppName;" , "mongodb://foo:27017;Application Name=CustAppName," , false, DisplayName = "[MONGODB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")]
+ [DataRow("mongodb://foo:27017" , "mongodb://foo:27017;Application Name=" , true , DisplayName = "[MONGODB]: DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'Application Name'.")]
+ [DataRow("mongodb://foo:27017;Application Name=CustAppName;" , "mongodb://foo:27017;Application Name=CustAppName," , true , DisplayName = "[MONGODB]: DAB appends DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'Application Name' property.")]
+ [DataRow("mongodb://foo:27017;App=CustAppName;" , "mongodb://foo:27017;Application Name=CustAppName," , true , DisplayName = "[MONGODB]: DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")]
+ #pragma warning restore format
+ public void MongoDbConnStringSupplementedWithAppNameProperty(
+ string configProvidedConnString,
+ string expectedDabModifiedConnString,
+ bool dabEnvOverride)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- RuntimeConfig configuration = AuthorizationHelpers.InitRuntimeConfig(
- entityName: POST_STARTUP_CONFIG_ENTITY,
- entitySource: POST_STARTUP_CONFIG_ENTITY_SOURCE,
- roleName: POST_STARTUP_CONFIG_ROLE,
- operation: EntityActionOperation.Read,
- includedCols: new HashSet() { "*" });
-
- JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint);
-
- HttpResponseMessage preConfigHydrationResult =
- await httpClient.GetAsync($"/{POST_STARTUP_CONFIG_ENTITY}");
- Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigHydrationResult.StatusCode);
+ // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set.
+ if (dabEnvOverride)
+ {
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted");
+ }
+ else
+ {
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null);
+ }
- HttpResponseMessage preConfigOpenApiDocumentExistence =
- await httpClient.GetAsync($"{RestRuntimeOptions.DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}");
- Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiDocumentExistence.StatusCode);
+ // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants.
+ string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent();
+ expectedDabModifiedConnString += resolvedAssemblyVersion;
- // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode.
- HttpResponseMessage preConfigOpenApiSwaggerEndpointAvailability =
- await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}");
- Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiSwaggerEndpointAvailability.StatusCode);
+ RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.MongoDB, configProvidedConnString);
- HttpStatusCode responseCode = await HydratePostStartupConfiguration(httpClient, content, configurationEndpoint, configuration.Runtime.Rest);
+ // Act
+ bool configParsed = RuntimeConfigLoader.TryParseConfig(
+ json: runtimeConfig.ToJson(),
+ config: out RuntimeConfig updatedRuntimeConfig,
+ replacementSettings: new(doReplaceEnvVar: true));
- // When the authorization resolver is properly configured, authorization will have failed
- // because no auth headers are present.
+ // Assert
Assert.AreEqual(
- expected: HttpStatusCode.Forbidden,
- actual: responseCode,
- message: "Configuration not yet hydrated after retry attempts..");
-
- // Sends a GET request to a protected entity which requires a specific role to access.
- // Authorization will pass because proper auth headers are present.
- HttpRequestMessage message = new(method: HttpMethod.Get, requestUri: $"api/{POST_STARTUP_CONFIG_ENTITY}");
-
- // Use an AppService EasyAuth principal carrying the required role when
- // authentication is configured to use AppService.
- string appServiceTokenPayload = AuthTestHelper.CreateAppServiceEasyAuthToken(
- roleClaimType: Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE,
- additionalClaims:
- [
- new AppServiceClaim
- {
- Typ = Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE,
- Val = POST_STARTUP_CONFIG_ROLE
- }
- ]);
-
- message.Headers.Add(Config.ObjectModel.AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, appServiceTokenPayload);
- message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, POST_STARTUP_CONFIG_ROLE);
- HttpResponseMessage authorizedResponse = await httpClient.SendAsync(message);
- Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode);
-
- // OpenAPI document is created during config hydration and
- // is made available after config hydration completes.
- HttpResponseMessage postConfigOpenApiDocumentExistence =
- await httpClient.GetAsync($"{RestRuntimeOptions.DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}");
- Assert.AreEqual(HttpStatusCode.OK, postConfigOpenApiDocumentExistence.StatusCode);
-
- // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode.
- // HTTP 400 - BadRequest because when SwaggerUI is disabled, the endpoint is not mapped
- // and the request is processed and failed by the RestService.
- HttpResponseMessage postConfigOpenApiSwaggerEndpointAvailability =
- await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}");
- Assert.AreEqual(HttpStatusCode.BadRequest, postConfigOpenApiSwaggerEndpointAvailability.StatusCode);
+ expected: true,
+ actual: configParsed,
+ message: "Runtime config unexpectedly failed parsing.");
+ Assert.AreEqual(
+ expected: expectedDabModifiedConnString,
+ actual: updatedRuntimeConfig.DataSource.ConnectionString,
+ message: "DAB did not properly set the 'Application Name' connection string property.");
}
///
- /// Tests that sending configuration to the DAB engine post-startup will properly hydrate even with data-source-files specified.
+ /// Validates that DAB supplements the CosmosDB database connection strings with the property "ApplicationName" and
+ /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set.
+ /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted".
+ /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.)
+ /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names.
///
- [TestCategory(TestCategory.MSSQL)]
- [TestMethod("Validates RuntimeConfig setup for post-configuraiton hydration with datasource-files specified.")]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestValidMultiSourceRunTimePostStartupConfigurations(string configurationEndpoint)
- {
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- RuntimeConfig config = AuthorizationHelpers.InitRuntimeConfig(
- entityName: POST_STARTUP_CONFIG_ENTITY,
- entitySource: POST_STARTUP_CONFIG_ENTITY_SOURCE,
- roleName: POST_STARTUP_CONFIG_ROLE,
- operation: EntityActionOperation.Read,
- includedCols: new HashSet() { "*" });
-
- // Set up Configuration with DataSource files.
- config = config with { DataSourceFiles = new DataSourceFiles(new List() { "file1", "file2" }) };
-
- JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, config, configurationEndpoint);
-
- HttpResponseMessage postResult = await httpClient.PostAsync(configurationEndpoint, content);
- Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode);
-
- RuntimeConfigProvider configProvider = server.Services.GetService();
-
- Assert.IsNotNull(configProvider, "Configuration Provider shouldn't be null after setting the configuration at runtime.");
- Assert.IsTrue(configProvider.TryGetConfig(out RuntimeConfig configuration), "TryGetConfig should return true when the config is set.");
- Assert.IsNotNull(configuration, "Config returned should not be null.");
-
- Assert.IsNotNull(configuration.DataSource, "The base datasource should get populated in case of late hydration of config in-spite of invalid multi-db files.");
- Assert.AreEqual(1, configuration.ListAllDataSources().Count(), "There should be only 1 datasource populated for late hydration of config with invalid multi-db files.");
- }
-
- [TestMethod("Validates that local CosmosDB_NoSQL settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- public void TestLoadingLocalCosmosSettings()
- {
- Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
-
- ValidateCosmosDbSetup(server);
- }
-
- [TestMethod("Validates access token is correctly loaded when Account Key is not present for Cosmos."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestLoadingAccessTokenForCosmosClient(string configurationEndpoint)
- {
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient httpClient = server.CreateClient();
-
- JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint, null, true);
-
- HttpResponseMessage authorizedResponse = await httpClient.PostAsync(configurationEndpoint, content);
-
- Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode);
- CosmosClientProvider cosmosClientProvider = server.Services.GetService(typeof(CosmosClientProvider)) as CosmosClientProvider;
- Assert.IsNotNull(cosmosClientProvider);
- Assert.IsNotNull(cosmosClientProvider.Clients);
- Assert.IsTrue(cosmosClientProvider.Clients.Any());
- }
-
- [TestMethod("Validates that local MsSql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.MSSQL)]
- public void TestLoadingLocalMsSqlSettings()
- {
- Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MSSQL_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
-
- QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory));
- Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.MSSQL), typeof(SqlQueryEngine));
-
- MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory));
- Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.MSSQL), typeof(SqlMutationEngine));
-
- QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory));
- Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.MSSQL), typeof(MsSqlQueryBuilder));
- Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.MSSQL), typeof(MsSqlQueryExecutor));
-
- MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory));
- Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(MsSqlMetadataProvider)));
- }
-
- [TestMethod("Validates that local PostgreSql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.POSTGRESQL)]
- public void TestLoadingLocalPostgresSettings()
- {
- Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, POSTGRESQL_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
-
- QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory));
- Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.PostgreSQL), typeof(SqlQueryEngine));
-
- MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory));
- Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.PostgreSQL), typeof(SqlMutationEngine));
-
- QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory));
- Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.PostgreSQL), typeof(PostgresQueryBuilder));
- Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.PostgreSQL), typeof(PostgreSqlQueryExecutor));
-
- MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory));
- Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(PostgreSqlMetadataProvider)));
- }
-
- [TestMethod("Validates that local MySql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.MYSQL)]
- public void TestLoadingLocalMySqlSettings()
- {
- Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MYSQL_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
-
- QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory));
- Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.MySQL), typeof(SqlQueryEngine));
-
- MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory));
- Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.MySQL), typeof(SqlMutationEngine));
-
- QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory));
- Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.MySQL), typeof(MySqlQueryBuilder));
- Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.MySQL), typeof(MySqlQueryExecutor));
-
- MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory));
- Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(MySqlMetadataProvider)));
- }
-
- [TestMethod("Validates that trying to override configs that are already set fail."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestOverridingLocalSettingsFails(string configurationEndpoint)
- {
- Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
- HttpClient client = server.CreateClient();
-
- JsonContent config = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
-
- HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, config);
- Assert.AreEqual(HttpStatusCode.Conflict, postResult.StatusCode);
- }
-
- [TestMethod("Validates that setting the configuration at runtime will instantiate the proper classes."), TestCategory(TestCategory.COSMOSDBNOSQL)]
- [DataRow(CONFIGURATION_ENDPOINT)]
- [DataRow(CONFIGURATION_ENDPOINT_V2)]
- public async Task TestSettingConfigurationCreatesCorrectClasses(string configurationEndpoint)
- {
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
- HttpClient client = server.CreateClient();
-
- JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
-
- HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content);
- Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode);
-
- ValidateCosmosDbSetup(server);
- RuntimeConfigProvider configProvider = server.Services.GetService();
-
- Assert.IsNotNull(configProvider, "Configuration Provider shouldn't be null after setting the configuration at runtime.");
- Assert.IsTrue(configProvider.TryGetConfig(out RuntimeConfig configuration), "TryGetConfig should return true when the config is set.");
- Assert.IsNotNull(configuration, "Config returned should not be null.");
-
- ConfigurationPostParameters expectedParameters = GetCosmosConfigurationParameters();
- Assert.AreEqual(DatabaseType.CosmosDB_NoSQL, configuration.DataSource.DatabaseType, "Expected CosmosDB_NoSQL database type after configuring the runtime with CosmosDB_NoSQL settings.");
- CosmosDbNoSQLDataSourceOptions options = configuration.DataSource.GetTypedOptions();
- Assert.IsNotNull(options);
- Assert.AreEqual(expectedParameters.Schema, options.GraphQLSchema, "Expected the schema in the configuration to match the one sent to the configuration endpoint.");
-
- // Don't use Assert.AreEqual, because a failure will print the entire connection string in the error message.
- Assert.IsTrue(expectedParameters.ConnectionString == configuration.DataSource.ConnectionString, "Expected the connection string in the configuration to match the one sent to the configuration endpoint.");
- string db = options.Database;
- Assert.AreEqual(COSMOS_DATABASE_NAME, db, "Expected the database name in the runtime config to match the one sent to the configuration endpoint.");
- }
-
- [TestMethod("Validates that an exception is thrown if there's a null model in filter parser.")]
- public void VerifyExceptionOnNullModelinFilterParser()
+ /// connection string provided in the config.
+ /// Updated connection string with Application Name.
+ /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.)
+ [DataTestMethod]
+ [DataRow("AccountEndpoint=https://foo:8081/;AccountKey=secret" , "AccountEndpoint=https://foo:8081/;Application Name=" , false, DisplayName = "[COSMOSDB]: DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'Application Name'.")]
+ [DataRow("AccountEndpoint=https://foo:8081/;Application Name=CustAppName;" , "AccountEndpoint=https://foo:8081/;Application Name=CustAppName," , false, DisplayName = "[COSMOSDB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.")]
+ [DataRow("AccountEndpoint=https://foo:8081/;App=CustAppName;" , "AccountEndpoint=https://foo:8081/;Application Name=CustAppName," , false, DisplayName = "[COSMOSDB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")]
+ [DataRow("AccountEndpoint=https://foo:8081/;Database=db" , "AccountEndpoint=https://foo:8081/;Application Name=" , true , DisplayName = "[COSMOSDB]: DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'Application Name'.")]
+ [DataRow("AccountEndpoint=https://foo:8081/;Application Name=CustAppName;" , "AccountEndpoint=https://foo:8081/;Application Name=CustAppName," , true , DisplayName = "[COSMOSDB]: DAB appends DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'Application Name' property.")]
+ [DataRow("AccountEndpoint=https://foo:8081/;App=CustAppName;" , "AccountEndpoint=https://foo:8081/;Application Name=CustAppName," , true , DisplayName = "[COSMOSDB]: DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")]
+ #pragma warning restore format
+ public void CosmosDbConnStringSupplementedWithAppNameProperty(
+ string configProvidedConnString,
+ string expectedDabModifiedConnString,
+ bool dabEnvOverride)
{
- ODataParser parser = new();
- try
+ // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set.
+ if (dabEnvOverride)
{
- // FilterParser has no model so we expect exception
- parser.GetFilterClause(filterQueryString: string.Empty, resourcePath: string.Empty);
- Assert.Fail();
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted");
}
- catch (DataApiBuilderException exception)
+ else
{
- Assert.AreEqual("The runtime has not been initialized with an Edm model.", exception.Message);
- Assert.AreEqual(HttpStatusCode.InternalServerError, exception.StatusCode);
- Assert.AreEqual(DataApiBuilderException.SubStatusCodes.UnexpectedError, exception.SubStatusCode);
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null);
}
+
+ // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants.
+ string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent();
+ expectedDabModifiedConnString += resolvedAssemblyVersion;
+
+ RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.CosmosDB_NoSQL, configProvidedConnString);
+
+ // Act
+ bool configParsed = RuntimeConfigLoader.TryParseConfig(
+ json: runtimeConfig.ToJson(),
+ config: out RuntimeConfig updatedRuntimeConfig,
+ replacementSettings: new(doReplaceEnvVar: true));
+
+ // Assert
+ Assert.AreEqual(
+ expected: true,
+ actual: configParsed,
+ message: "Runtime config unexpectedly failed parsing.");
+ Assert.AreEqual(
+ expected: expectedDabModifiedConnString,
+ actual: updatedRuntimeConfig.DataSource.ConnectionString,
+ message: "DAB did not properly set the 'Application Name' connection string property.");
}
///
@@ -1812,1812 +1550,773 @@ public async Task TestSqlMetadataValidationForEntitiesWithInvalidSource()
}
///
- /// This test method validates a sample DAB runtime config file against DAB's JSON schema definition.
- /// It asserts that the validation is successful and there are no validation failures.
- /// It also verifies that the expected log message is logged.
+ /// Tests that DAB supplements the CosmosDB database connection strings with the property "ApplicationName" and
+ /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set.
+ /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted".
+ /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.)
+ /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names.
///
- [TestMethod("Validates the config file schema."), TestCategory(TestCategory.MSSQL)]
- public void TestConfigSchemaIsValid()
+ /// connection string provided in the config.
+ /// Updated connection string with Application Name.
+ /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.)
+ public void CosmosDbConnStringSupplementedWithAppNameProperty(
+ string configProvidedConnString,
+ string expectedDabModifiedConnString,
+ bool dabEnvOverride)
{
- TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT);
- FileSystemRuntimeConfigLoader configLoader = TestHelper.GetRuntimeConfigLoader();
+ // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set.
+ if (dabEnvOverride)
+ {
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted");
+ }
+ else
+ {
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null);
+ }
- Mock> schemaValidatorLogger = new();
+ // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants.
+ string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent();
+ expectedDabModifiedConnString += resolvedAssemblyVersion;
- string jsonSchema = File.ReadAllText("dab.draft.schema.json");
- string jsonData = File.ReadAllText(configLoader.ConfigFilePath);
+ RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.CosmosDB_NoSQL, configProvidedConnString);
- JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem());
+ // Act
+ bool configParsed = RuntimeConfigLoader.TryParseConfig(
+ json: runtimeConfig.ToJson(),
+ config: out RuntimeConfig updatedRuntimeConfig,
+ replacementSettings: new(doReplaceEnvVar: true));
- JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData);
- Assert.IsTrue(result.IsValid);
- Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors));
- schemaValidatorLogger.Verify(
- x => x.Log(
- LogLevel.Information,
- It.IsAny(),
- It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")),
- It.IsAny(),
- (Func)It.IsAny