Skip to content

Commit 28c0d60

Browse files
authored
.Net: Harden structured data mutation boundaries (#14718)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> Structured data mutation functions need a clear boundary between model-provided request data and Entity Framework persistence entities. Applications also need to control how update and delete targets are resolved before a mutation runs. ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> - Makes the convenience plugin factory query-only by default and rejects mutation operations that lack explicit application configuration. - Keeps the legacy direct-entity mutation overloads for compatibility, but makes them fail closed. - Adds safe mutation overloads that use separate request types, explicit insert/update mapping, and application-scoped tracked-entity resolution for update and delete. - Updates the structured data sample to demonstrate explicit safe function composition. - Adds regression coverage for request mapping, scoped resolution, detached entities, and the fail-closed legacy surface. Validation: - `dotnet build dotnet\src\Plugins\Plugins.StructuredData.EntityFramework\Plugins.StructuredData.EntityFramework.csproj -c Debug --tl:off --warnaserror --no-restore` — passed for `net10.0`, `net8.0`, and `net462` with 0 warnings and 0 errors. - `dotnet build dotnet\src\Plugins\Plugins.UnitTests\Plugins.UnitTests.csproj -c Debug -f net10.0 --tl:off --warnaserror --no-restore` — passed with 0 warnings and 0 errors. - `dotnet test dotnet\src\Plugins\Plugins.UnitTests\Plugins.UnitTests.csproj -c Debug -f net10.0 --no-build --no-restore -v Normal --logger trx` — 592 passed, 15 skipped, 0 failed. - `dotnet build dotnet\samples\Demos\StructuredDataPlugin\StructuredDataPlugin.csproj -c Debug --tl:off --warnaserror --no-restore` — passed with 0 warnings and 0 errors. - Scoped `dotnet format --verify-no-changes` — passed natively and with `mcr.microsoft.com/dotnet/sdk:10.0` for all affected projects. ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄
1 parent 1a3ccd8 commit 28c0d60

6 files changed

Lines changed: 521 additions & 53 deletions

File tree

‎dotnet/samples/Demos/StructuredDataPlugin/Product.cs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,51 @@ public sealed class Product
3737
[DatabaseGenerated(DatabaseGeneratedOption.Computed)]
3838
public DateTime? DateCreated { get; set; }
3939
}
40+
41+
/// <summary>
42+
/// Information the model may provide when creating a product.
43+
/// </summary>
44+
internal sealed class CreateProductRequest
45+
{
46+
/// <summary>
47+
/// Gets or sets the product name.
48+
/// </summary>
49+
[Description("The name of the product.")]
50+
public string? Name { get; set; }
51+
52+
/// <summary>
53+
/// Gets or sets the product price.
54+
/// </summary>
55+
[Description("The price of the product in USD.")]
56+
public decimal? Price { get; set; }
57+
}
58+
59+
/// <summary>
60+
/// Information the model may provide when updating a product.
61+
/// </summary>
62+
internal sealed class UpdateProductRequest
63+
{
64+
/// <summary>
65+
/// Gets or sets the unique product identifier.
66+
/// </summary>
67+
[Description("The unique identifier of the product to update.")]
68+
public Guid Id { get; set; }
69+
70+
/// <summary>
71+
/// Gets or sets the replacement price.
72+
/// </summary>
73+
[Description("The new price of the product in USD.")]
74+
public decimal? Price { get; set; }
75+
}
76+
77+
/// <summary>
78+
/// Information the model may provide when deleting a product.
79+
/// </summary>
80+
internal sealed class DeleteProductRequest
81+
{
82+
/// <summary>
83+
/// Gets or sets the unique product identifier.
84+
/// </summary>
85+
[Description("The unique identifier of the product to delete.")]
86+
public Guid Id { get; set; }
87+
}

‎dotnet/samples/Demos/StructuredDataPlugin/Program.cs‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

