n4nAI

Semantic Kernel enterprise tutorial: role-based access

Implement role-based access control in Semantic Kernel for .NET enterprise apps with middleware, policies, and plugin-level authorization.

n4n Team4 min read887 words

Audio narration

Coming soon — every post will get a voice note here.

Semantic Kernel enterprise role-based access control requires more than decorating controllers with [Authorize]. When your application exposes AI capabilities through plugins, planners, and chat completion services, authorization decisions must happen at the kernel level — before a prompt reaches the model, before a function executes, and before sensitive data enters context. This guide walks through a production-ready RBAC implementation that integrates with ASP.NET Core’s authorization system while respecting Semantic Kernel’s execution pipeline.

The authorization surface in Semantic Kernel

Semantic Kernel executes code through three primary paths that each need protection:

  1. Function invocation — plugins called directly or via planner
  2. Prompt rendering — templates that may include user data or system instructions
  3. Chat completion — multi-turn conversations where context accumulates

A naive approach wraps each kernel call in a controller action with [Authorize(Roles = "Admin")]. This fails because planners dynamically select functions at runtime, and the kernel itself doesn’t know about HTTP contexts. You need authorization that travels with the kernel instance.

Building a kernel-scoped authorization middleware

Create a KernelAuthorizationMiddleware that intercepts function invocations before they execute. This middleware runs inside the kernel’s function invocation pipeline, giving you access to the function metadata, arguments, and the current principal.

public class KernelAuthorizationMiddleware
{
    private readonly IAuthorizationService _authorizationService;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public KernelAuthorizationMiddleware(
        IAuthorizationService authorizationService,
        IHttpContextAccessor httpContextAccessor)
    {
        _authorizationService = authorizationService;
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task InvokeAsync(FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next)
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext?.User == null)
        {
            throw new UnauthorizedAccessException("No authenticated principal available for kernel execution");
        }

        var functionName = context.Function.Name;
        var pluginName = context.Function.PluginName;
        var resource = new KernelFunctionResource(pluginName, functionName, context.Arguments);

        var result = await _authorizationService.AuthorizeAsync(httpContext.User, resource, "KernelFunctionPolicy");
        if (!result.Succeeded)
        {
            throw new ForbiddenException($"Access denied to function {pluginName}.{functionName}");
        }

        await next(context);
    }
}

public record KernelFunctionResource(string PluginName, string FunctionName, KernelArguments Arguments);

Register this middleware in your kernel builder:

var builder = Kernel.CreateBuilder()
    .AddAzureOpenAIChatCompletion(deploymentName, endpoint, apiKey)
    .Plugins.AddFromType<EmailPlugin>()
    .Plugins.AddFromType<DatabasePlugin>();

builder.Services.AddSingleton<KernelAuthorizationMiddleware>();

// In Program.cs
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("KernelFunctionPolicy", policy =>
    {
        policy.Requirements.Add(new KernelFunctionRequirement());
    });
});

Defining resource-based requirements

The KernelFunctionRequirement lets you evaluate permissions based on plugin name, function name, and even argument values. This is where enterprise RBAC gets granular — you can restrict DatabasePlugin.ExecuteQuery to read-only for analysts while allowing DBAs full write access.

public class KernelFunctionRequirement : IAuthorizationRequirement { }

public class KernelFunctionAuthorizationHandler : AuthorizationHandler<KernelFunctionRequirement, KernelFunctionResource>
{
    private readonly IPermissionService _permissionService;

    public KernelFunctionAuthorizationHandler(IPermissionService permissionService)
    {
        _permissionService = permissionService;
    }

    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        KernelFunctionRequirement requirement,
        KernelFunctionResource resource)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (string.IsNullOrEmpty(userId))
        {
            return; // Fail silently, let other handlers decide
        }

        var hasPermission = await _permissionService.CheckAsync(userId, resource);
        if (hasPermission)
        {
            context.Succeed(requirement);
        }
    }
}

