diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index df03b27a31..bf04a76000 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -125,6 +125,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" @@ -144,6 +147,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/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 64c8f41747..4979909827 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -125,6 +125,30 @@ 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(); + + 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 McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {dabEx.Message}", 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..546566cc7d 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -166,6 +166,26 @@ 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). + try + { + if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation( + authResolver, + entityName, + effectiveRole!, + EntityActionOperation.Update, + fields.Keys, + out string columnAuthError)) + { + return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", columnAuthError, logger); + } + } + catch (Azure.DataApiBuilder.Service.Exceptions.DataApiBuilderException dabEx) + { + return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {dabEx.Message}", 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..748b60f98b 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?.ToList() ?? new List(); + + // 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/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index d1d3f47c49..a40839f8e8 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Service.Exceptions; @@ -905,6 +906,171 @@ 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)); + + EntityPermission permissionForEntity = new( + Role: AuthorizationHelpers.TEST_ROLE, + Actions: new EntityAction[] { readAction, createAction, updateAction, deleteAction }); + + Entity sampleEntity = new( + Source: new EntitySource(AuthorizationHelpers.TEST_ENTITY, EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Array.Empty()), + GraphQL: new(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ENTITY), + Permissions: new EntityPermission[] { permissionForEntity }, + Relationships: null, + Mappings: null); + + RuntimeConfig runtimeConfig = new( + Schema: "UnitTestSchema", + DataSource: new DataSource(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" }), + "Read should allow all columns since no fields are excluded for the read action."); + + // 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" }), + "Create should deny col3 since it is excluded for the create action."); + + // 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" }), + "Update should deny col3 since it is excluded for the update action."); + + // 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( + RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig? roundTrippedConfig), + "Round-tripped config should parse successfully."); + AuthorizationResolver roundTrippedResolver = AuthorizationHelpers.InitAuthorizationResolver(roundTrippedConfig!); + + Assert.IsTrue(roundTrippedResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Read, + new List { "col1", "col3" }), + "After round-trip, read should still allow all columns."); + + Assert.IsFalse(roundTrippedResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Create, + new List { "col1", "col3" }), + "After round-trip, create should still deny excluded col3."); + + Assert.IsFalse(roundTrippedResolver.AreColumnsAllowedForOperation( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + operation: EntityActionOperation.Update, + new List { "col1", "col3" }), + "After round-trip, update should still deny excluded 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( + RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config), + "Config should parse successfully."); + Assert.IsNotNull(config); + + Entity bookEntity = config!.Entities["Book"]; + EntityPermission permission = + bookEntity.Permissions.Single(p => p.Role == "test_role_with_excluded_fields_on_mutation"); + + EntityAction createAction = + permission.Actions.Single(a => a.Action == EntityActionOperation.Create); + 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/Mcp/CreateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs index 3ebcafb44b..67e2b1d457 100644 --- a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs @@ -138,6 +138,82 @@ 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. + /// 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 + { + { "id", testUserId } + }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation"); + CreateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", "WebsiteUser" }, + { "data", data } + }; + + try + { + CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args); + + AssertSuccess(result, "CreateRecord should succeed when only permitted columns are supplied."); + } + finally + { + await DeleteWebsiteUser(testUserId); + } + } + + #endregion + #region Helpers private static async Task ExecuteCreateAsync(string entity, Dictionary data) @@ -154,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/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/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 2482d40c29..63ca4f72a8 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: { diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 6e1beafefa..1d82173250 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": [ @@ -1677,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": {