3+
using System.Data.Entity;
34
using Microsoft.Extensions.Configuration;
45
using Microsoft.Extensions.DependencyInjection;
56
using Microsoft.SemanticKernel;
@@ -30,9 +31,29 @@ internal static async Task Main(string[] args)
3031
modelId: "gpt-4o",
3132
apiKey: config["OpenAI:ApiKey"]!);
3233

33-
// Add the database plugin using the factory with default operations
34-
var databasePlugin = StructuredDataPluginFactory.CreateStructuredDataPlugin<ApplicationDbContext, Product>(
35-
structuredDataService);
34+
var selectFunction = structuredDataService.CreateSelectFunction<ApplicationDbContext, Product>();
35+
var insertFunction = structuredDataService.CreateInsertFunction<ApplicationDbContext, Product, CreateProductRequest>(
36+
input => new Product { Name = input.Name, Price = input.Price });
37+
var updateFunction = structuredDataService.CreateUpdateFunction<ApplicationDbContext, Product, UpdateProductRequest>(
38+
async (input, cancellationToken) =>
39+
{
40+
// This application-owned query is the row-scope boundary; real applications can add tenant or owner filters.
41+
return await structuredDataService.Context.Set<Product>()
42+
.SingleOrDefaultAsync(product => product.Id == input.Id, cancellationToken);
43+
},
44+
(input, product) => product.Price = input.Price);
45+
var deleteFunction = structuredDataService.CreateDeleteFunction<ApplicationDbContext, Product, DeleteProductRequest>(
46+
async (input, cancellationToken) =>
47+
{
48+
// This application-owned query is the row-scope boundary; real applications can add tenant or owner filters.
49+
return await structuredDataService.Context.Set<Product>()
50+
.SingleOrDefaultAsync(product => product.Id == input.Id, cancellationToken);
51+
});
52+
53+
var databasePlugin = KernelPluginFactory.CreateFromFunctions(
54+
"ProductDatabasePlugin",
55+
"Allows approved operations against products in the database",
56+
[selectFunction, insertFunction, updateFunction, deleteFunction]);
3657

3758
kernelBuilder.Plugins.Add(databasePlugin);
3859

@@ -59,13 +80,13 @@ internal static async Task Main(string[] args)
5980

6081
// Example 3: Updating records
6182
Console.WriteLine("\nUpdating a record...");
62-
var updatePrompt = "Update the price of 'Book' to 39.99 and keep its name";
83+
var updatePrompt = "Find the product named 'Book', then update its price to 39.99 using its ID";
6384
var updateResult = await kernel.InvokePromptAsync(updatePrompt, new(settings));
6485
Console.WriteLine($"Update Result: {updateResult}");
6586

6687
// Example 3: Updating records
6788
Console.WriteLine("\nDeleting a record...");
68-
var deletePrompt = "Delete the product 'Book'";
89+
var deletePrompt = "Find the product named 'Book', then delete it using its ID";
6990
var deleteResult = await kernel.InvokePromptAsync(deletePrompt, new(settings));
7091
Console.WriteLine($"Delete Result: {deleteResult}");
7192

@@ -74,7 +95,7 @@ internal static async Task Main(string[] args)
7495
Console.WriteLine("You can try queries like:");
7596
Console.WriteLine("- Find all products under $50");
7697
Console.WriteLine("- Insert a new product with name 'Table' and price 19.99");
77-
Console.WriteLine("- Update the price of 'Table' to 25.99");
98+
Console.WriteLine("- Find 'Table', then update its price to 25.99 using its ID");
7899