The IPermissionService implementation depends on your identity store. A common pattern maps roles to function permissions in a database or configuration:

public class PermissionService : IPermissionService
{
    private readonly IOptions<KernelPermissionOptions> _options;

    public PermissionService(IOptions<KernelPermissionOptions> options)
    {
        _options = options;
    }

    public async Task<bool> CheckAsync(string userId, KernelFunctionResource resource)
    {
        var userRoles = await GetUserRolesAsync(userId);
        var requiredRoles = _options.Value.GetRequiredRoles(resource.PluginName, resource.FunctionName);
        
        return userRoles.Intersect(requiredRoles).Any();
    }
}

public class KernelPermissionOptions
{
    private readonly Dictionary<string, Dictionary<string, HashSet<string>>> _permissions = new();

    public void AddPermission(string plugin, string function, params string[] roles)
    {
        if (!_permissions.ContainsKey(plugin))
        {
            _permissions[plugin] = new Dictionary<string, HashSet<string>>();
        }
        _permissions[plugin][function] = new HashSet<string>(roles, StringComparer.OrdinalIgnoreCase);
    }

    public HashSet<string> GetRequiredRoles(string plugin, string function)
    {
        return _permissions.TryGetValue(plugin, out var pluginPerms) &&
               pluginPerms.TryGetValue(function, out var roles)
            ? roles
            : new HashSet<string>(); // Empty = deny by default
    }
}

Configure permissions at startup:

builder.Services.Configure<KernelPermissionOptions>(options =>
{
    options.AddPermission("EmailPlugin", "SendEmail", "Marketing", "Support", "Admin");
    options.AddPermission("EmailPlugin", "DeleteEmail", "Admin");
    options.AddPermission("DatabasePlugin", "ExecuteQuery", "Analyst", "DBA", "Admin");
    options.AddPermission("DatabasePlugin", "ExecuteNonQuery", "DBA", "Admin");
    options.AddPermission("FilePlugin", "ReadFile", "User", "Admin");
    options.AddPermission("FilePlugin", "WriteFile", "Admin");
});

Protecting prompt templates and context

Function-level authorization isn’t enough when planners construct prompts that include sensitive data. A user with Analyst role might be authorized for DatabasePlugin.ExecuteQuery but shouldn’t see connection strings or PII in the prompt template itself.

Use a PromptRenderFilter to sanitize or block template rendering based on the current principal:

public class PromptAuthorizationFilter : IPromptRenderFilter
{
    private readonly IAuthorizationService _authorizationService;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public PromptAuthorizationFilter(
        IAuthorizationService authorizationService,
        IHttpContextAccessor httpContextAccessor)
    {
        _authorizationService = authorizationService;
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task OnPromptRenderAsync(PromptRenderContext context, Func<PromptRenderContext, Task> next)
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext?.User == null)
        {
            await next(context);
            return;
        }

        var templateName = context.PromptTemplateConfig.Name;
        var resource = new PromptTemplateResource(templateName, context.Arguments);

        var result = await _authorizationService.AuthorizeAsync(httpContext.User, resource, "PromptTemplatePolicy");
        if (!result.Succeeded)
        {
            throw new ForbiddenException($"Access denied to prompt template {templateName}");
        }

        // Sanitize arguments before rendering
        SanitizeArguments(context.Arguments, httpContext.User);

        await next(context);
    }

    private void SanitizeArguments(KernelArguments arguments, ClaimsPrincipal user)
    {
        var isAdmin = user.IsInRole("Admin");
        if (!isAdmin)
        {
            // Remove or mask sensitive keys
            var sensitiveKeys = new[] { "connectionString", "apiKey", "password", "ssn" };
            foreach (var key in sensitiveKeys)
            {
                if (arguments.ContainsKey(key))
                {
                    arguments[key] = "[REDACTED]";
                }
            }
        }
    }
}

