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
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ public async Task<CallToolResult> ExecuteAsync(
}
}
}
catch (OperationCanceledException)
{
return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The create operation was canceled.", logger);
}
catch (Exception ex)
{
return McpResponseBuilder.BuildErrorResult(toolName, "Error", $"Error: {ex.Message}", logger);
Expand Down
5 changes: 2 additions & 3 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -696,14 +696,13 @@ private void WriteError(JsonElement? id, int code, string message)
/// Extracts the value of a JSON-RPC request identifier.
/// </summary>
/// <param name="id">The JSON element representing the request identifier.</param>
/// <returns>The extracted identifier value as an object, or null if the identifier is not a primitive type.</returns>
/// <returns>The string value or a cloned numeric element, or null if the identifier is not a supported primitive type.</returns>
private static object? GetIdValue(JsonElement id)
{
return id.ValueKind switch
{
JsonValueKind.String => id.GetString(),
JsonValueKind.Number => id.TryGetInt64(out long l) ? l :
id.TryGetDouble(out double d) ? d : null,
JsonValueKind.Number => id.Clone(),
_ => null
};
}
Expand Down
5 changes: 2 additions & 3 deletions src/Config/Converters/DmlToolsConfigConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ internal class DmlToolsConfigConverter : JsonConverter<DmlToolsConfig>
/// Reads DmlToolsConfig from JSON which can be either:
/// - A boolean: all tools are enabled/disabled
/// - An object: individual tool settings (unspecified tools default to true)
/// - Null/undefined: defaults to all tools enabled (true)
/// - Null: defaults to all tools enabled (true)
/// </summary>
public override DmlToolsConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
Expand Down Expand Up @@ -163,8 +163,7 @@ internal class DmlToolsConfigConverter : JsonConverter<DmlToolsConfig>
aggregateRecordsQueryTimeout: aggregateRecordsQueryTimeout);
}

// For any other unexpected token type, return default (all enabled)
return DmlToolsConfig.Default;
throw new JsonException("The MCP dml-tools configuration must be a boolean or object value.");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ private class HealthCheckOptionsConverter : JsonConverter<EntityHealthCheckConfi
int parseThresholdMs = reader.GetInt32();
if (parseThresholdMs <= 0)
{
throw new JsonException($"Invalid value for ttl-seconds: {parseThresholdMs}. Value must be greater than 0.");
throw new JsonException($"Invalid value for threshold-ms: {parseThresholdMs}. Value must be greater than 0.");
}

threshold_ms = parseThresholdMs;
Expand Down
17 changes: 15 additions & 2 deletions src/Config/DatabasePrimitives/DatabaseObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,21 @@ public bool Equals(ForeignKeyDefinition? other)