79100
while (true)
80101
{

‎dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataOperation.cs‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,11 @@ namespace Microsoft.SemanticKernel;
3131
public static StructuredDataOperation Delete { get; } = new("Delete");
3232

3333
/// <summary>
34-
/// The default set of supported operations.
34+
/// The default set of supported operations. Mutation operations require explicit application mapping and scoped resolution.
3535
/// </summary>
3636
public static readonly HashSet<StructuredDataOperation> Default =
3737
[
38-
Select,
39-
Insert,
40-
Update,
41-
Delete
38+
Select
4239
];
4340

4441
/// <summary>

‎dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataPluginFactory.cs‎

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
using System;
44
using System.Collections.Generic;
55
using System.Data.Entity;
6-
using System.Reflection;
76

87
namespace Microsoft.SemanticKernel;
98

@@ -18,10 +17,13 @@ public static class StructuredDataPluginFactory
1817
/// <typeparam name="TContext">The type of DbContext to use for database operations.</typeparam>
1918
/// <typeparam name="TEntity">The entity type to perform operations on.</typeparam>
2019
/// <param name="service">The structured data service instance to use.</param>
21-
/// <param name="operations">Optional collection of operations to support. Defaults to StructuredDataOperations.Default if not specified.</param>
20+
/// <param name="operations">Optional collection of operations to support. Defaults to query-only operations if not specified.</param>
2221
/// <param name="description">Optional description for the plugin. If not provided, a default description will be generated.</param>
2322
/// <returns>A KernelPlugin instance configured for the specified entity type and operations.</returns>
2423
/// <exception cref="InvalidOperationException">Thrown if the specified operation is not supported by the service.</exception>
24+
/// <exception cref="NotSupportedException">
25+
/// Thrown if a mutation operation is requested without the explicit mapping and scoped resolution required by the safe mutation APIs.
26+
/// </exception>
2527
public static KernelPlugin CreateStructuredDataPlugin<TContext, TEntity>(
2628
StructuredDataService<TContext> service,
2729
IEnumerable<StructuredDataOperation>? operations = null,
@@ -30,34 +32,29 @@ public static KernelPlugin CreateStructuredDataPlugin<TContext, TEntity>(
3032
where TEntity : class
3133
{
3234
operations ??= StructuredDataOperation.Default;
33-
description ??= $"Allows CRUD operations against the {typeof(TEntity).Name} entity in the database";
35+
description ??= $"Allows queries against the {typeof(TEntity).Name} entity in the database";
3436

3537
var functions = new List<KernelFunction>();
36-
var extensionsType = typeof(StructuredDataServiceExtensions);
3738

3839
foreach (var operation in operations)
3940
{
40-
// Look for Create{Operation}Function method in the extensions
41-
var methodName = $"Create{operation}Function";
42-
var method = extensionsType.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static);
43-
44-
if (method == null)
41+
if (operation == StructuredDataOperation.Select)
4542
{
46-
throw new InvalidOperationException(
47-
$"Operation '{operation}' is not supported. Extension method '{methodName}' not found.");
43+
functions.Add(service.CreateSelectFunction<TContext, TEntity>());
44+
continue;
4845
}
4946

50-
try
51-
{
52-
var genericMethod = method.MakeGenericMethod(typeof(TContext), typeof(TEntity));
53-
var function = (KernelFunction)genericMethod.Invoke(null, new object?[] { service, null })!;
54-
functions.Add(function);
55-
}
56-
catch (Exception ex) when (ex is not InvalidOperationException)
47+
if (operation == StructuredDataOperation.Insert ||
48+
operation == StructuredDataOperation.Update ||
49+
operation == StructuredDataOperation.Delete)
5750
{
58-
throw new InvalidOperationException(
59-
$"Failed to create function for operation '{operation}': {ex.Message}", ex);
51+
// Mutation functions need application-owned request mapping and row scope, which this convenience factory cannot infer.
52+
throw new NotSupportedException(
53+
$"Operation '{operation}' requires an explicitly created safe mutation function. " +
54+
$"Create it with a separate request type, then compose the plugin with {nameof(KernelPluginFactory)}.");
6055
}
56+
57+
throw new InvalidOperationException($"Operation '{operation}' is not supported.");
6158
}
6259

6360
return KernelPluginFactory.CreateFromFunctions(

0 commit comments

Comments
 (0)