Skip to content
Open
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 @@ -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"
Expand All @@ -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"
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
13 changes: 13 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,19 @@ 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).
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);
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 is null ? new List<string>() : 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;
}
}
}
157 changes: 157 additions & 0 deletions src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,163 @@ 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));

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<SupportedHttpVerb>()),
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<string, Azure.DataApiBuilder.Config.ObjectModel.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" }));

// 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" }));

// 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" }));

// 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<string> { "col1", "col3" }));

Assert.IsFalse(roundTrippedResolver.AreColumnsAllowedForOperation(
AuthorizationHelpers.TEST_ENTITY,
AuthorizationHelpers.TEST_ROLE,
operation: EntityActionOperation.Create,
new List<string> { "col1", "col3" }));

Assert.IsFalse(roundTrippedResolver.AreColumnsAllowedForOperation(
AuthorizationHelpers.TEST_ENTITY,
AuthorizationHelpers.TEST_ROLE,
operation: EntityActionOperation.Update,
new List<string> { "col1", "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(
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)}]");
}

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