public override int GetHashCode()
{
return HashCode.Combine(
Pair, ReferencedColumns, ReferencingColumns);
HashCode hashCode = new();
hashCode.Add(Pair);
hashCode.Add(ReferencedColumns.Count);
foreach (string column in ReferencedColumns)
{
hashCode.Add(column, StringComparer.Ordinal);
}

hashCode.Add(ReferencingColumns.Count);
foreach (string column in ReferencingColumns)
{
hashCode.Add(column, StringComparer.Ordinal);
}

return hashCode.ToHashCode();
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Core/Models/SqlQueryStructures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public override bool Equals(object? obj)
/// <inheritdoc/>
public override int GetHashCode()
{
return base.GetHashCode() ^ Label.GetHashCode(StringComparison.Ordinal);
return HashCode.Combine(TableSchema, TableName, ColumnName, Label);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ protected static object ParseParamAsSystemType(string param, Type systemType)
"Date" => DateOnly.Parse(param),
"Guid" => Guid.Parse(param),
"TimeOnly" => TimeOnly.Parse(param),
"TimeSpan" => TimeOnly.Parse(param),
"TimeSpan" => TimeSpan.Parse(param, CultureInfo.InvariantCulture),
"Single[]" => ParseArrayIntoSystemType(param, systemType),
_ => throw new NotSupportedException($"{systemType.Name} is not supported")
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ public class AuthorizationResolverUnitTests
private const string TEST_AUTHENTICATION_TYPE = "TestAuth";
private const string TEST_CLAIMTYPE_NAME = "TestName";

[TestMethod]
public void GetRolesForOperation_NullEntityNameThrows()
{
Assert.ThrowsException<ArgumentNullException>(() =>
IAuthorizationResolver.GetRolesForOperation(null!, EntityActionOperation.Read, null));
}

#region Role Context Tests
/// <summary>
/// When the client role header is present, validates result when
Expand Down Expand Up @@ -1737,6 +1744,53 @@ public async Task TestClaimsParsingToJson()
Assert.AreEqual(expected: "", actual: claimsInRequestContext["nullValuedClaim"]);
}

[TestMethod]
public void GetProcessedUserClaims_MultipleClaimsPreserveArrayValueTypes()
{
List<Claim> claims = new()
{
new("booleans", "true", ClaimValueTypes.Boolean),
new("booleans", "false", ClaimValueTypes.Boolean),
new("integers", "-1", ClaimValueTypes.Integer),
new("integers", "2", ClaimValueTypes.Integer),
new("integer32s", "-3", ClaimValueTypes.Integer32),
new("integer32s", "4", ClaimValueTypes.Integer32),
new("uinteger32s", "5", ClaimValueTypes.UInteger32),
new("uinteger32s", "6", ClaimValueTypes.UInteger32),
new("integer64s", "-7", ClaimValueTypes.Integer64),
new("integer64s", "8", ClaimValueTypes.Integer64),
new("uinteger64s", "9", ClaimValueTypes.UInteger64),
new("uinteger64s", "10", ClaimValueTypes.UInteger64),
new("doubles", "11", ClaimValueTypes.Double),
new("doubles", "12", ClaimValueTypes.Double),
new("strings", "first", ClaimValueTypes.String),
new("strings", "second", ClaimValueTypes.String),
new("jsonNulls", "null", JsonClaimValueTypes.JsonNull),
new("jsonNulls", "null", JsonClaimValueTypes.JsonNull),
new("jsonObjects", "{\"id\":1}", JsonClaimValueTypes.Json),
new("jsonObjects", "{\"id\":2}", JsonClaimValueTypes.Json),
new("customs", "alpha", ClaimValueTypes.DateTime),
new("customs", "beta", ClaimValueTypes.DateTime)
};
ClaimsIdentity identity = new(claims, TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE);
DefaultHttpContext context = new() { User = new ClaimsPrincipal(identity) };

Dictionary<string, string> processedClaims = AuthorizationResolver.GetProcessedUserClaims(context);

Assert.AreEqual("[true,false]", processedClaims["booleans"]);
Assert.AreEqual("[-1,2]", processedClaims["integers"]);
Assert.AreEqual("[-3,4]", processedClaims["integer32s"]);
Assert.AreEqual("[5,6]", processedClaims["uinteger32s"]);
Assert.AreEqual("[-7,8]", processedClaims["integer64s"]);
Assert.AreEqual("[9,10]", processedClaims["uinteger64s"]);
Assert.AreEqual("[11,12]", processedClaims["doubles"]);
Assert.AreEqual("[\"first\",\"second\"]", processedClaims["strings"]);
Assert.AreEqual("[\"null\",\"null\"]", processedClaims["jsonNulls"]);
Assert.AreEqual("[\"{\\u0022id\\u0022:1}\",\"{\\u0022id\\u0022:2}\"]", processedClaims["jsonObjects"]);
Assert.AreEqual("[\"alpha\",\"beta\"]", processedClaims["customs"]);
Assert.AreEqual(0, AuthorizationResolver.GetProcessedUserClaims(null).Count);
}

/// <summary>
/// JWT token JSON payloads may not be flat and may contain nested JSON objects or arrays.
/// This test validates that when dotnet's JWT processing code flattens the JWT token payload
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Auth;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
Expand Down Expand Up @@ -288,6 +290,132 @@ public async Task FindColumnPermissionsTests(string[] columnsRequestedInput,
CollectionAssert.AreEquivalent(expected: (ICollection)allowedColumns, actual: stubRestRequestContext.FieldsToBeReturned, message: "FieldsToBeReturned not subset of allowed columns.");
}

[TestMethod]
public async Task MultipleRequirementsAreRejected()
{
AuthorizationHandlerContext context = new(
new IAuthorizationRequirement[] { new RoleContextPermissionsRequirement(), new ColumnsPermissionsRequirement() },
new ClaimsPrincipal(),
AuthorizationHelpers.TEST_ENTITY);
RestAuthorizationHandler handler = CreateHandler(new Mock<IAuthorizationResolver>().Object, CreateHttpContext());

await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => handler.HandleAsync(context));
}

