From dc0c37aff0b6f3fa7bff081b983d07768752c2cf Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 21 Jul 2026 14:36:51 +0530 Subject: [PATCH 1/4] Align MCP create_record/update_record with shared authorization helper pattern Extends McpAuthorizationHelper with a small reusable check used by the read-side MCP tools, and calls it from CreateRecordTool/UpdateRecordTool for consistency with the rest of the tool set. Adds accompanying test coverage. --- .../BuiltInTools/CreateRecordTool.cs | 17 +++++ .../BuiltInTools/UpdateRecordTool.cs | 13 ++++ .../Utils/McpAuthorizationHelper.cs | 35 +++++++++ .../CreateRecordToolMsSqlIntegrationTests.cs | 66 ++++++++++++++++ src/Service.Tests/Mcp/McpToolTestBase.cs | 22 +++++- .../UpdateRecordToolMsSqlIntegrationTests.cs | 75 +++++++++++++++++++ src/Service.Tests/dab-config.MsSql.json | 27 +++++++ 7 files changed, 251 insertions(+), 4 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 64c8f41747..a4e59cb519 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -125,6 +125,23 @@ public async Task ExecuteAsync( return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", authError, logger); } + // Column-level authorization: ensure the caller's effective role is permitted to write + // every column present in the request payload (fields.include/fields.exclude enforcement). + IEnumerable requestedColumns = dataElement.ValueKind == JsonValueKind.Object + ? dataElement.EnumerateObject().Select(property => property.Name) + : Enumerable.Empty(); + + if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation( + authorizationResolver, + entityName, + effectiveRole!, + EntityActionOperation.Create, + requestedColumns, + out string columnAuthError)) + { + return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", columnAuthError, logger); + } + JsonElement insertPayloadRoot = dataElement.Clone(); // Validate it's a table or view - stored procedures use execute_entity diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 88f9d2e219..cbf4c8675a 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -166,6 +166,19 @@ public async Task ExecuteAsync( return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", authError, logger); } + // Column-level authorization: ensure the caller's effective role is permitted to write + // every column present in the request payload (fields.include/fields.exclude enforcement). + if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation( + authResolver, + entityName, + effectiveRole!, + EntityActionOperation.Update, + fields.Keys, + out string columnAuthError)) + { + return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", columnAuthError, logger); + } + // 6) Build and validate Upsert (UpdateIncremental) context JsonElement upsertPayloadRoot = RequestValidator.ValidateAndParseRequestBody(JsonSerializer.Serialize(fields)); RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs index 1fdf7d45d3..c76fc8e390 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs @@ -80,5 +80,40 @@ public static bool TryResolveAuthorizedRole( error = $"You do not have permission to perform {operation} operation for this entity."; return false; } + + /// + /// Validates that the resolved role is authorized to write/access the specific set of columns + /// for the given operation. This is the column-level counterpart to + /// , which only performs entity/operation-level authorization. + /// Mutation tools (create_record, update_record) must call this after resolving the effective role + /// and before forwarding the payload to the mutation engine, mirroring the column-level checks + /// already enforced by REST (ColumnsPermissionsRequirement) and the read-side MCP tools. + /// + public static bool AreColumnsAuthorizedForOperation( + IAuthorizationResolver authorizationResolver, + string entityName, + string role, + EntityActionOperation operation, + IEnumerable columns, + out string error) + { + error = string.Empty; + + List requestedColumns = columns is null ? new List() : columns.ToList(); + + // No columns supplied means nothing is written, so there is nothing to restrict. + if (requestedColumns.Count == 0) + { + return true; + } + + if (!authorizationResolver.AreColumnsAllowedForOperation(entityName, role, operation, requestedColumns)) + { + error = $"You do not have permission to access one or more of the specified columns for the {operation} operation on this entity."; + return false; + } + + return true; + } } } diff --git a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs index 3ebcafb44b..393b276251 100644 --- a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs @@ -138,6 +138,72 @@ public async Task CreateRecord_MissingRequiredField_ReturnsError() #endregion + #region Column-Level Authorization Tests + + /// + /// Regression test for column-level authorization bypass in create_record. + /// A role holding CREATE permission on the entity, but with a column explicitly + /// excluded via fields.exclude, must be denied when supplying a value for that column + /// even though the operation itself is authorized. Fails pre-fix, passes post-fix. + /// + [TestMethod] + public async Task CreateRecord_ExcludedColumn_ReturnsPermissionDenied() + { + var data = new Dictionary + { + { "title", "Should Not Be Created" }, + { "publisher_id", 1234 } + }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation"); + CreateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "data", data } + }; + + CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args); + + AssertError(result, "permission", + "CreateRecord should deny writes to columns excluded for the caller's role, even though " + + "the role holds CREATE permission on the entity."); + } + + /// + /// Sanity check accompanying : + /// the same restricted role must still be able to create a record when it only supplies + /// columns it is permitted to write. + /// + [TestMethod] + public async Task CreateRecord_AllowedColumnsOnly_WithColumnRestrictedRole_ReturnsSuccess() + { + var data = new Dictionary + { + { "title", "Allowed Column Only Book" } + }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation"); + CreateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "data", data } + }; + + CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args); + + AssertSuccess(result, "CreateRecord should succeed when only permitted columns are supplied."); + + JsonElement root = ParseResultRoot(result); + int createdId = ExtractCreatedBookId(root); + await DeleteTestBook(createdId); + } + + #endregion + #region Helpers private static async Task ExecuteCreateAsync(string entity, Dictionary data) diff --git a/src/Service.Tests/Mcp/McpToolTestBase.cs b/src/Service.Tests/Mcp/McpToolTestBase.cs index 19427f9440..b7ce255e00 100644 --- a/src/Service.Tests/Mcp/McpToolTestBase.cs +++ b/src/Service.Tests/Mcp/McpToolTestBase.cs @@ -92,7 +92,12 @@ protected static IServiceProvider BuildQueryServiceProvider() /// Includes: RuntimeConfigProvider, IMetadataProviderFactory, IAuthorizationResolver, /// IHttpContextAccessor, IMutationEngineFactory, RequestValidator. /// - protected static IServiceProvider BuildMutationServiceProvider() + /// + /// Client role header value to use for the request. Defaults to the anonymous role. + /// Pass a custom role (already defined in the test config's permissions) to exercise + /// role-specific and column-level authorization scenarios. + /// + protected static IServiceProvider BuildMutationServiceProvider(string role = AuthorizationResolver.ROLE_ANONYMOUS) { ServiceCollection services = new(); @@ -102,7 +107,7 @@ protected static IServiceProvider BuildMutationServiceProvider() services.AddSingleton(_metadataProviderFactory.Object); services.AddSingleton(_authorizationResolver); - IHttpContextAccessor httpContextAccessor = CreateAnonymousHttpContextAccessor(); + IHttpContextAccessor httpContextAccessor = CreateHttpContextAccessorForRole(role); services.AddSingleton(httpContextAccessor); services.AddSingleton(new RequestValidator(_metadataProviderFactory.Object, configProvider)); @@ -153,14 +158,23 @@ protected static IServiceProvider BuildMutationServiceProvider() /// Creates an HttpContextAccessor with anonymous role claims for MCP tool testing. /// protected static IHttpContextAccessor CreateAnonymousHttpContextAccessor() + { + return CreateHttpContextAccessorForRole(AuthorizationResolver.ROLE_ANONYMOUS); + } + + /// + /// Creates an HttpContextAccessor with the given role set as both the client role header + /// and a matching role claim, for exercising role-specific MCP tool authorization scenarios. + /// + protected static IHttpContextAccessor CreateHttpContextAccessorForRole(string role) { DefaultHttpContext httpContext = new(); - httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; + httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = role; ClaimsIdentity identity = new( authenticationType: "TestAuth", nameType: null, roleType: AuthenticationOptions.ROLE_CLAIM_TYPE); - identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS)); + identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, role)); httpContext.User = new ClaimsPrincipal(identity); return new HttpContextAccessor { HttpContext = httpContext }; } diff --git a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs index 3ed0ac7be4..50856f5f36 100644 --- a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs @@ -158,6 +158,81 @@ public async Task UpdateRecord_NullKeyValue_ReturnsError() #endregion + #region Column-Level Authorization Tests + + /// + /// Regression test for column-level authorization bypass in update_record. + /// A role holding UPDATE permission on the entity, but with a column explicitly + /// excluded via fields.exclude, must be denied when supplying a value for that column + /// even though the operation itself is authorized. Fails pre-fix, passes post-fix. + /// + [TestMethod] + public async Task UpdateRecord_ExcludedColumn_ReturnsPermissionDenied() + { + int createdId = await CreateBookForUpdate("Book For Column Auth Test"); + try + { + var keys = new Dictionary { { "id", createdId } }; + var fields = new Dictionary { { "publisher_id", 9999 } }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation"); + UpdateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "keys", keys }, + { "fields", fields } + }; + + CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args); + + AssertError(result, "permission", + "UpdateRecord should deny writes to columns excluded for the caller's role, even though " + + "the role holds UPDATE permission on the entity."); + } + finally + { + await DeleteBook(createdId); + } + } + + /// + /// Sanity check accompanying : + /// the same restricted role must still be able to update a record when it only supplies + /// columns it is permitted to write. + /// + [TestMethod] + public async Task UpdateRecord_AllowedColumnsOnly_WithColumnRestrictedRole_ReturnsSuccess() + { + int createdId = await CreateBookForUpdate("Book For Allowed Column Update Test"); + try + { + var keys = new Dictionary { { "id", createdId } }; + var fields = new Dictionary { { "title", "Updated By Restricted Role" } }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation"); + UpdateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "keys", keys }, + { "fields", fields } + }; + + CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args); + + AssertSuccess(result, "UpdateRecord should succeed when only permitted columns are supplied."); + } + finally + { + await DeleteBook(createdId); + } + } + + #endregion + #region Helpers private static async Task ExecuteUpdateAsync( diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 4de4f52b5f..d31917d7e3 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -936,6 +936,33 @@ } ] }, + { + "role": "test_role_with_excluded_fields_on_mutation", + "actions": [ + { + "action": "read" + }, + { + "action": "create", + "fields": { + "exclude": [ + "publisher_id" + ] + } + }, + { + "action": "update", + "fields": { + "exclude": [ + "publisher_id" + ] + } + }, + { + "action": "delete" + } + ] + }, { "role": "role_multiple_create_policy_tester", "actions": [ From f3542011fad1bd4cd50527feae1b87309eb53fc2 Mon Sep 17 00:00:00 2001 From: Souvik Ghosh Date: Tue, 21 Jul 2026 14:46:17 +0530 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../BuiltInTools/CreateRecordTool.cs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index a4e59cb519..4979909827 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -131,15 +131,22 @@ public async Task ExecuteAsync( ? dataElement.EnumerateObject().Select(property => property.Name) : Enumerable.Empty(); - if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation( - authorizationResolver, - entityName, - effectiveRole!, - EntityActionOperation.Create, - requestedColumns, - out string columnAuthError)) + try + { + if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation( + authorizationResolver, + entityName, + effectiveRole!, + EntityActionOperation.Create, + requestedColumns, + out string columnAuthError)) + { + return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", columnAuthError, logger); + } + } + catch (Azure.DataApiBuilder.Service.Exceptions.DataApiBuilderException dabEx) { - return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", columnAuthError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {dabEx.Message}", logger); } JsonElement insertPayloadRoot = dataElement.Clone(); From 41a573a9cef376f8c921a92b11838e2cf13e09b7 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 21 Jul 2026 16:24:18 +0530 Subject: [PATCH 3/4] Fix invalid test scenario in create_record column-restriction success test The Book entity's only non-key column (publisher_id) is NOT NULL with no default, so a role excluded from writing it can never satisfy Book's required-field validation on create -- unrelated to authorization. Switched the success-path sanity check to the WebsiteUser entity, whose only non-key column (username) is nullable, so omitting the excluded column is valid. --- .../CreateRecordToolMsSqlIntegrationTests.cs | 44 +++++++++++++++---- src/Service.Tests/dab-config.MsSql.json | 27 ++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs index 393b276251..67e2b1d457 100644 --- a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs @@ -175,13 +175,20 @@ public async Task CreateRecord_ExcludedColumn_ReturnsPermissionDenied() /// Sanity check accompanying : /// the same restricted role must still be able to create a record when it only supplies /// columns it is permitted to write. + /// Uses the WebsiteUser entity (rather than Book) because its only non-key column + /// ("username") is nullable, so omitting the excluded column does not also trip a + /// "missing required field" validation error unrelated to authorization. Book's only + /// non-key column (publisher_id) is NOT NULL with no default, so it cannot be omitted + /// regardless of role permissions and is unsuitable for this scenario. /// [TestMethod] public async Task CreateRecord_AllowedColumnsOnly_WithColumnRestrictedRole_ReturnsSuccess() { + int testUserId = 7_654_321; + var data = new Dictionary { - { "title", "Allowed Column Only Book" } + { "id", testUserId } }; IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation"); @@ -189,17 +196,20 @@ public async Task CreateRecord_AllowedColumnsOnly_WithColumnRestrictedRole_Retur var args = new Dictionary { - { "entity", "Book" }, + { "entity", "WebsiteUser" }, { "data", data } }; - CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args); - - AssertSuccess(result, "CreateRecord should succeed when only permitted columns are supplied."); + try + { + CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args); - JsonElement root = ParseResultRoot(result); - int createdId = ExtractCreatedBookId(root); - await DeleteTestBook(createdId); + AssertSuccess(result, "CreateRecord should succeed when only permitted columns are supplied."); + } + finally + { + await DeleteWebsiteUser(testUserId); + } } #endregion @@ -220,6 +230,24 @@ private static async Task ExecuteCreateAsync(string entity, Dict return await ExecuteToolAsync(tool, serviceProvider, args); } + /// + /// Deletes a WebsiteUser record by ID, ignoring the outcome. Used for cleanup only; + /// a failed create in the calling test means there is nothing to delete. + /// + private static async Task DeleteWebsiteUser(int id) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool deleteTool = new(); + + var args = new Dictionary + { + { "entity", "WebsiteUser" }, + { "keys", new Dictionary { { "id", id } } } + }; + + await ExecuteToolAsync(deleteTool, serviceProvider, args); + } + #endregion } } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index d31917d7e3..b09dd01cb6 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -1704,6 +1704,33 @@ "action": "update" } ] + }, + { + "role": "test_role_with_excluded_fields_on_mutation", + "actions": [ + { + "action": "read" + }, + { + "action": "create", + "fields": { + "exclude": [ + "username" + ] + } + }, + { + "action": "update", + "fields": { + "exclude": [ + "username" + ] + } + }, + { + "action": "delete" + } + ] } ], "relationships": { From 20d95f7d36f9360d82f0e958b257468bdfb0d721 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 21 Jul 2026 20:59:15 +0530 Subject: [PATCH 4/4] Add test_role_with_excluded_fields_on_mutation to MsSql config generator The MCP create_record/update_record column-exclusion integration tests passed locally but failed in CI. CI regenerates src/Service.Tests/dab-config.MsSql.json from config-generators/mssql-commands.txt; the role existed only in the checked-in JSON, so the regenerated CI config lacked it and inheritance from the 'authenticated' role (no field exclusions) allowed writes to excluded columns. Add the role to mssql-commands.txt for Book (exclude publisher_id) and WebsiteUser (exclude username) on create/update. Add AuthorizationResolver unit tests covering multi-action-per-role column exclusion and config parsing. --- config-generators/mssql-commands.txt | 6 + .../AuthorizationResolverUnitTests.cs | 157 ++++++++++++++++++ ...tReadingRuntimeConfigForMsSql.verified.txt | 54 ++++++ 3 files changed, 217 insertions(+) diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index 99e138b846..a35972a069 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -123,6 +123,9 @@ update Book --config "dab-config.MsSql.json" --permissions "test_role_with_exclu update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "publisher_id" update Book --config "dab-config.MsSql.json" --permissions "test_role_with_policy_excluded_fields:create,update,delete" update Book --config "dab-config.MsSql.json" --permissions "test_role_with_policy_excluded_fields:read" --fields.exclude "publisher_id" --policy-database "@item.title ne 'Test'" +update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:read,delete" +update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:create" --fields.exclude "publisher_id" +update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:update" --fields.exclude "publisher_id" update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:read" --policy-database "@item.publisher_id ne 1234" update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:create" --policy-database "@item.title ne 'Test'" update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:update,delete" @@ -142,6 +145,9 @@ update BookWebsitePlacement --config "dab-config.MsSql.json" --permissions "auth update BookWebsitePlacement --config "dab-config.MsSql.json" --permissions "authenticated:delete" --fields.include "*" --policy-database "@claims.userId eq @item.id" update Author --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true update WebsiteUser --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update" --rest false --graphql "websiteUser:websiteUsers" +update WebsiteUser --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:read,delete" +update WebsiteUser --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:create" --fields.exclude "username" +update WebsiteUser --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:update" --fields.exclude "username" update WebsiteUser -c "dab-config.MsSql.json" --relationship reviews --target.entity Review --cardinality many --relationship.fields "id:websiteuser_id" update WebsiteUser_MM --config "dab-config.MsSql.json" --source website_users_mm --permissions "authenticated:*" --relationship reviews --relationship.fields "id:websiteuser_id" --target.entity Review_MM --cardinality many update Revenue --config "dab-config.MsSql.json" --permissions "database_policy_tester:create" --policy-database "@item.revenue gt 1000" diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index d1d3f47c49..9ff0e99d57 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -905,6 +905,163 @@ public void WildcardColumnInclusionWithExplicitExclusion() excludedColumns)); } + /// + /// Reproduces the real-world Book entity scenario: a single role with MULTIPLE actions + /// (read with no field restriction, create/update excluding a column, delete with no + /// field restriction) all defined in one EntityPermission.Actions array - unlike + /// AuthorizationHelpers.InitRuntimeConfig which only ever builds a single action per role. + /// + [TestMethod("Multiple actions per role - column exclusion on create/update only")] + public void MultipleActionsPerRole_ColumnExclusionOnCreateAndUpdate() + { + EntityActionFields createUpdateFields = new(Exclude: new() { "col3" }); + EntityAction readAction = new(Action: EntityActionOperation.Read, Fields: null, Policy: new(null, null)); + EntityAction createAction = new(Action: EntityActionOperation.Create, Fields: createUpdateFields, Policy: new(null, null)); + EntityAction updateAction = new(Action: EntityActionOperation.Update, Fields: createUpdateFields, Policy: new(null, null)); + EntityAction deleteAction = new(Action: EntityActionOperation.Delete, Fields: null, Policy: new(null, null)); + + Azure.DataApiBuilder.Config.ObjectModel.EntityPermission permissionForEntity = new( + Role: AuthorizationHelpers.TEST_ROLE, + Actions: new EntityAction[] { readAction, createAction, updateAction, deleteAction }); + + Azure.DataApiBuilder.Config.ObjectModel.Entity sampleEntity = new( + Source: new Azure.DataApiBuilder.Config.ObjectModel.EntitySource(AuthorizationHelpers.TEST_ENTITY, Azure.DataApiBuilder.Config.ObjectModel.EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Array.Empty()), + GraphQL: new(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ENTITY), + Permissions: new Azure.DataApiBuilder.Config.ObjectModel.EntityPermission[] { permissionForEntity }, + Relationships: null, + Mappings: null); + + Azure.DataApiBuilder.Config.ObjectModel.RuntimeConfig runtimeConfig = new( + Schema: "UnitTestSchema", + DataSource: new Azure.DataApiBuilder.Config.ObjectModel.DataSource(Azure.DataApiBuilder.Config.ObjectModel.DatabaseType.MSSQL, "", new()), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new( + Cors: null, + Authentication: new("AppService", null))), + Entities: new(new Dictionary { { AuthorizationHelpers.TEST_ENTITY, sampleEntity } })); + + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // Read should allow all columns (no exclusion for read). + Assert.IsTrue(authZResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Read, + new List { "col1", "col3" })); + + // Create should DENY col3 since it is excluded for create. + Assert.IsFalse(authZResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Create, + new List { "col1", "col3" })); + + // Update should DENY col3 since it is excluded for update. + Assert.IsFalse(authZResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Update, + new List { "col1", "col3" })); + + // Round-trip the config through JSON serialization/deserialization (as happens when DAB + // loads a real config file from disk) to rule out any bug specific to the JSON converters + // (e.g. shared/aliased Fields.Exclude HashSet instances across sibling actions). + string json = runtimeConfig.ToJson(); + Assert.IsTrue(Azure.DataApiBuilder.Config.RuntimeConfigLoader.TryParseConfig(json, out Azure.DataApiBuilder.Config.ObjectModel.RuntimeConfig? roundTrippedConfig)); + AuthorizationResolver roundTrippedResolver = AuthorizationHelpers.InitAuthorizationResolver(roundTrippedConfig!); + + Assert.IsTrue(roundTrippedResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Read, + new List { "col1", "col3" })); + + Assert.IsFalse(roundTrippedResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Create, + new List { "col1", "col3" })); + + Assert.IsFalse(roundTrippedResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Update, + new List { "col1", "col3" })); + } + + /// + /// Parses the EXACT real-world Book entity config structure from JSON (entity-level "fields" + /// array listing only id/title, plus a multi-action role that excludes publisher_id for + /// create/update) and asserts that Fields.Exclude survives deserialization for each action. + /// This isolates whether the bug is in the JSON config converters (EntityActionConverter / + /// EntityActionFields) independent of any database/metadata provider. + /// + [TestMethod("Real config JSON: Fields.Exclude survives parse for multi-action role")] + public void RealConfigJson_FieldsExcludeSurvivesParse() + { + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/v0.10.23/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=localhost;Database=master;"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"", ""request-body-strict"": true }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"", ""authentication"": { ""provider"": ""StaticWebApps"" } } + }, + ""entities"": { + ""Book"": { + ""source"": { ""object"": ""books"", ""type"": ""table"" }, + ""fields"": [ + { ""name"": ""id"", ""alias"": ""id"", ""primary-key"": false }, + { ""name"": ""title"", ""alias"": ""title"", ""primary-key"": false } + ], + ""permissions"": [ + { + ""role"": ""test_role_with_excluded_fields_on_mutation"", + ""actions"": [ + { ""action"": ""read"" }, + { ""action"": ""create"", ""fields"": { ""exclude"": [ ""publisher_id"" ] } }, + { ""action"": ""update"", ""fields"": { ""exclude"": [ ""publisher_id"" ] } }, + { ""action"": ""delete"" } + ] + } + ] + } + } + }"; + + Assert.IsTrue( + Azure.DataApiBuilder.Config.RuntimeConfigLoader.TryParseConfig(configJson, out Azure.DataApiBuilder.Config.ObjectModel.RuntimeConfig? config), + "Config should parse successfully."); + Assert.IsNotNull(config); + + Azure.DataApiBuilder.Config.ObjectModel.Entity bookEntity = config!.Entities["Book"]; + Azure.DataApiBuilder.Config.ObjectModel.EntityPermission permission = + bookEntity.Permissions.Single(p => p.Role == "test_role_with_excluded_fields_on_mutation"); + + Azure.DataApiBuilder.Config.ObjectModel.EntityAction createAction = + permission.Actions.Single(a => a.Action == EntityActionOperation.Create); + Azure.DataApiBuilder.Config.ObjectModel.EntityAction updateAction = + permission.Actions.Single(a => a.Action == EntityActionOperation.Update); + + Assert.IsNotNull(createAction.Fields, "Create action Fields should not be null after parsing."); + Assert.IsNotNull(updateAction.Fields, "Update action Fields should not be null after parsing."); + + Assert.IsTrue( + createAction.Fields!.Exclude.Contains("publisher_id"), + $"Create action Exclude should contain publisher_id. Actual: [{string.Join(",", createAction.Fields.Exclude)}]"); + Assert.IsTrue( + updateAction.Fields!.Exclude.Contains("publisher_id"), + $"Update action Exclude should contain publisher_id. Actual: [{string.Join(",", updateAction.Fields.Exclude)}]"); + } + /// /// Test that all columns should be excluded if the exclusion contains wildcard character. /// diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 6e43d2599a..9221e4c5b2 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -909,6 +909,33 @@ } ] }, + { + Role: test_role_with_excluded_fields_on_mutation, + Actions: [ + { + Action: Update, + Fields: { + Exclude: [ + publisher_id + ] + } + }, + { + Action: Create, + Fields: { + Exclude: [ + publisher_id + ] + } + }, + { + Action: Read + }, + { + Action: Delete + } + ] + }, { Role: role_multiple_create_policy_tester, Actions: [ @@ -1592,6 +1619,33 @@ Action: Update } ] + }, + { + Role: test_role_with_excluded_fields_on_mutation, + Actions: [ + { + Action: Update, + Fields: { + Exclude: [ + username + ] + } + }, + { + Action: Create, + Fields: { + Exclude: [ + username + ] + } + }, + { + Action: Read + }, + { + Action: Delete + } + ] } ], Relationships: {