Skip to content

Commit c144103

Browse files
authored
Merge branch 'main' into copilot/fix-autoentities-in-child-configs
2 parents b1e3d6c + fe2a3a8 commit c144103

27 files changed

Lines changed: 1241 additions & 59 deletions

config-generators/mssql-commands.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ add WebsiteUser_MM --config "dab-config.MsSql.json" --source website_users_mm --
1818
add SupportedType --config "dab-config.MsSql.json" --source type_table --permissions "anonymous:create,read,delete,update"
1919
add VectorType --config "dab-config.MsSql.json" --source vector_type_table --rest true --graphql false --permissions "anonymous:create,read,delete,update"
2020
update VectorType --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update"
21+
add Profile --config "dab-config.MsSql.json" --source profiles --rest true --graphql true --permissions "anonymous:create,read,delete,update"
22+
update Profile --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update"
2123
add stocks_price --config "dab-config.MsSql.json" --source stocks_price --permissions "authenticated:create,read,update,delete"
2224
update stocks_price --config "dab-config.MsSql.json" --permissions "anonymous:read"
2325
update stocks_price --config "dab-config.MsSql.json" --permissions "TestNestedFilterFieldIsNull_ColumnForbidden:read" --fields.exclude "price"
@@ -123,6 +125,9 @@ update Book --config "dab-config.MsSql.json" --permissions "test_role_with_exclu
123125
update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "publisher_id"
124126
update Book --config "dab-config.MsSql.json" --permissions "test_role_with_policy_excluded_fields:create,update,delete"
125127
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'"
128+
update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:read,delete"
129+
update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:create" --fields.exclude "publisher_id"
130+
update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:update" --fields.exclude "publisher_id"
126131
update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:read" --policy-database "@item.publisher_id ne 1234"
127132
update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:create" --policy-database "@item.title ne 'Test'"
128133
update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:update,delete"
@@ -142,6 +147,9 @@ update BookWebsitePlacement --config "dab-config.MsSql.json" --permissions "auth
142147
update BookWebsitePlacement --config "dab-config.MsSql.json" --permissions "authenticated:delete" --fields.include "*" --policy-database "@claims.userId eq @item.id"
143148
update Author --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true
144149
update WebsiteUser --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update" --rest false --graphql "websiteUser:websiteUsers"
150+
update WebsiteUser --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:read,delete"
151+
update WebsiteUser --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:create" --fields.exclude "username"
152+
update WebsiteUser --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_mutation:update" --fields.exclude "username"
145153
update WebsiteUser -c "dab-config.MsSql.json" --relationship reviews --target.entity Review --cardinality many --relationship.fields "id:websiteuser_id"
146154
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
147155
update Revenue --config "dab-config.MsSql.json" --permissions "database_policy_tester:create" --policy-database "@item.revenue gt 1000"

global.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"sdk": {
3-
"version": "10.0.301",
3+
"version": "10.0.302",
44
"rollForward": "latestFeature"
55
}
66
}

src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,30 @@ public async Task<CallToolResult> ExecuteAsync(
125125
return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", authError, logger);
126126
}
127127

128+
// Column-level authorization: ensure the caller's effective role is permitted to write
129+
// every column present in the request payload (fields.include/fields.exclude enforcement).
130+
IEnumerable<string> requestedColumns = dataElement.ValueKind == JsonValueKind.Object
131+
? dataElement.EnumerateObject().Select(property => property.Name)
132+
: Enumerable.Empty<string>();
133+
134+
try
135+
{
136+
if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation(
137+
authorizationResolver,
138+
entityName,
139+
effectiveRole!,
140+
EntityActionOperation.Create,
141+
requestedColumns,
142+
out string columnAuthError))
143+
{
144+
return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", columnAuthError, logger);
145+
}
146+
}
147+
catch (Azure.DataApiBuilder.Service.Exceptions.DataApiBuilderException dabEx)
148+
{
149+
return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {dabEx.Message}", logger);
150+
}
151+
128152
JsonElement insertPayloadRoot = dataElement.Clone();
129153

130154
// Validate it's a table or view - stored procedures use execute_entity

src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,26 @@ public async Task<CallToolResult> ExecuteAsync(
166166
return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", authError, logger);
167167
}
168168

169+
// Column-level authorization: ensure the caller's effective role is permitted to write
170+
// every column present in the request payload (fields.include/fields.exclude enforcement).
171+
try
172+
{
173+
if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation(
174+
authResolver,
175+
entityName,
176+
effectiveRole!,
177+
EntityActionOperation.Update,
178+
fields.Keys,
179+
out string columnAuthError))
180+
{
181+
return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", columnAuthError, logger);
182+
}
183+
}
184+
catch (Azure.DataApiBuilder.Service.Exceptions.DataApiBuilderException dabEx)
185+
{
186+
return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {dabEx.Message}", logger);
187+
}
188+
169189
// 6) Build and validate Upsert (UpdateIncremental) context
170190
JsonElement upsertPayloadRoot = RequestValidator.ValidateAndParseRequestBody(JsonSerializer.Serialize(fields));
171191
RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider);

