Skip to content

Commit dc0c37a

Browse files
committed
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.
1 parent 091a627 commit dc0c37a

7 files changed

Lines changed: 251 additions & 4 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ 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+
if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation(
135+
authorizationResolver,
136+
entityName,
137+
effectiveRole!,
138+
EntityActionOperation.Create,
139+
requestedColumns,
140+
out string columnAuthError))
141+
{
142+
return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", columnAuthError, logger);
143+
}
144+
128145
JsonElement insertPayloadRoot = dataElement.Clone();
129146

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

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,19 @@ 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+
if (!McpAuthorizationHelper.AreColumnsAuthorizedForOperation(
172+
authResolver,
173+
entityName,
174+
effectiveRole!,
175+
EntityActionOperation.Update,
176+
fields.Keys,
177+
out string columnAuthError))
178+
{
179+
return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", columnAuthError, logger);
180+
}
181+
169182
// 6) Build and validate Upsert (UpdateIncremental) context
170183
JsonElement upsertPayloadRoot = RequestValidator.ValidateAndParseRequestBody(JsonSerializer.Serialize(fields));
171184
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 is null ? new List<string>() : columns.ToList();
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/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,72 @@ public async Task CreateRecord_MissingRequiredField_ReturnsError()
138138

139139
#endregion
140140

141+
#region Column-Level Authorization Tests
142+
143+
/// <summary>
144+
/// Regression test for column-level authorization bypass in create_record.
145+
/// A role holding CREATE permission on the entity, but with a column explicitly
146+
/// excluded via fields.exclude, must be denied when supplying a value for that column
147+
/// even though the operation itself is authorized. Fails pre-fix, passes post-fix.
148+
/// </summary>
149+
[TestMethod]
150+
public async Task CreateRecord_ExcludedColumn_ReturnsPermissionDenied()
151+
{
152+
var data = new Dictionary<string, object>
153+
{
154+
{ "title", "Should Not Be Created" },
155+
{ "publisher_id", 1234 }
156+
};
157+
158+
IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation");
159+
CreateRecordTool tool = new();
160+
161+
var args = new Dictionary<string, object?>
162+
{
163+
{ "entity", "Book" },
164+
{ "data", data }
165+
};
166+
167+
CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args);
168+
169+
AssertError(result, "permission",
170+
"CreateRecord should deny writes to columns excluded for the caller's role, even though " +
171+
"the role holds CREATE permission on the entity.");
172+
}
173+
174+
/// <summary>
175+
/// Sanity check accompanying <see cref="CreateRecord_ExcludedColumn_ReturnsPermissionDenied"/>:
176+
/// the same restricted role must still be able to create a record when it only supplies
177+
/// columns it is permitted to write.
178+
/// </summary>
179+
[TestMethod]
180+
public async Task CreateRecord_AllowedColumnsOnly_WithColumnRestrictedRole_ReturnsSuccess()
181+
{
182+
var data = new Dictionary<string, object>
183+
{
184+
{ "title", "Allowed Column Only Book" }
185+
};
186+
187+
IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation");
188+
CreateRecordTool tool = new();
189+
190+
var args = new Dictionary<string, object?>
191+
{
192+
{ "entity", "Book" },
193+
{ "data", data }
194+
};
195+
196+
CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args);
197+
198+
AssertSuccess(result, "CreateRecord should succeed when only permitted columns are supplied.");
199+
200+
JsonElement root = ParseResultRoot(result);
201+
int createdId = ExtractCreatedBookId(root);
202+
await DeleteTestBook(createdId);
203+
}
204+
205+
#endregion
206+
141207
#region Helpers
142208

143209
private static async Task<CallToolResult> ExecuteCreateAsync(string entity, Dictionary<string, object> data)

src/Service.Tests/Mcp/McpToolTestBase.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ protected static IServiceProvider BuildQueryServiceProvider()
9292
/// Includes: RuntimeConfigProvider, IMetadataProviderFactory, IAuthorizationResolver,
9393
/// IHttpContextAccessor, IMutationEngineFactory, RequestValidator.
9494
/// </summary>
95-
protected static IServiceProvider BuildMutationServiceProvider()
95+
/// <param name="role">
96+
/// Client role header value to use for the request. Defaults to the anonymous role.
97+
/// Pass a custom role (already defined in the test config's permissions) to exercise
98+
/// role-specific and column-level authorization scenarios.
99+
/// </param>
100+
protected static IServiceProvider BuildMutationServiceProvider(string role = AuthorizationResolver.ROLE_ANONYMOUS)
96101
{
97102
ServiceCollection services = new();
98103

@@ -102,7 +107,7 @@ protected static IServiceProvider BuildMutationServiceProvider()
102107
services.AddSingleton(_metadataProviderFactory.Object);
103108
services.AddSingleton(_authorizationResolver);
104109

105-
IHttpContextAccessor httpContextAccessor = CreateAnonymousHttpContextAccessor();
110+
IHttpContextAccessor httpContextAccessor = CreateHttpContextAccessorForRole(role);
106111
services.AddSingleton(httpContextAccessor);
107112

108113
services.AddSingleton(new RequestValidator(_metadataProviderFactory.Object, configProvider));
@@ -153,14 +158,23 @@ protected static IServiceProvider BuildMutationServiceProvider()
153158
/// Creates an HttpContextAccessor with anonymous role claims for MCP tool testing.
154159
/// </summary>
155160
protected static IHttpContextAccessor CreateAnonymousHttpContextAccessor()
161+
{
162+
return CreateHttpContextAccessorForRole(AuthorizationResolver.ROLE_ANONYMOUS);
163+
}
164+
165+
/// <summary>
166+
/// Creates an HttpContextAccessor with the given role set as both the client role header
167+
/// and a matching role claim, for exercising role-specific MCP tool authorization scenarios.
168+
/// </summary>
169+
protected static IHttpContextAccessor CreateHttpContextAccessorForRole(string role)
156170
{
157171
DefaultHttpContext httpContext = new();
158-
httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS;
172+
httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = role;
159173
ClaimsIdentity identity = new(
160174
authenticationType: "TestAuth",
161175
nameType: null,
162176
roleType: AuthenticationOptions.ROLE_CLAIM_TYPE);
163-
identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS));
177+
identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, role));
164178
httpContext.User = new ClaimsPrincipal(identity);
165179
return new HttpContextAccessor { HttpContext = httpContext };
166180
}