[TestMethod]
public async Task MissingHttpContextIsRejected()
{
AuthorizationHandlerContext context = new(
new IAuthorizationRequirement[] { new RoleContextPermissionsRequirement() },
new ClaimsPrincipal(),
AuthorizationHelpers.TEST_ENTITY);
RestAuthorizationHandler handler = CreateHandler(new Mock<IAuthorizationResolver>().Object, null);

await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => handler.HandleAsync(context));
}

[TestMethod]
public async Task UnsupportedHttpVerbIsRejected()
{
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => IsAuthorizationSuccessfulAsync(
new EntityRoleOperationPermissionsRequirement(),
AuthorizationHelpers.TEST_ENTITY,
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext("OPTIONS")));
}

[TestMethod]
public async Task DeleteColumnRequirementSucceedsWithoutColumnChecks()
{
bool result = await IsAuthorizationSuccessfulAsync(
new ColumnsPermissionsRequirement(),
CreateRestRequestContext(Array.Empty<string>()),
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext(HttpConstants.DELETE));

Assert.IsTrue(result);
}

[DataTestMethod]
[DataRow(true, true)]
[DataRow(false, false)]
public async Task EmptyInsertColumnsDependOnAccessibleFields(bool hasAccessibleFields, bool expected)
{
Mock<IAuthorizationResolver> resolver = new();
resolver.Setup(x => x.GetAllowedExposedColumns(
AuthorizationHelpers.TEST_ENTITY,
AuthorizationHelpers.TEST_ROLE,
EntityActionOperation.Create))
.Returns(hasAccessibleFields ? new[] { "id" } : Array.Empty<string>());
using JsonDocument payload = JsonDocument.Parse("{}");
RestRequestContext context = new InsertRequestContext(
AuthorizationHelpers.TEST_ENTITY,
new DatabaseTable { TableDefinition = new SourceDefinition() },
payload.RootElement,
EntityActionOperation.Insert);

bool result = await IsAuthorizationSuccessfulAsync(
new ColumnsPermissionsRequirement(),
context,
resolver.Object,
CreateHttpContext(HttpConstants.POST));

Assert.AreEqual(expected, result);
}

[TestMethod]
public async Task InvalidColumnsRequirementResourceIsRejected()
{
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => IsAuthorizationSuccessfulAsync(
new ColumnsPermissionsRequirement(),
new object(),
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext()));
}

[DataTestMethod]
[DataRow(true, true)]
[DataRow(false, false)]
public async Task StoredProcedureRequirementUsesResolverDecision(bool permitted, bool expected)
{
Mock<IAuthorizationResolver> resolver = new();
resolver.Setup(x => x.IsStoredProcedureExecutionPermitted(
AuthorizationHelpers.TEST_ENTITY,
AuthorizationHelpers.TEST_ROLE,
SupportedHttpVerb.Post))
.Returns(permitted);

bool result = await IsAuthorizationSuccessfulAsync(
new StoredProcedurePermissionsRequirement(),
AuthorizationHelpers.TEST_ENTITY,
resolver.Object,
CreateHttpContext(HttpConstants.POST));

Assert.AreEqual(expected, result);
}

[TestMethod]
public async Task StoredProcedureRequirementFailsForNullResource()
{
bool result = await IsAuthorizationSuccessfulAsync(
new StoredProcedurePermissionsRequirement(),
null,
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext(HttpConstants.POST));

Assert.IsFalse(result);
}

[TestMethod]
public async Task InvalidStoredProcedureResourceIsRejected()
{
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => IsAuthorizationSuccessfulAsync(
new StoredProcedurePermissionsRequirement(),
new object(),
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext(HttpConstants.POST)));
}

#region Helper Methods
/// <summary>
/// Setup request and authorization context and get Authorization result
Expand Down Expand Up @@ -315,6 +443,13 @@ private static async Task<bool> IsAuthorizationSuccessfulAsync(
return context.HasSucceeded;
}

private static RestAuthorizationHandler CreateHandler(IAuthorizationResolver resolver, HttpContext? httpContext)
{
Mock<IHttpContextAccessor> accessor = new();
accessor.Setup(x => x.HttpContext).Returns(httpContext);
return new RestAuthorizationHandler(resolver, accessor.Object, new Mock<ILogger<RestAuthorizationHandler>>().Object);
}

/// <summary>
/// Create Mock HttpContext object for use in test fixture.
/// </summary>
Expand Down
Loading