Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config-generators/mssql-commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,30 @@ public async Task<CallToolResult> 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<string> requestedColumns = dataElement.ValueKind == JsonValueKind.Object
? dataElement.EnumerateObject().Select(property => property.Name)
: Enumerable.Empty<string>();

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);
}
Comment thread
Copilot marked this conversation as resolved.

JsonElement insertPayloadRoot = dataElement.Clone();

// Validate it's a table or view - stored procedures use execute_entity
Expand Down
20 changes: 20 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,26 @@ public async Task<CallToolResult> 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);
Expand Down
35 changes: 35 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,40 @@ public static bool TryResolveAuthorizedRole(
error = $"You do not have permission to perform {operation} operation for this entity.";
return false;
}

/// <summary>
/// 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
/// <see cref="TryResolveAuthorizedRole"/>, 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.
/// </summary>
public static bool AreColumnsAuthorizedForOperation(
IAuthorizationResolver authorizationResolver,
string entityName,
string role,
EntityActionOperation operation,
IEnumerable<string> columns,
out string error)
{
error = string.Empty;

List<string> requestedColumns = columns?.ToList() ?? new List<string>();

// 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;
}
}
}
166 changes: 166 additions & 0 deletions src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -905,6 +906,171 @@ public void WildcardColumnInclusionWithExplicitExclusion()
excludedColumns));
}

/// <summary>
/// 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.
/// </summary>
[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<SupportedHttpVerb>()),
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<string, Entity> { { 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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "col1", "col3" }),
"After round-trip, update should still deny excluded col3.");
}

/// <summary>
/// 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.
/// </summary>
[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)}]");
}

/// <summary>
/// Test that all columns should be excluded if the exclusion contains wildcard character.
/// </summary>
Expand Down
Loading