src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,81 @@ public async Task UpdateRecord_NullKeyValue_ReturnsError()
158158

159159
#endregion
160160

161+
#region Column-Level Authorization Tests
162+
163+
/// <summary>
164+
/// Regression test for column-level authorization bypass in update_record.
165+
/// A role holding UPDATE permission on the entity, but with a column explicitly
166+
/// excluded via fields.exclude, must be denied when supplying a value for that column
167+
/// even though the operation itself is authorized. Fails pre-fix, passes post-fix.
168+
/// </summary>
169+
[TestMethod]
170+
public async Task UpdateRecord_ExcludedColumn_ReturnsPermissionDenied()
171+
{
172+
int createdId = await CreateBookForUpdate("Book For Column Auth Test");
173+
try
174+
{
175+
var keys = new Dictionary<string, object> { { "id", createdId } };
176+
var fields = new Dictionary<string, object> { { "publisher_id", 9999 } };
177+
178+
IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation");
179+
UpdateRecordTool tool = new();
180+
181+
var args = new Dictionary<string, object?>
182+
{
183+
{ "entity", "Book" },
184+
{ "keys", keys },
185+
{ "fields", fields }
186+
};
187+
188+
CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args);
189+
190+
AssertError(result, "permission",
191+
"UpdateRecord should deny writes to columns excluded for the caller's role, even though " +
192+
"the role holds UPDATE permission on the entity.");
193+
}
194+
finally
195+
{
196+
await DeleteBook(createdId);
197+
}
198+
}
199+
200+
/// <summary>
201+
/// Sanity check accompanying <see cref="UpdateRecord_ExcludedColumn_ReturnsPermissionDenied"/>:
202+
/// the same restricted role must still be able to update a record when it only supplies
203+
/// columns it is permitted to write.
204+
/// </summary>
205+
[TestMethod]
206+
public async Task UpdateRecord_AllowedColumnsOnly_WithColumnRestrictedRole_ReturnsSuccess()
207+
{
208+
int createdId = await CreateBookForUpdate("Book For Allowed Column Update Test");
209+
try
210+
{
211+
var keys = new Dictionary<string, object> { { "id", createdId } };
212+
var fields = new Dictionary<string, object> { { "title", "Updated By Restricted Role" } };
213+
214+
IServiceProvider serviceProvider = BuildMutationServiceProvider(role: "test_role_with_excluded_fields_on_mutation");
215+
UpdateRecordTool tool = new();
216+
217+
var args = new Dictionary<string, object?>
218+
{
219+
{ "entity", "Book" },
220+
{ "keys", keys },
221+
{ "fields", fields }
222+
};
223+
224+
CallToolResult result = await ExecuteToolAsync(tool, serviceProvider, args);
225+
226+
AssertSuccess(result, "UpdateRecord should succeed when only permitted columns are supplied.");
227+
}
228+
finally
229+
{
230+
await DeleteBook(createdId);
231+
}
232+
}
233+
234+
#endregion
235+
161236
#region Helpers
162237

163238
private static async Task<CallToolResult> ExecuteUpdateAsync(

src/Service.Tests/dab-config.MsSql.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,33 @@
936936
}
937937
]
938938
},
939+
{
940+
"role": "test_role_with_excluded_fields_on_mutation",
941+
"actions": [
942+
{
943+
"action": "read"
944+
},
945+
{
946+
"action": "create",
947+
"fields": {
948+
"exclude": [
949+
"publisher_id"
950+
]
951+
}
952+
},
953+
{
954+
"action": "update",
955+
"fields": {
956+
"exclude": [
957+
"publisher_id"
958+
]
959+
}
960+
},
961+
{
962+
"action": "delete"
963+
}
964+
]
965+
},
939966
{
940967
"role": "role_multiple_create_policy_tester",
941968
"actions": [

0 commit comments

Comments
 (0)