src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,40 @@ public static bool TryResolveAuthorizedRole(
8080
error = $"You do not have permission to perform {operation} operation for this entity.";
8181
return false;
8282
}
83+
84+
/// <summary>
85+
/// Validates that the resolved role is authorized to write/access the specific set of columns
86+
/// for the given operation. This is the column-level counterpart to
87+
/// <see cref="TryResolveAuthorizedRole"/>, which only performs entity/operation-level authorization.
88+
/// Mutation tools (create_record, update_record) must call this after resolving the effective role
89+
/// and before forwarding the payload to the mutation engine, mirroring the column-level checks
90+
/// already enforced by REST (ColumnsPermissionsRequirement) and the read-side MCP tools.
91+
/// </summary>
92+
public static bool AreColumnsAuthorizedForOperation(
93+
IAuthorizationResolver authorizationResolver,
94+
string entityName,
95+
string role,
96+
EntityActionOperation operation,
97+
IEnumerable<string> columns,
98+
out string error)
99+
{
100+
error = string.Empty;
101+
102+
List<string> requestedColumns = columns?.ToList() ?? new List<string>();
103+
104+
// No columns supplied means nothing is written, so there is nothing to restrict.
105+
if (requestedColumns.Count == 0)
106+
{
107+
return true;
108+
}
109+
110+
if (!authorizationResolver.AreColumnsAllowedForOperation(entityName, role, operation, requestedColumns))
111+
{
112+
error = $"You do not have permission to access one or more of the specified columns for the {operation} operation on this entity.";
113+
return false;
114+
}
115+
116+
return true;
117+
}
83118
}
84119
}

src/Core/Resolvers/MsSqlQueryBuilder.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Data;
45
using System.Data.Common;
56
using System.Text;
67
using System.Text.RegularExpressions;
@@ -456,10 +457,36 @@ private string WrappedColumns(SqlQueryStructure structure)
456457
structure.Columns.Select(
457458
c => structure.IsSubqueryColumn(c) ?
458459
WrapSubqueryColumn(c, structure.JoinQueries[c.TableAlias!]) + $" AS {QuoteIdentifier(c.Label)}" :
459-
Build(c)
460+
BuildResultColumn(c, structure)
460461
));
461462
}
462463

464+
/// <summary>
465+
/// Builds a top-level (non-subquery) result column.
466+
/// A SQL Server native <c>json</c> column is cast to NVARCHAR(MAX) so that the trailing
467+
/// FOR JSON PATH clause emits it as an escaped JSON string instead of inlining it as a nested
468+
/// JSON value. DAB treats a json column as a normal string, so its raw JSON text must
469+
/// round-trip as a string at the REST/GraphQL boundary.
470+
/// </summary>
471+
private string BuildResultColumn(LabelledColumn column, SqlQueryStructure structure)
472+
{
473+
if (IsJsonColumn(column, structure))
474+
{
475+
return $"CAST({Build(column as Column)} AS NVARCHAR(MAX)) AS {QuoteIdentifier(column.Label)}";
476+
}
477+
478+
return Build(column);
479+
}
480+
481+
/// <summary>
482+
/// Returns true when the given column is backed by a SQL Server native <c>json</c> column.
483+
/// </summary>
484+
private static bool IsJsonColumn(LabelledColumn column, SqlQueryStructure structure)
485+
{
486+
return structure.GetUnderlyingSourceDefinition().Columns.TryGetValue(column.ColumnName, out ColumnDefinition? columnDefinition)
487+
&& columnDefinition.SqlDbType == SqlDbType.Json;
488+
}
489+
463490
/// <summary>
464491
/// Builds the parameter list for the stored procedure execute call
465492
/// paramKeys are the user-generated procedure parameter names

src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,32 @@ public BaseSqlQueryStructure(
8585
}
8686
}
8787