public record PromptTemplateResource(string TemplateName, KernelArguments Arguments);

Register the filter:

builder.Services.AddSingleton<IPromptRenderFilter, PromptAuthorizationFilter>();

// In kernel construction
var kernel = builder.Build();
kernel.PromptRenderFilters.Add(kernel.Services.GetRequiredService<IPromptRenderFilter>());

Handling planner-driven execution

Planners (FunctionCallingStepwisePlanner, HandlebarsPlanner) dynamically select and chain functions. Your authorization middleware runs for each function invocation, but planners also need to know which functions are available to avoid generating invalid plans.

Implement a FunctionVisibilityProvider that filters the function catalog based on the current user’s permissions:

public interface IFunctionVisibilityProvider
{
    IReadOnlyList<KernelFunction> GetVisibleFunctions(KernelPluginCollection plugins, ClaimsPrincipal user);
}

public class RoleBasedFunctionVisibilityProvider : IFunctionVisibilityProvider
{
    private readonly IPermissionService _permissionService;

    public RoleBasedFunctionVisibilityProvider(IPermissionService permissionService)
    {
        _permissionService = permissionService;
    }

    public IReadOnlyList<KernelFunction> GetVisibleFunctions(KernelPluginCollection plugins, ClaimsPrincipal user)
    {
        var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (string.IsNullOrEmpty(userId))
        {
            return Array.Empty<KernelFunction>();
        }

        var visibleFunctions = new List<KernelFunction>();
        foreach (var plugin in plugins)
        {
            foreach (var function in plugin)
            {
                var resource = new KernelFunctionResource(plugin.Name, function.Name, new KernelArguments());
                if (_permissionService.CheckAsync(userId, resource).GetAwaiter().GetResult())
                {
                    visibleFunctions.Add(function);
                }
            }
        }
        return visibleFunctions;
    }
}

Pass the filtered function list to your planner:

public class AuthorizedPlanner
{
    private readonly Kernel _kernel;
    private readonly IFunctionVisibilityProvider _visibilityProvider;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public AuthorizedPlanner(Kernel kernel, IFunctionVisibilityProvider visibilityProvider, IHttpContextAccessor httpContextAccessor)
    {
        _kernel = kernel;
        _visibilityProvider = visibilityProvider;
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task<Plan> CreatePlanAsync(string goal, CancellationToken cancellationToken = default)
    {
        var user = _httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal();
        var visibleFunctions = _visibilityProvider.GetVisibleFunctions(_kernel.Plugins, user);
        
        var planner = new FunctionCallingStepwisePlanner();
        var plan = await planner.CreatePlanAsync(_kernel, goal, visibleFunctions, cancellationToken);
        return plan;
    }
}

Pitfall: Filtering functions at plan creation time creates a TOCTOU (time-of-check-time-of-use) window. A user’s permissions could change between plan creation and execution. The middleware authorization on each function invocation remains your source of truth — the visibility filter is a UX optimization, not a security boundary.

Chat completion with conversation-level RBAC

Multi-turn conversations accumulate context that may cross permission boundaries. A user starts a conversation with Analyst permissions, gets promoted to DBA mid-session, and the existing chat history now contains data they shouldn’t have seen.

Implement a ChatContextFilter that validates the entire conversation history against current permissions before each completion request:

public class ChatContextAuthorizationFilter : IChatCompletionFilter
{
    private readonly IAuthorizationService _authorizationService;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public ChatContextAuthorizationFilter(
        IAuthorizationService authorizationService,
        IHttpContextAccessor httpContextAccessor)
    {
        _authorizationService = authorizationService;
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task OnChatCompletionAsync(ChatCompletionContext context, Func<ChatCompletionContext, Task> next)
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext?.User == null)
        {
            await next(context);
            return;
        }

        // Validate each message in history against current permissions
        foreach (var message in context.History)
        {
            if (message.Role == AuthorRole.Tool)
            {
                var functionName = message.Metadata?["FunctionName"]?.ToString();
                var pluginName = message.Metadata?["PluginName"]?.ToString();
                
                if (!string.IsNullOrEmpty(functionName) && !string.IsNullOrEmpty(pluginName))
                {
                    var resource = new KernelFunctionResource(pluginName, functionName, new KernelArguments());
                    var result = await _authorizationService.AuthorizeAsync(httpContext.User, resource, "KernelFunctionPolicy");
                    
                    if (!result.Succeeded)
                    {
                        // Remove unauthorized tool results from history
                        context.History.Remove(message);
                    }
                }
            }
        }

        await next(context);
    }
}

Register it:

builder.Services.AddSingleton<IChatCompletionFilter, ChatContextAuthorizationFilter>();

// In kernel construction
kernel.ChatCompletionFilters.Add(kernel.Services.GetRequiredService<IChatCompletionFilter>());

Tradeoff: Stripping tool results from history breaks conversation coherence. The model may reference data that no longer exists in context. Alternative approaches include encrypting sensitive tool outputs with a key derived from the user’s permissions, or maintaining separate conversation histories per permission level. Choose based on your compliance requirements.

Integrating with external identity providers

Enterprise environments typically use Azure AD, Okta, or Keycloak. Map external roles to your kernel permissions at token validation time:

public class KernelClaimsTransformation : IClaimsTransformation
{
    private readonly IOptions<KernelPermissionOptions> _permissionOptions;

    public KernelClaimsTransformation(IOptions<KernelPermissionOptions> permissionOptions)
    {
        _permissionOptions = permissionOptions;
    }

    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        var identity = (ClaimsIdentity)principal.Identity!;
        
        // Map Azure AD app roles to kernel roles
        var appRoles = identity.FindAll("roles").Select(c => c.Value).ToList();
        foreach (var role in appRoles)
        {
            if (IsValidKernelRole(role))
            {
                identity.AddClaim(new Claim(ClaimTypes.Role, role));
            }
        }

        // Add kernel-specific permissions as claims for faster checks
        var kernelRoles = identity.FindAll(ClaimTypes.Role).Select(c => c.Value).ToHashSet();
        foreach (var plugin in _permissionOptions.Value.GetAllPlugins())
        {
            foreach (var function in _permissionOptions.Value.GetFunctions(plugin))
            {
                var requiredRoles = _permissionOptions.Value.GetRequiredRoles(plugin, function);
                if (kernelRoles.Overlaps(requiredRoles))
                {
                    identity.AddClaim(new Claim("kernel:permission", $"{plugin}.{function}"));
                }
            }
        }

        return Task.FromResult(principal);
    }

    private bool IsValidKernelRole(string role)
    {
        var validRoles = new[] { "Admin", "DBA", "Analyst", "Marketing", "Support", "User" };
        return validRoles.Contains(role, StringComparer.OrdinalIgnoreCase);
    }
}

builder.Services.AddTransient<IClaimsTransformation, KernelClaimsTransformation>();

This transformation runs on every request, enriching the principal with kernel-specific permission claims. Your PermissionService can then check these claims directly without database round-trips:

public class ClaimsBasedPermissionService : IPermissionService
{
    public Task<bool> CheckAsync(string userId, KernelFunctionResource resource)
    {
        // This runs in the context of the current principal via IHttpContextAccessor
        var httpContext = _httpContextAccessor.HttpContext;
        var permissionClaim = $"kernel:permission:{resource.PluginName}.{resource.FunctionName}";
        return Task.FromResult(httpContext?.User.HasClaim("kernel:permission", permissionClaim) ?? false);
    }
}

Testing the authorization pipeline

Write integration tests that exercise the full kernel pipeline with different principals:

public class KernelAuthorizationTests
{
    private readonly WebApplicationFactory<Program> _factory;