88+
/// <inheritdoc />
89+
public override string MakeDbConnectionParam(object? value, string? paramName = null, bool lengthOverride = false)
90+
{
91+
if (MetadataProvider.GetDatabaseType() is DatabaseType.PostgreSQL &&
92+
!string.IsNullOrEmpty(paramName) &&
93+
value is string stringValue &&
94+
GetUnderlyingSourceDefinition().Columns.TryGetValue(paramName, out ColumnDefinition? columnDefinition))
95+
{
96+
Type columnSystemType = columnDefinition.SystemType;
97+
if (columnSystemType != typeof(string))
98+
{
99+
value = GetParamAsSystemType(stringValue, paramName, columnSystemType);
100+
}
101+
102+
// Npgsql requires DateTime with Kind=Unspecified for 'timestamp without time zone' columns.
103+
// ParseParamAsSystemType returns Kind=Utc (via .UtcDateTime), which causes PostgreSQL to
104+
// apply a UTC-to-local offset during comparison, producing incorrect filter results.
105+
if (value is DateTime dtValue && dtValue.Kind == DateTimeKind.Utc && columnSystemType == typeof(DateTime))
106+
{
107+
value = DateTime.SpecifyKind(dtValue, DateTimeKind.Unspecified);
108+
}
109+
}
110+
111+
return base.MakeDbConnectionParam(value, paramName, lengthOverride);
112+
}
113+
88114
/// <summary>
89115
/// For UPDATE (OVERWRITE) operation
90116
/// Adds result of (SourceDefinition.Columns minus MutationFields) to UpdateOperations with null values
@@ -422,9 +448,9 @@ protected List<LabelledColumn> GenerateOutputColumns()
422448
/// Tries to parse the string parameter to the given system type
423449
/// Useful for inferring parameter types for columns or procedure parameters
424450
/// </summary>
425-
/// <param name="param"></param>
426-
/// <param name="systemType"></param>
427-
/// <returns></returns>
451+
/// <param name="param">The string value to parse.</param>
452+
/// <param name="systemType">The target system type for the parsed value.</param>
453+
/// <returns>The parameter parsed as the requested system type.</returns>
428454
/// <exception cref="NotSupportedException"></exception>
429455
protected static object ParseParamAsSystemType(string param, Type systemType)
430456
{

src/Core/Services/GraphQLSchemaCreator.cs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,9 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction
295295
Dictionary<string, IEnumerable<string>> rolesAllowedForFields = new();
296296
SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(entityName);
297297
bool isStoredProcedure = entity.Source.Type is EntitySourceType.StoredProcedure;
298+
EntityActionOperation operation = isStoredProcedure ? EntityActionOperation.Execute : EntityActionOperation.Read;
298299
foreach (string column in sourceDefinition.Columns.Keys)
299300
{
300-
EntityActionOperation operation = isStoredProcedure ? EntityActionOperation.Execute : EntityActionOperation.Read;
301301
IEnumerable<string> roles = _authorizationResolver.GetRolesForField(entityName, field: column, operation: operation);
302302
if (!rolesAllowedForFields.TryAdd(key: column, value: roles))
303303
{
@@ -309,7 +309,6 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction
309309
}
310310
}
311311

312-
// The roles allowed for Fields are the roles allowed to READ the fields, so any role that has a read definition for the field.
313312
// Only add objectTypeDefinition for GraphQL if it has a role definition defined for access.
314313
if (rolesAllowedForEntity.Any())
315314
{
@@ -397,23 +396,18 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction
397396
GenerateSourceTargetLinkingObjectDefinitions(objectTypes, linkingObjectTypes);
398397
}
399398

400-
// Return a list of all the object types to be exposed in the schema.
401-
Dictionary<string, FieldDefinitionNode> fields = new();
402-
403-
// Add the DBOperationResult type to the schema
404399
NameNode nameNode = new(value: GraphQLUtils.DB_OPERATION_RESULT_TYPE);
405-
FieldDefinitionNode field = GetDbOperationResultField();
406-
407-
fields.TryAdd(GraphQLUtils.DB_OPERATION_RESULT_FIELD_NAME, field);
408400

401+
// Add the DBOperationResult type to the schema
409402
objectTypes.Add(GraphQLUtils.DB_OPERATION_RESULT_TYPE, new ObjectTypeDefinitionNode(
410403
location: null,
411404
name: nameNode,
412405
description: null,
413406
new List<DirectiveNode>(),
414407
new List<NamedTypeNode>(),
415-
fields.Values.ToImmutableList()));
408+
ImmutableList.Create(GetDbOperationResultField())));
416409

410+
// Return a list of all the object types to be exposed in the schema.
417411
List<IDefinitionNode> nodes = new(objectTypes.Values);
418412
nodes.AddRange(enumTypes.Values);
419413
return new DocumentNode(nodes);
@@ -748,7 +742,7 @@ private static FieldDefinitionNode GetDbOperationResultField()
748742
DocumentNode cosmosResult = GenerateCosmosGraphQLObjects(cosmosDataSourceNames, inputObjects);
749743
DocumentNode sqlResult = GenerateSqlGraphQLObjects(sql, inputObjects);
750744
// Create Root node with definitions from both cosmos and sql.
751-
DocumentNode root = new(cosmosResult.Definitions.Concat(sqlResult.Definitions).ToImmutableList());
745+
DocumentNode root = cosmosResult.WithDefinitions(cosmosResult.Definitions.Concat(sqlResult.Definitions).ToImmutableList());
752746

753747
// Merge the inputobjectType definitions from cosmos and sql onto the root.
754748
return (root.WithDefinitions(root.Definitions.Concat(inputObjects.Values).ToImmutableList()), inputObjects);

0 commit comments

Comments
 (0)