    public KernelAuthorizationTests()
    {
        _factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureTestServices(services =>
                {
                    services.AddAuthentication("Test")
                        .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
                });
            });
    }

    [Theory]
    [InlineData("Analyst", "DatabasePlugin.ExecuteQuery", true)]
    [InlineData("Analyst", "DatabasePlugin.ExecuteNonQuery", false)]
    [InlineData("DBA", "DatabasePlugin.ExecuteNonQuery", true)]
    [InlineData("User", "EmailPlugin.SendEmail", false)]
    public async Task FunctionAuthorization_RespectsRoles(string role, string function, bool shouldSucceed)
    {
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test", role);

        var kernel = _factory.Services.GetRequiredService<Kernel>();
        var pluginName = function.Split('.')[0];
        var functionName = function.Split('.')[1];
        var kernelFunction = kernel.Plugins[pluginName][functionName];

        var act = async () => await kernel.InvokeAsync(kernelFunction, new KernelArguments());

        if (shouldSucceed)
        {
            await act.Should().NotThrowAsync();
        }
        else
        {
            await act.Should().ThrowAsync<ForbiddenException>();
        }
    }
}

Common pitfalls

Pitfall 1: Skipping middleware for direct function calls If you call kernel.InvokeAsync(function, args) from a background service or SignalR hub without an HTTP context, the middleware throws UnauthorizedAccessException. Create a SystemPrincipal for trusted internal callers:

public class SystemPrincipalProvider : IPrincipalProvider
{
    public ClaimsPrincipal GetPrincipal() => new ClaimsPrincipal(new ClaimsIdentity(new[]
    {
        new Claim(ClaimTypes.NameIdentifier, "system"),
        new Claim(ClaimTypes.Role, "System")
    }, "System"));
}

Pitfall 2: Caching authorization decisions incorrectly Don’t cache AuthorizationHandler results across requests. The AuthorizationHandlerContext is per-request. Cache the policy evaluation logic (role-to-permission mappings), not the decision.

Pitfall 3: Forgetting streaming responses When using kernel.InvokeStreamingAsync, the middleware runs once at invocation start. If a function streams partial results and fails authorization mid-stream, you’ve already sent data. For high-sensitivity functions, disable streaming or buffer the entire response before authorization.

Observability and audit logging

Enterprise RBAC requires audit trails. Add a KernelAuditLogger that records every authorization decision:

public class KernelAuditLogger
{
    private readonly ILogger<KernelAuditLogger> _logger;

    public void LogAuthorizationDecision(
        string userId,
        string pluginName,
        string functionName,
        bool allowed,
        string? reason = null)
    {
        _logger.LogInformation(
            "Kernel authorization decision: User={UserId} Plugin={Plugin} Function={Function} Allowed={Allowed} Reason={Reason}",
            userId, pluginName, functionName, allowed, reason ?? "N/A");
    }
}

Inject it into your authorization handler and middleware. Structure logs for SIEM ingestion — include correlation IDs, timestamp, and the full resource identifier.

Scaling considerations

As your plugin catalog grows, the function visibility filter becomes a performance bottleneck. Optimize by:

  1. Pre-computing visible functions at token issuance time and storing as a claim
  2. Using a distributed cache (Redis) for permission lookups with short TTL
  3. Partitioning plugins by security domain — separate kernels for separate trust zones

For multi-tenant applications, scope the KernelPermissionOptions per tenant and resolve them via ITenantContext in your PermissionService.

Summary

Semantic Kernel enterprise role-based access control works when you treat the kernel as a protected resource, not just the HTTP endpoints that invoke it. The middleware pipeline gives you interception points at function invocation, prompt rendering, and chat completion — use all three. Filter planner visibility for UX, but enforce authorization at execution time. Map external identity to kernel permissions at the claims transformation layer. Audit every decision. The result is an AI execution environment where “who can do what” is as enforceable as your database row-level security.

Tagssemantic-kernelenterpriserbacsecurity

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel for .net enterprise apps posts →