Showing posts with label Managed Identity. Show all posts
Showing posts with label Managed Identity. Show all posts

Saturday, 7 March 2026

Integrating AI Agents in SharePoint with Azure Functions (.NET 8), Azure OpenAI, and React (TypeScript) using Managed Identity

Integrating AI Agents in SharePoint

AI Agents in SharePoint often stall on two fronts: secure Graph access and reliable orchestration. Teams hardcode keys, expose function keys in SPAs, and skip schema validation—leading to outages and security reviews. This solution shows how to integrate AI Agents in SharePoint using Azure Functions (.NET 8), Azure OpenAI, and React with strict TypeScript, Zod schemas, and Managed Identity—no secrets in code, no function keys.

Prerequisites

- Azure subscription with permissions to assign roles - .NET 8 SDK - Node.js 20+ - Azure Functions Core Tools v4 - Azure OpenAI resource (standard or global) with a deployed Chat Completions model (e.g., gpt-4o or gpt-35-turbo) - Microsoft Entra ID app registration for the SPA (React)

The Solution (Step-by-Step)

1) Architecture Overview

- React SPA authenticates with Microsoft Entra ID (MSAL) and calls an Azure Function over HTTPS. - Azure Function uses Managed Identity for two things: - Call Azure OpenAI without API keys. - Call Microsoft Graph for SharePoint operations (e.g., list items, search content). - Optional: Retrieve non-secret configuration from Azure Key Vault using Managed Identity.

2) Azure Roles and Permissions

- Function App (system-assigned managed identity): - On Azure OpenAI resource: Cognitive Services OpenAI User role. - On Azure Key Vault: Key Vault Secrets User role (only if you store configuration there). - On Microsoft Graph: Application permissions as needed, e.g., Sites.Read.All (read) or Sites.ReadWrite.All (write). Grant admin consent in Enterprise Applications for the Function’s managed identity service principal. - SPA app registration: - Expose an API for your Function App or configure Easy Auth with audience (Application ID URI). The SPA will request tokens for this audience.

3) Azure Function (.NET 8, Isolated Worker) with Managed Identity

// File: Program.cs // Purpose: Configure DI, Managed Identity credentials, HTTP trigger, and CORS. using System.Net; using Azure; using Azure.AI.OpenAI; using Azure.Core; using Azure.Identity; // DefaultAzureCredential using Azure.Security.KeyVault.Secrets; // Optional: Key Vault using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Middleware; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; var host = new HostBuilder() .ConfigureFunctionsWorkerDefaults() .ConfigureServices(services => { // Use DefaultAzureCredential to support Managed Identity in Azure and dev flows locally. services.AddSingleton(_ => new DefaultAzureCredential()); // OpenAI client uses Managed Identity; endpoint comes from configuration (App Setting: AZURE_OPENAI_ENDPOINT). services.AddSingleton(sp => { var cred = sp.GetRequiredService(); var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT not configured."); return new OpenAIClient(new Uri(endpoint), cred); }); // Optional Key Vault to fetch non-secret config at runtime. services.AddSingleton(sp => { var cred = sp.GetRequiredService(); var kvUri = Environment.GetEnvironmentVariable("KEY_VAULT_URI"); return kvUri is null ? null! : new SecretClient(new Uri(kvUri), cred); }); // Graph client factory using Managed Identity. You can use Graph SDK or raw HTTP with token. services.AddHttpClient("graph"); services.AddSingleton(); services.AddSingleton(); }) .Build(); await host.RunAsync(); // File: Services.cs // Purpose: Token acquisition for Graph and agent orchestration including OpenAI and SharePoint. using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; using Azure.Core; public interface IGraphTokenProvider { // Acquire an application token for Microsoft Graph using Managed Identity. Task GetTokenAsync(CancellationToken ct); } public sealed class GraphTokenProvider(TokenCredential credential) : IGraphTokenProvider { private readonly TokenCredential _credential = credential; private static readonly string[] Scopes = ["https://graph.microsoft.com/.default"]; // Application permissions public async Task GetTokenAsync(CancellationToken ct) { var token = await _credential.GetTokenAsync(new TokenRequestContext(Scopes), ct); return token.Token; } } public interface IAgentService { // Orchestrate: summarize SharePoint content then respond via Azure OpenAI. Task HandleAsync(AgentRequest request, CancellationToken ct); } public sealed class AgentService(OpenAIClient openAi, IHttpClientFactory httpFactory, IGraphTokenProvider tokenProvider, ILogger logger) : IAgentService { private readonly OpenAIClient _openAi = openAi; private readonly IHttpClientFactory _httpFactory = httpFactory; private readonly IGraphTokenProvider _tokenProvider = tokenProvider; private readonly ILogger _logger = logger; public async Task HandleAsync(AgentRequest request, CancellationToken ct) { // 1) Fetch SharePoint content via Microsoft Graph based on request.SiteId and query. var graphClient = _httpFactory.CreateClient("graph"); var token = await _tokenProvider.GetTokenAsync(ct); graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Example: Search site for files matching the query. Adjust endpoints to your needs. var searchPayload = new { requests = new[] { new { entityTypes = new[] { "driveItem" }, query = new { queryString = request.Query }, from = 0, size = 5, fields = new[] { "name", "path", "title" } } } }; using var content = new StringContent(JsonSerializer.Serialize(searchPayload), Encoding.UTF8, "application/json"); using var resp = await graphClient.PostAsync("https://graph.microsoft.com/v1.0/search/query", content, ct); if (!resp.IsSuccessStatusCode) { _logger.LogWarning("Graph search failed: {Status}", resp.StatusCode); } var graphJson = await resp.Content.ReadAsStringAsync(ct); // 2) Ask Azure OpenAI to synthesize an answer citing found items. // Use a deterministic, concise system prompt to guide the agent. var systemPrompt = "You are a SharePoint-aware assistant. Summarize results and provide next steps. Be concise."; var chat = new ChatCompletionsOptions { Messages = { new ChatRequestSystemMessage(systemPrompt), new ChatRequestUserMessage($"User query: {request.Query}\nGraph results JSON: {graphJson}") }, Temperature = 0.2f, MaxTokens = 600 }; var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_CHAT_DEPLOYMENT") ?? throw new InvalidOperationException("AZURE_OPENAI_CHAT_DEPLOYMENT not configured."); var result = await _openAi.GetChatCompletionsAsync(deployment, chat, ct); var message = result.Value.Choices[0].Message.Content?.Trim() ?? "No response."; return new AgentResponse(message); } } public sealed record AgentRequest(string SiteId, string Query); public sealed record AgentResponse(string Message); // File: Function.cs // Purpose: HTTP endpoint secured by App Service Authentication. No function keys. Validates input and returns agent response. using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; public sealed class AgentFunction(IAgentService agent, ILogger logger) { private readonly IAgentService _agent = agent; private readonly ILogger _logger = logger; [Function("agent-run")] public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/run")] HttpRequestData req, FunctionContext ctx) { // Authentication is enforced by App Service Authentication (Easy Auth) at the platform layer. // Validate payload strictly. try { var request = await JsonSerializer.DeserializeAsync(req.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); if (request is null || string.IsNullOrWhiteSpace(request.Query) || string.IsNullOrWhiteSpace(request.SiteId)) { var bad = req.CreateResponse(HttpStatusCode.BadRequest); await bad.WriteStringAsync("Invalid payload: 'siteId' and 'query' are required."); return bad; } var result = await _agent.HandleAsync(request, ctx.CancellationToken); var ok = req.CreateResponse(HttpStatusCode.OK); await ok.WriteStringAsync(JsonSerializer.Serialize(result)); return ok; } catch (Exception ex) { _logger.LogError(ex, "Agent invocation failed"); var res = req.CreateResponse(HttpStatusCode.InternalServerError); await res.WriteStringAsync("Agent failed. Check logs."); return res; } } }

4) React (TypeScript, strict) with MSAL and Zod

// File: src/agentSchema.ts // Purpose: Runtime validation for request/response payloads. import { z } from "zod"; export const AgentRequestSchema = z.object({ siteId: z.string().min(1), query: z.string().min(1) }); export type AgentRequest = z.infer; export const AgentResponseSchema = z.object({ message: z.string() }); export type AgentResponse = z.infer; // File: src/msal.ts // Purpose: Initialize MSAL for SPA login; acquire tokens for the Function App audience. import { PublicClientApplication, type Configuration } from "@azure/msal-browser"; const config: Configuration = { auth: { clientId: import.meta.env.VITE_AAD_CLIENT_ID, // SPA app registration authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AAD_TENANT_ID}`, redirectUri: import.meta.env.VITE_REDIRECT_URI }, cache: { cacheLocation: "sessionStorage" } }; export const msal = new PublicClientApplication(config); // File: src/api.ts // Purpose: Call the Function endpoint with a bearer token; no function keys in URL. import { msal } from "./msal"; import { AgentRequestSchema, AgentResponseSchema, type AgentRequest, type AgentResponse } from "./agentSchema"; const FUNCTION_SCOPE = import.meta.env.VITE_FUNCTION_AUDIENCE; // e.g., api://YOUR-FUNCTION-APP-APPID/.default const FUNCTION_BASE = import.meta.env.VITE_FUNCTION_BASE; // e.g., https://your-func.azurewebsites.net async function acquireToken(): Promise { const accounts = msal.getAllAccounts(); const account = accounts[0] ?? (await msal.loginPopup({ scopes: [FUNCTION_SCOPE] })).account; const result = await msal.acquireTokenSilent({ account, scopes: [FUNCTION_SCOPE] }); return result.accessToken; } export async function runAgent(input: AgentRequest): Promise { const parsed = AgentRequestSchema.parse(input); // Validate before sending const token = await acquireToken(); // MSAL gets an access token for the Function audience const res = await fetch(`${FUNCTION_BASE}/api/agent/run`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(parsed) }); if (!res.ok) { throw new Error(`Agent failed: ${res.status}`); } const json = await res.json(); return AgentResponseSchema.parse(json); // Validate response shape } // File: src/App.tsx // Purpose: Simple UI to query the agent against SharePoint content. import { useState } from "react"; import { runAgent } from "./api"; export default function App() { const [siteId, setSiteId] = useState(""); const [query, setQuery] = useState(""); const [result, setResult] = useState(""); const [busy, setBusy] = useState(false); async function onSubmit(e: React.FormEvent) { e.preventDefault(); setBusy(true); try { const res = await runAgent({ siteId, query }); setResult(res.message); } catch (err) { setResult((err as Error).message); } finally { setBusy(false); } } return (

SharePoint AI Agent

setSiteId(e.target.value)} />
setQuery(e.target.value)} />
{result}
); }

5) Configuration Notes

- App Settings for Function: - AZURE_OPENAI_ENDPOINT = https://your-openai-resource.openai.azure.com - AZURE_OPENAI_CHAT_DEPLOYMENT = your-deployment-name - Optional KEY_VAULT_URI = https://your-kv.vault.azure.net - CORS: - For local: local.settings.json Host:CORS = https://localhost:5173 - In Azure: enable CORS on the Function App for your SPA origins. - Authentication (Function App): - Enable App Service Authentication (Login with Microsoft) and set Allowed Token Audiences to your Function App Application ID URI. - Do not use function keys. Rely on Azure AD tokens.

Best Practices & Security

- Recommendation: Use DefaultAzureCredential everywhere. It enables Managed Identity in Azure and dev auth locally. - Recommendation: Grant least privilege on Graph (e.g., Sites.Read.All). Avoid tenant-wide write unless necessary. - Recommendation: Store only non-secret configuration in Key Vault when needed; prefer Managed Identity over storing API keys. - Recommendation: Validate all inputs/outputs with Zod (SPA) and model binding or explicit checks (Function) to reduce runtime errors. - Recommendation: Configure CORS explicitly per environment. Avoid wildcard origins in production. - Recommendation: Monitor with Application Insights. Track dependency calls (Graph, OpenAI) and add custom dimensions for correlation IDs. - Recommendation: Implement retry with exponential backoff for Graph and OpenAI calls. Consider Polly for transient faults. - Recommendation: If hosting the SPA on Azure Static Web Apps, use its built-in authentication and route protected calls to the Function with Easy Auth.

Summary

- Securely integrate AI Agents in SharePoint by fronting Graph and Azure OpenAI with an Azure Function using Managed Identity—no keys, no secrets in code. - Enforce strict schemas with Zod in React and input validation in .NET to reduce errors and improve reliability. - Harden production with proper RBAC, CORS, Application Insights, and transient fault handling.

Sunday, 22 February 2026

Build a secure MCP-style server for Power Apps with .NET 8 Azure Functions and Azure AD

Creating an MCP server in PowerApps typically means exposing a secure, typed HTTP API that Power Apps can call via a Custom Connector. This article shows why Azure Functions (.NET 8 isolated) with Azure AD (Entra ID) is the right foundation and how to ship a production-ready MCP-style server with OpenAPI, validation, and zero secret management.

The Problem

You need a reliable backend for Power Apps that enforces validation and security, offers a clean contract (OpenAPI), scales serverlessly, and avoids Function Keys or shared secrets. You also want workflows and data operations to be testable and observable.

Prerequisites

  • .NET 8 SDK
  • Azure CLI 2.58+ and Azure Functions Core Tools v4+
  • Azure subscription with permissions to create Resource Group, Function App, and Managed Identity
  • Power Apps environment with permission to create Custom Connectors

Implementation Details

Project setup

// Terminal commands (run locally)
// 1) Create a .NET 8 isolated Azure Functions app
dotnet new func --worker-runtime dotnet-isolated --name PowerAppsMcpServer

cd PowerAppsMcpServer

// 2) Add packages for validation, OpenAPI, and DI helpers
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.OpenApi
dotnet add package Azure.Identity
dotnet add package Microsoft.Extensions.Azure
dotnet add package Microsoft.Extensions.Logging.ApplicationInsights

Program.cs (minimal hosting, DI, OpenAPI)

using Azure.Core;
using Azure.Identity;
using FluentValidation;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.OpenApi.Extensions;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

// Top-level statements for .NET 8 minimal hosting
var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults(builder =>
    {
        // Enables OpenAPI endpoints at /api/swagger.json and /api/swagger/ui
        builder.AddApplicationInsights();
        builder.AddOpenApi();
    })
    .ConfigureServices(services =>
    {
        // Register Application Insights logger
        services.AddLogging();

        // Register validators
        services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();

        // Register Azure SDK clients using DefaultAzureCredential (Managed Identity in Azure)
        services.AddAzureClients(clientBuilder =>
        {
            clientBuilder.UseCredential(new DefaultAzureCredential());
            // Example: clientBuilder.AddSecretClient(new Uri("https://<your-kv>.vault.azure.net/"));
        });

        // Register domain services
        services.AddSingleton<IOrderService, OrderService>();
    })
    .Build();

host.Run();

Contracts and validation

namespace PowerAppsMcpServer;

public sealed record CreateOrderRequest(
    string CustomerId,      // Must be a known customer
    string Sku,             // Product SKU
    int Quantity            // >= 1
);

public sealed record CreateOrderResponse(
    string OrderId,
    string Status,
    string Message
);
using FluentValidation;

namespace PowerAppsMcpServer;

public sealed class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        // Ensure CustomerId is present and well-formed
        RuleFor(x => x.CustomerId)
            .NotEmpty().WithMessage("CustomerId is required.")
            .Length(3, 64);

        // Basic SKU constraints
        RuleFor(x => x.Sku)
            .NotEmpty().WithMessage("Sku is required.")
            .Length(2, 64);

        // Quantity must be positive
        RuleFor(x => x.Quantity)
            .GreaterThan(0).WithMessage("Quantity must be at least 1.");
    }
}

Domain service (DI, testable logic)

using System;
using System.Threading;
using System.Threading.Tasks;

namespace PowerAppsMcpServer;

public interface IOrderService
{
    Task<CreateOrderResponse> CreateAsync(CreateOrderRequest request, CancellationToken ct = default);
}

public sealed class OrderService() : IOrderService // Primary constructor (no fields needed)
{
    public Task<CreateOrderResponse> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
    {
        // Simulate business logic; replace with real persistence/integration
        var orderId = $"ORD-{Guid.NewGuid():N}";
        return Task.FromResult(new CreateOrderResponse(orderId, "Created", "Order accepted"));
    }
}

HTTP-triggered Function with Azure AD auth and OpenAPI

using System.Net;
using System.Text.Json;
using FluentValidation;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Azure.Functions.Worker.Extensions.OpenApi.Attributes;
using Microsoft.OpenApi.Models;

namespace PowerAppsMcpServer;

public sealed class CreateOrderFunction(
    ILogger<CreateOrderFunction> logger,
    IValidator<CreateOrderRequest> validator,
    IOrderService orderService)
{
    // This endpoint is described for OpenAPI and secured via Azure AD (set on the Function App)
    [Function("CreateOrder")]
    [OpenApiOperation(operationId: "CreateOrder", tags: new[] { "orders" }, Summary = "Create order", Description = "Creates an order with validated input.")]
    [OpenApiRequestBody(contentType: "application/json", bodyType: typeof(CreateOrderRequest), Required = true, Description = "Order payload")]
    [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: "application/json", bodyType: typeof(CreateOrderResponse), Summary = "Order created")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "orders")] HttpRequestData req,
        FunctionContext ctx)
    {
        // Note: Use EasyAuth/Azure AD at the Function App level to avoid handling tokens here.
        // The function still requires a valid AAD token via Custom Connector.

        var payload = await JsonSerializer.DeserializeAsync<CreateOrderRequest>(req.Body, new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        });

        // Validate request
        var result = await validator.ValidateAsync(payload!);
        if (!result.IsValid)
        {
            var bad = req.CreateResponse(HttpStatusCode.BadRequest);
            await bad.WriteStringAsync(JsonSerializer.Serialize(new
            {
                error = "ValidationFailed",
                details = result.Errors.Select(e => new { e.PropertyName, e.ErrorMessage })
            }));
            return bad;
        }

        // Execute domain logic
        var responseModel = await orderService.CreateAsync(payload!, ctx.CancellationToken);

        // Return success
        var ok = req.CreateResponse(HttpStatusCode.OK);
        await ok.WriteStringAsync(JsonSerializer.Serialize(responseModel));
        return ok;
    }
}

Note: Set the Function App to be secured by Azure AD (App Service Authentication/Authorization). Do not rely on Function Keys in production.

OpenAPI for the Custom Connector

  • Run locally and navigate to /api/swagger/ui to inspect the contract. The OpenAPI JSON is available at /api/swagger.json.
  • Export this JSON and import it when creating your Power Apps Custom Connector, selecting OAuth 2.0 (Azure AD) as the authentication type.

Power Apps integration (Custom Connector)

Authentication setup

  • Create an Azure AD App Registration for the Custom Connector (client app) and expose an application ID URI for the Function App (resource app) if you choose user-assigned scopes. Alternatively, enable EasyAuth with “Log in with Azure Active Directory”.
  • In the Custom Connector, choose OAuth 2.0 (Azure AD). Provide the Authorization URL, Token URL, and the client application details. Use the Application ID URI or scope configured for the Function App.
  • Grant users access via Azure AD and Power Platform permissions so they can acquire tokens and use the connector.

Calling the connector from Power Apps

// Power Apps (Canvas) example formula usage (pseudo):
// Assuming Custom Connector named 'OrdersApi'
Set(
    createResult,
    OrdersApi.CreateOrder({
        CustomerId: "CUST-001",
        Sku: "WIDGET-42",
        Quantity: 2
    })
);
// Access response fields: createResult.OrderId, createResult.Status, createResult.Message

Deployment

// Azure deployment with CLI (example)
// 1) Login
az login

// 2) Create resource group
az group create -n rg-mcp-powerapps -l eastus

// 3) Create storage and function app (Linux, isolated, .NET 8)
az storage account create -n mcpfuncstor$RANDOM -g rg-mcp-powerapps -l eastus --sku Standard_LRS
az functionapp create -n mcp-func-app-$RANDOM -g rg-mcp-powerapps -s <storageName> --consumption-plan-location eastus --runtime dotnet-isolated --functions-version 4

// 4) Enable App Service Authentication with Azure AD (EasyAuth)
# Replace <clientId>, <issuerUrl> as per your AAD setup
az webapp auth microsoft update \
  --resource-group rg-mcp-powerapps \
  --name mcp-func-app-XXXX \
  --client-id <clientId> \
  --issuer "https://login.microsoftonline.com/<tenantId>/v2.0" \
  --unauthenticated-client-action RedirectToLoginPage

// 5) Deploy code
func azure functionapp publish mcp-func-app-XXXX

RBAC roles to assign

  • Deployment automation: Contributor on the resource group or scoped roles to Function App and Storage Account.
  • Function App management: Azure Functions Contributor (for CI/CD and app updates).
  • Storage access (if using Managed Identity to access blobs/queues): Storage Blob Data Reader or Storage Blob Data Contributor as needed, following least privilege.
  • Application Insights access (read-only dashboards): Monitoring Reader.

Validation and typing on the client

If you invoke this API from a TypeScript app (e.g., React), validate payloads before sending. Below is a strictly typed example using Zod.

// TypeScript (strict) example with Zod
import { z } from "zod";

const CreateOrderRequest = z.object({
  CustomerId: z.string().min(3).max(64),
  Sku: z.string().min(2).max(64),
  Quantity: z.number().int().positive()
});
type CreateOrderRequest = z.infer<typeof CreateOrderRequest>;

async function createOrder(apiBase: string, token: string, body: CreateOrderRequest) {
  const payload = CreateOrderRequest.parse(body); // Validates at runtime
  const res = await fetch(`${apiBase}/api/orders`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${token}`
    },
    body: JSON.stringify(payload)
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return await res.json() as { OrderId: string; Status: string; Message: string };
}

Best Practices & Security

  • Authentication: Use Azure AD (EasyAuth) with the Custom Connector. Avoid Function Keys to eliminate shared secrets.
  • Authorization: Scope access by Entra app roles or scopes; assign least-privilege RBAC to managed identities.
  • Secrets: Prefer Managed Identity and DefaultAzureCredential to access downstream services. Avoid storing credentials in app settings.
  • Validation: Enforce input validation on the server (FluentValidation) and on the client (Zod) for robust defense-in-depth.
  • Observability: Enable Application Insights. Correlate requests by logging operation IDs and include key business fields.
  • Resiliency: Add retry policies on the client, idempotency keys for order creation, and request timeouts to prevent retries creating duplicates.
  • Versioning: Version endpoints (e.g., /api/v1/orders) and keep your Custom Connector mapped to a specific version.
  • Cost: Consumption plan scales to zero; set sampling in Application Insights to control telemetry cost.

Best Practice: Use AsNoTracking() in Entity Framework when performing read-only queries to improve performance.

Monitoring and alerting

With Application Insights, create alerts on failed requests or latency thresholds.

// Examples (Kusto)
// 1) High failure rate in the last 15 minutes
requests
| where timestamp > ago(15m)
| summarize FailureRate = 100.0 * countif(success == false) / count()

// 2) P95 latency by operation
requests
| where timestamp > ago(1h)
| summarize p95_duration = percentile(duration, 95) by operation_Name

Summary

  • Build your MCP-style server for Power Apps with .NET 8 isolated Azure Functions, DI, and FluentValidation.
  • Secure the API with Azure AD and avoid Function Keys; integrate via a Custom Connector using OAuth 2.0.
  • Ship with OpenAPI, monitoring, and clear RBAC to ensure production readiness.

Surface What’s New: Power Apps integration with a secure .NET 8 API for MCP server updates

The question what is new in powerapps for MCP server lacks a precise product definition, so rather than speculating on features, this guide shows how to reliably surface and version "What’s New" updates into Power Apps using a secure .NET 8 backend, strict TypeScript validation, and Azure-native security. You will get a production-ready pattern that Power Apps can call via a Custom Connector, keeping your release notes current without manual edits.

The Problem

Teams need a trustworthy way to display "What’s New" for MCP server in Power Apps, but the upstream source and format of updates can change. Hardcoding content or querying unsecured endpoints leads to drift, security gaps, and poor developer experience.

Prerequisites

  • .NET 8 SDK
  • Node.js 20+ and a package manager (pnpm/npm)
  • Azure subscription with permissions to create: Azure Functions or Container Apps, Key Vault, Azure API Management, Application Insights
  • Entra ID app registration for the API (OAuth 2.0)
  • Power Apps environment (to build a Custom Connector)

The Solution (Step-by-Step)

1) .NET 8 Minimal API that normalizes "What’s New" items

This minimal API demonstrates production-ready design: DI-first, HttpClientFactory, global exception handling, validation, versioned contract, and managed identity for downstream access.

// Program.cs (.NET 8, file-scoped namespace, minimal API, DI-centric)
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.Identity; // DefaultAzureCredential for managed identity
using Azure.Security.KeyVault.Secrets;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Azure;

var builder = WebApplication.CreateBuilder(args);

// Strongly typed options for upstream source configuration
builder.Services.Configure<NewsOptions>(builder.Configuration.GetSection("News"));

// HttpClient with resilient handler
builder.Services.AddHttpClient<INewsClient, NewsClient>()
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));

// Azure clients via DefaultAzureCredential (uses Managed Identity in Azure)
builder.Services.AddAzureClients(azure =>
{
    azure.UseCredential(new DefaultAzureCredential());
    var kvUri = builder.Configuration["KeyVaultUri"]; // e.g., https://my-kv.vault.azure.net/
    if (!string.IsNullOrWhiteSpace(kvUri))
    {
        azure.AddSecretClient(new Uri(kvUri));
    }
});

// Application Insights (OpenTelemetry auto-collection can also be used)
builder.Services.AddApplicationInsightsTelemetry();

var app = builder.Build();

// Global exception handler producing problem+json
app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var feature = context.Features.Get<IExceptionHandlerPathFeature>();
        var problem = new ProblemDetails
        {
            Title = "Unexpected error",
            Detail = app.Environment.IsDevelopment() ? feature?.Error.ToString() : "",
            Status = StatusCodes.Status500InternalServerError,
            Type = "https://httpstatuses.com/500"
        };
        context.Response.StatusCode = problem.Status ?? 500;
        context.Response.ContentType = "application/problem+json";
        await context.Response.WriteAsJsonAsync(problem);
    });
});

app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

// Versioned route for the normalized news feed (v1)
app.MapGet("/api/v1/news", async (
    INewsClient client
) => Results.Ok(await client.GetNewsAsync()))
.WithName("GetNewsV1")
.Produces<NewsItemV1[]>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status500InternalServerError);

app.Run();

// Options to control the upstream feed location and parsing mode
public sealed class NewsOptions
{
    public string? SourceUrl { get; init; } // Upstream JSON or RSS converted via a worker
    public string Format { get; init; } = "json"; // json|rss (extend as needed)
}

// Public DTO contract exposed to Power Apps via Custom Connector
public sealed class NewsItemV1
{
    public required string Id { get; init; } // stable identifier
    public required string Title { get; init; }
    public required string Summary { get; init; }
    public required DateTimeOffset PublishedAt { get; init; }
    public string? Category { get; init; } // optional taxonomy
    public string? Url { get; init; } // link to detail page
}

// Client interface for fetching and normalizing upstream data
public interface INewsClient
{
    Task<NewsItemV1[]> GetNewsAsync(CancellationToken ct = default);
}

// Implementation that reads upstream source, validates, and normalizes
public sealed class NewsClient(HttpClient http, Microsoft.Extensions.Options.IOptions<NewsOptions> options) : INewsClient
{
    private readonly HttpClient _http = http;
    private readonly NewsOptions _opts = options.Value;
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    public async Task<NewsItemV1[]> GetNewsAsync(CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(_opts.SourceUrl))
            throw new InvalidOperationException("News:SourceUrl is not configured.");

        // Fetch upstream JSON and map to a stable contract consumed by Power Apps
        var upstreamItems = await _http.GetFromJsonAsync<UpstreamItem[]>(_opts.SourceUrl, JsonOptions, ct)
            ?? Array.Empty<UpstreamItem>();

        // Normalize and order by publish date desc
        return upstreamItems
            .Select(u => new NewsItemV1
            {
                Id = u.Id ?? Guid.NewGuid().ToString("n"),
                Title = u.Title ?? "Untitled",
                Summary = u.Summary ?? string.Empty,
                PublishedAt = u.PublishedAt == default ? DateTimeOffset.UtcNow : u.PublishedAt,
                Category = u.Category,
                Url = u.Url
            })
            .OrderByDescending(n => n.PublishedAt)
            .ToArray();
    }

    // Internal model matching the upstream source (keep flexible)
    private sealed class UpstreamItem
    {
        public string? Id { get; init; }
        public string? Title { get; init; }
        public string? Summary { get; init; }
        public DateTimeOffset PublishedAt { get; init; }
        public string? Category { get; init; }
        public string? Url { get; init; }
    }
}

Configuration (appsettings.json):

{
  "News": {
    "SourceUrl": "https://<your-source>/mcp-news.json",
    "Format": "json"
  },
  "KeyVaultUri": "https://<your-kv>.vault.azure.net/"
}

Pro-Tip: Use AsNoTracking() in Entity Framework when performing read-only queries to improve performance.

2) Secure Azure deployment with Managed Identity and API Management

  • Deploy as Azure Functions (isolated) or Azure Container Apps. Enable System-Assigned Managed Identity.
  • Expose through Azure API Management with OAuth 2.0 (Entra ID) for inbound auth. Create a Power Apps Custom Connector pointing to APIM.

Required RBAC roles (assign to the managed identity or DevOps service principal):

  • Key Vault Secrets User (to read secrets if you store upstream source credentials or API keys)
  • App Configuration Data Reader (if using Azure App Configuration instead of appsettings)
  • API Management Service Contributor (to publish and manage the API surface)
  • Monitoring Reader (to view Application Insights telemetry)
  • Storage Blob Data Reader (only if the upstream source is in Azure Storage)

Pro-Tip: Favor Managed Identity and DefaultAzureCredential across services; avoid connection strings and embedded secrets entirely.

3) Strict TypeScript models with Zod and versioning

The client schema mirrors the v1 API and can evolve with v2+ while keeping backward compatibility in Power Apps and React.

// news.schema.ts (TypeScript, strict mode)
import { z } from "zod";

// Discriminated union enables future breaking changes with clear versioning
export const NewsItemV1 = z.object({
  id: z.string().min(1),
  title: z.string().min(1),
  summary: z.string().default(""),
  publishedAt: z.string().datetime(),
  category: z.string().optional(),
  url: z.string().url().optional()
});

export const NewsResponseV1 = z.array(NewsItemV1);

export type TNewsItemV1 = z.infer<typeof NewsItemV1>;
export type TNewsResponseV1 = z.infer<typeof NewsResponseV1>;

// Future-proof: union for versioned responses
export const NewsResponse = z.union([
  z.object({ version: z.literal("v1"), data: NewsResponseV1 })
]);
export type TNewsResponse = z.infer<typeof NewsResponse>;

4) React 19 component using TanStack Query

Functional component with error and loading states, plus Zod runtime validation.

// NewsPanel.tsx (React 19)
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { NewsResponseV1, type TNewsItemV1 } from "./news.schema";

async function fetchNews(): Promise<TNewsItemV1[]> {
  const res = await fetch("/api/v1/news", { headers: { Accept: "application/json" } });
  if (!res.ok) throw new Error(`Failed: ${res.status}`);
  const json = await res.json();
  const parsed = NewsResponseV1.safeParse(json);
  if (!parsed.success) throw new Error("Schema validation failed");
  return parsed.data;
}

export function NewsPanel(): JSX.Element {
  const { data, error, isLoading } = useQuery({
    queryKey: ["news", "v1"],
    queryFn: fetchNews,
    staleTime: 60_000
  });

  if (isLoading) return <div>Loading updates…</div>;
  if (error) return <div>Failed to load updates.</div>;

  return (
    <ul>
      {data!.map(item => (
        <li key={item.id}>
          <strong>{item.title}</strong> — {new Date(item.publishedAt).toLocaleDateString()}<br />
          {item.summary}
        </li>
      ))}
    </ul>
  );
}

5) Power Apps Custom Connector

Create a Custom Connector targeting the APIM endpoint /api/v1/news. Map the response to your app data schema. Add the connector to your Power App and display the items in a gallery. When the upstream feed changes, you only update the backend normalizer, not the app.

Best Practices & Security

  • Authentication: Use Entra ID for APIM inbound auth. Backend-to-Azure uses DefaultAzureCredential with Managed Identity.
  • Secrets: Store upstream tokens in Key Vault; assign Key Vault Secrets User to the app’s managed identity.
  • Telemetry: Enable Application Insights. Track request IDs and dependency calls for the upstream fetch.
  • Authorization: Restrict APIM access via OAuth scopes and, if needed, IP restrictions or rate limits.
  • Resilience: Configure retry and timeout on HttpClient with sensible limits; add circuit breakers if the upstream is unreliable.
  • Versioning: Pin /api/v1/news; introduce /api/v2/news when the contract changes. Version TypeScript schemas alongside API versions.

App Insights integration (example):

// Add to Program.cs before app.Run(); ensure Application Insights is enabled
app.Use(async (ctx, next) =>
{
    // Correlate requests with upstream calls via trace IDs
    ctx.Response.Headers["Request-Id"] = System.Diagnostics.Activity.Current?.Id ?? Guid.NewGuid().ToString();
    await next();
});

Testing strategy:

  • API: Unit test NewsClient with mocked HttpMessageHandler; integration test /api/v1/news in-memory.
  • TypeScript: Schema tests to ensure validation rejects malformed payloads; component tests for loading/error states.
  • Contract: Add a CI step that fetches a sample payload from the upstream and validates against NewsResponseV1.

Security roles recap:

  • API surface: API Management Service Contributor
  • Secrets: Key Vault Secrets User
  • Config (if used): App Configuration Data Reader
  • Monitoring: Monitoring Reader
  • Storage (if used): Storage Blob Data Reader

Pro-Tip: Use APIM policies (validate-content, rate-limit-by-key) to protect the API consumed by Power Apps.

Summary

  • Do not guess "what’s new" for MCP server; centralize updates behind a stable, versioned API.
  • Use Managed Identity, DefaultAzureCredential, and APIM OAuth to secure end-to-end access for Power Apps.
  • Validate with Zod, monitor with Application Insights, and evolve safely via API/schema versioning.

Saturday, 7 February 2026

Mastering Dynamics CRM Plugin Triggers: Pre-Validation, Pre-Operation, Post-Operation, and Async with Azure-Ready Patterns

Dynamics CRM plugin triggers define when your custom logic runs in the Dataverse pipeline. If you understand how Dynamics CRM plugin triggers behave across Pre-Validation, Pre-Operation, Post-Operation, and Asynchronous execution, you can write reliable, idempotent, and production-ready business logic that scales with Azure.

The Problem

Developers struggle to pick the correct stage and execution mode for Dynamics 365/Dataverse plugins, causing issues like recursion, lost transactions, or performance bottlenecks. You need clear rules, copy-paste-safe examples, and guidance on automation, security, and Azure integration without manual portal steps.

Prerequisites

• .NET 8 SDK installed (for companion services and automation)
• Power Platform Tools (PAC CLI) installed
• Azure CLI (az) installed, logged in with least-privilege account
• Access to a Dataverse environment and solution where you can register plugins
• Basic familiarity with IPlugin, IPluginExecutionContext, and IServiceProvider

The Solution (Step-by-Step)

1) Know the stages and when to use each

• Pre-Validation (Stage 10, synchronous): Validate input early, block bad requests before the main transaction. Good for authorization and schema checks.
• Pre-Operation (Stage 20, synchronous): Mutate Target before it’s saved. Good for defaulting fields, data normalization, or cross-field validation.
• Post-Operation (Stage 40, synchronous): Runs after the record is saved, still in the transaction. Good for operations that must be atomic with the main operation (e.g., child record creation that must roll back with parent).
• Post-Operation (Asynchronous): Offload non-transactional, latency-tolerant work (notifications, integrations). Improves throughput and user experience.

2) Messages and images

• Common messages: Create, Update, Delete, Assign, SetState, Associate/Disassociate, Merge, Retrieve/RetrieveMultiple (use sparingly to avoid performance impact).
• Filtering attributes (Update): Only trigger when specific columns change to reduce overhead.
• Images: Use Pre-Image for old values, Post-Image for new values. Keep images minimal to reduce payload and improve performance.

3) Synchronous Pre-Operation example (mutate data safely)

Target framework note: Dataverse runtime support for .NET versions can vary. The C# syntax below follows modern patterns while remaining compatible with the Dataverse plugin model. Always target the supported framework for your environment at build time.

using System;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Extensions; // For helpful extension methods
using Microsoft.Extensions.DependencyInjection; // For DI patterns inside plugin
using System.Globalization;

// File-scoped namespace for clean organization
namespace Company.Plugins;

// Primary-constructor-like pattern for clarity; the Dataverse runtime will call the parameterless constructor.
public sealed class AccountNormalizeNamePlugin : IPlugin
{
    // Build a tiny DI container once per plugin instance to follow DI principles instead of static helpers.
    private readonly IServiceProvider _rootServices;

    public AccountNormalizeNamePlugin()
    {
        var services = new ServiceCollection();
        services.AddSingleton<INameNormalizer, TitleCaseNameNormalizer>();
        _rootServices = services.BuildServiceProvider();
    }

    public void Execute(IServiceProvider serviceProvider)
    {
        // Standard service access from the pipeline
        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
        var factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var service = factory.CreateOrganizationService(context.UserId);
        var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));

        // Guard: Ensure we only run on Update of account and when 'name' changes
        if (!string.Equals(context.PrimaryEntityName, "account", StringComparison.OrdinalIgnoreCase) ||
            !string.Equals(context.MessageName, "Update", StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        // Prevent recursion: depth should be 1 for first-level execution
        if (context.Depth > 1) return;

        var target = context.InputParameters.Contains("Target") ? context.InputParameters["Target"] as Entity : null;
        if (target == null) return;

        // Run only when 'name' was provided in this Update
        if (!target.Contains("name")) return;

        // Resolve our normalizer from DI
        var normalizer = _rootServices.GetRequiredService<INameNormalizer>();

        // Normalize 'name' to Title Case
        var originalName = target.GetAttributeValue<string>("name");
        var normalized = normalizer.Normalize(originalName);
        target["name"] = normalized;

        tracing.Trace($"AccountNormalizeNamePlugin: normalized '{originalName}' to '{normalized}'.");
    }
}

// Service abstraction for testability and SRP
public interface INameNormalizer
{
    string Normalize(string? input);
}

public sealed class TitleCaseNameNormalizer : INameNormalizer
{
    public string Normalize(string? input)
    {
        if (string.IsNullOrWhiteSpace(input)) return input ?? string.Empty;
        var textInfo = CultureInfo.InvariantCulture.TextInfo;
        return textInfo.ToTitleCase(input.Trim().ToLowerInvariant());
    }
}

Registration guidelines: Register this on account Update, Stage Pre-Operation (20), Synchronous, with filtering attributes = name. Add a minimal Pre-Image if you need original values.

4) Synchronous Post-Operation example (atomic child creation)

using System;
using Microsoft.Xrm.Sdk;

namespace Company.Plugins;

public sealed class ContactCreateWelcomeTaskPlugin : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
        var factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var service = factory.CreateOrganizationService(context.UserId);
        var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));

        // Only run on Contact Create, after it is created (Post-Operation)
        if (!string.Equals(context.PrimaryEntityName, "contact", StringComparison.OrdinalIgnoreCase) ||
            !string.Equals(context.MessageName, "Create", StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        if (context.Depth > 1) return;

        var contactId = context.PrimaryEntityId;
        if (contactId == Guid.Empty) return;

        // Create a follow-up task; if this plugin throws, both contact and task roll back
        var task = new Entity("task");
        task["subject"] = "Welcome new contact";
        task["regardingobjectid"] = new EntityReference("contact", contactId);
        task["prioritycode"] = new OptionSetValue(1); // High
        service.Create(task);

        tracing.Trace("ContactCreateWelcomeTaskPlugin: created welcome task.");
    }
}

5) Asynchronous Post-Operation example (offload integration)

Use Async Post-Operation for non-transactional work such as calling Azure services. Prefer a durable, retry-enabled mechanism (queue, function) over direct HTTP. The plugin should enqueue a message; an Azure Function (managed identity) processes it.

using System;
using Microsoft.Xrm.Sdk;

namespace Company.Plugins;

public sealed class ContactCreatedEnqueueIntegrationPlugin : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
        var factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var service = factory.CreateOrganizationService(context.UserId);
        var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));

        if (!string.Equals(context.PrimaryEntityName, "contact", StringComparison.OrdinalIgnoreCase) ||
            !string.Equals(context.MessageName, "Create", StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        // Idempotency key: use the contact id
        var contactId = context.PrimaryEntityId;
        if (contactId == Guid.Empty) return;

        // Example: write an integration record for downstream Azure Function (poll or Dataverse Change Tracking)
        // This avoids secrets and direct outbound calls from the plugin.
        var integrationLog = new Entity("new_integrationmessage"); // Custom table
        integrationLog["new_name"] = $"ContactCreated:{contactId}";
        integrationLog["new_payload"] = contactId.ToString();
        service.Create(integrationLog);

        tracing.Trace("ContactCreatedEnqueueIntegrationPlugin: queued integration message.");
    }
}

6) Automate registration with PAC CLI (no manual portal)

:: Batch/PowerShell snippet to build and register the assembly
:: 1) Build plugin project (target a runtime supported by your environment)
dotnet build .\src\Company.Plugins\Company.Plugins.csproj -c Release

:: 2) Pack into a solution if applicable
pac solution pack --zipFilePath .\dist\CompanySolution.zip --folder .\solution

:: 3) Import or update solution into the environment
pac auth create --url https://<yourorg>.crm.dynamics.com --cloud Public
pac solution import --path .\dist\CompanySolution.zip --activate-plugins true

This keeps registration repeatable in CI/CD without manual steps.

7) Azure companion Minimal API (for outbound webhooks or admin tools)

For external processing, build a Minimal API or Azure Function with managed identity and Azure RBAC. Example Minimal API (.NET 8) that reads from Storage using DefaultAzureCredential.

using Azure;
using Azure.Identity;
using Azure.Storage.Blobs;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Use DefaultAzureCredential to prefer Managed Identity in Azure and dev fallbacks locally
builder.Services.AddAzureClients(az =>
{
    az.UseCredential(new DefaultAzureCredential());
    az.AddBlobServiceClient(new Uri(builder.Configuration["BLOB_ENDPOINT"]!));
});

var app = builder.Build();

// Simple endpoint to fetch a blob; secure this behind Azure AD (AAD) in production
app.MapGet("/files/{name}", async (string name, BlobServiceClient blobs) =>
{
    // Access container 'docs' with RBAC: Storage Blob Data Reader/Contributor on the Managed Identity
    var container = blobs.GetBlobContainerClient("docs");
    var client = container.GetBlobClient(name);

    if (!await container.ExistsAsync()) return Results.NotFound("Container not found.");
    if (!await client.ExistsAsync()) return Results.NotFound("Blob not found.");

    var stream = await client.OpenReadAsync();
    return Results.Stream(stream, "application/octet-stream");
});

await app.RunAsync();

Required Azure RBAC role for the app's managed identity: Storage Blob Data Reader (read-only) or Storage Blob Data Contributor (read-write) on the storage account or specific container scope.

8) IaC with Bicep: storage + managed identity + role assignment

// main.bicep
targetScope = 'resourceGroup'

param location string = resourceGroup().location
param storageName string
param identityName string = 'dv-plugin-mi'

// Storage Account
resource stg 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}

// User Assigned Managed Identity
resource uami 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
  name: identityName
  location: location
}

// Blob Data Reader role on storage for the identity
resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(stg.id, uami.id, 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Reader
  scope: stg
  properties: {
    principalId: uami.properties.principalId
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')
    principalType: 'ServicePrincipal'
  }
}

Deploy with: az deployment group create -g <rg> -f main.bicep -p storageName=<name>.

Best Practices & Security

Pick the right trigger

• Pre-Validation: Reject invalid input early (authorization, schema, required business rules).
• Pre-Operation: Mutate data before save, avoid external calls here.
• Post-Operation (sync): Keep logic small and deterministic to minimize transaction time.
• Post-Operation (async): Offload long-running and I/O-heavy work.

Recursion, idempotency, and performance

• Check context.Depth to prevent infinite loops.
• Use idempotency keys (primary entity id) in integration logs.
• Keep images and columns minimal; filter attributes to reduce trigger noise.
• Use AsNoTracking() in external EF Core services when reading data.

Pro-Tip: Use AsNoTracking() in Entity Framework when performing read-only queries to improve performance.

Security and authentication

• Use Azure AD and Managed Identity for external services; never store secrets in plugin code.
• Apply least privilege with Azure RBAC. Examples: Storage Blob Data Reader/Contributor for the app workload identity; Key Vault Secrets User if retrieving secrets via a separate process.
• In Dataverse, ensure the application user has the minimal security roles necessary for the operations (table-level privileges only on the entities it touches).

Automation and IaC

• Use PAC CLI and CI/CD to register and update plugins, avoiding manual portal steps.
• Use Bicep or azd to provision Azure resources, assign RBAC, and configure endpoints.

Error handling and resiliency

• Synchronous plugins should throw InvalidPluginExecutionException only for business errors that must roll back the transaction.
• For external work, prefer async steps that enqueue messages and rely on Azure Functions with retry policies and dead-letter queues (e.g., Azure Storage Queues or Service Bus).
• Trace key events with ITracingService for diagnosability.

Testing strategy

• Abstract logic behind interfaces and inject into the plugin to enable unit testing without Dataverse.
• Use fakes for IOrganizationService and validate behavior under different stages and messages.
• Add integration tests in a sandbox environment using PAC CLI to seed and verify behavior.

References

• Azure RBAC built-in roles: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles
• DefaultAzureCredential: https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential
• Power Platform CLI: https://learn.microsoft.com/power-platform/developer/cli/introduction

Summary

• Choose the correct trigger: Pre-Validation for guards, Pre-Operation for mutation, Post-Operation for atomic side-effects, Async for integrations.
• Enforce security: Managed Identity for auth, Azure RBAC with least privilege, and no secrets in code.
• Automate everything: PAC CLI for plugin registration, Bicep for Azure resources, and add retries and dead-lettering for resilient async flows.

Monday, 26 January 2026

React 19: The Practical Difference Between Hooks and Components (With TypeScript and Azure Integration)

The difference between react hooks and components matters because it defines how you separate logic from presentation. Problem: teams mix stateful logic directly inside UI and struggle to test, reuse, and scale. Solution: put data fetching, validation, and side effects in reusable hooks; keep rendering in lean components. Value: cleaner architecture, easier testing, fewer bugs, and production-ready integration with Azure using least privilege.

The Problem

Developers often blur the line between where logic lives (hooks) and where UI renders (components). This leads to duplicated code, tangled effects, and UI tests that are slow and brittle. We need a clear pattern: hooks encapsulate logic and I/O; components focus on layout and accessibility.

Prerequisites

Node.js v20+, TypeScript 5+ with strict mode, React 19, TanStack Query v5+, Zod v3+, Azure Functions Core Tools v4+, .NET 8 SDK, Azure CLI (or azd), and a browser-compatible fetch API.

The Solution (Step-by-Step)

1) Define strict runtime and compile-time types

// src/schemas/user.ts
import { z } from "zod";

// Zod schema for runtime validation and safe parsing
export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1),
});

export const UsersSchema = z.array(UserSchema);

export type User = z.infer<typeof UserSchema>;

2) Create a focused hook: logic, data fetching, and validation

// src/hooks/useUsers.ts
import { useMemo } from "react";
import { useQuery, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { UsersSchema, type User } from "../schemas/user";

// Discriminated union for explicit UI states
export type UsersState =
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "success"; data: ReadonlyArray<User> };

// Fetch function with runtime validation and descriptive errors
async function fetchUsers(): Promise<ReadonlyArray<User>> {
  const res = await fetch("/api/users", { headers: { "accept": "application/json" } });
  if (!res.ok) {
    // Include status for observability; avoid leaking server internals
    throw new Error(`Request failed: ${res.status}`);
  }
  const json = await res.json();
  // Validate and coerce; throws if shape is wrong
  return UsersSchema.parse(json);
}

export function useUsers(): UsersState {
  const { data, error, status } = useQuery({
    queryKey: ["users"],
    queryFn: fetchUsers,
    staleTime: 60_000, // cache for 1 minute
    retry: 2,          // conservative retry policy
  });

  // Map TanStack Query status to a strict discriminated union for the UI
  return useMemo((): UsersState => {
    if (status === "pending") return { status: "loading" };
    if (status === "error") return { status: "error", error: (error as Error).message };
    // At this point data is defined and validated by Zod
    return { status: "success", data: data ?? [] };
  }, [status, error, data]);
}

// Optional: provide a QueryClient at the app root (copy-paste ready)
export const queryClient = new QueryClient();

// In your app root (e.g., src/main.tsx):
// import { createRoot } from "react-dom/client";
// import { QueryClientProvider } from "@tanstack/react-query";
// import { queryClient } from "./hooks/useUsers";
// import { App } from "./App";
// createRoot(document.getElementById("root")!).render(
//   <QueryClientProvider client={queryClient}>
//     <App />
//   </QueryClientProvider>
// );

3) Keep components presentational and accessible

// src/components/UsersList.tsx
import React from "react";
import { useUsers } from "../hooks/useUsers";

// Functional component focuses on rendering and accessibility
export function UsersList(): JSX.Element {
  const state = useUsers();

  if (state.status === "loading") {
    // Keep loading states lightweight and non-blocking
    return <p role="status" aria-live="polite">Loading users...</p>;
  }

  if (state.status === "error") {
    // Display a user-friendly message without revealing internals
    return <p role="alert">Could not load users. Please try again.</p>;
  }

  // Success path: minimal, semantic markup
  return (
    <ul aria-label="Users">
      {state.data.map(u => (
        <li key={u.id}>{u.name} ({u.email})</li>
      ))}
    </ul>
  );
}

4) Optional Azure back end: least-privilege, Managed Identity

This example shows an Azure Functions .NET 8 HTTP API that returns users. It authenticates to Azure Cosmos DB using DefaultAzureCredential and a system-assigned Managed Identity, avoiding connection strings. Assign only the necessary RBAC role.

// FunctionApp/Program.cs (.NET 8 isolated worker)
using Azure.Identity; // DefaultAzureCredential
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using Azure.Core;
using Azure.Cosmos; // Azure.Data.Cosmos alternative for .NET SDK

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services =>
    {
        // Use managed identity via DefaultAzureCredential
        services.AddSingleton<TokenCredential>(_ => new DefaultAzureCredential());

        services.AddSingleton<CosmosClient>(sp =>
        {
            var credential = sp.GetRequiredService<TokenCredential>();
            // Endpoint from configuration (no keys). Use App Settings.
            var endpoint = Environment.GetEnvironmentVariable("COSMOS_ENDPOINT")!;
            return new CosmosClient(endpoint, credential);
        });
    })
    .Build();

await host.RunAsync();

// FunctionApp/GetUsers.cs
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using System.Net;
using Azure.Cosmos;
using System.Text.Json;

namespace FunctionApp;

public class GetUsers(CosmosClient cosmos)
{
    // HTTP-triggered function returning JSON users
    [Function("GetUsers")] 
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "users")] HttpRequestData req)
    {
        var db = cosmos.GetDatabase("app");
        var container = db.GetContainer("users");

        var iterator = container.GetItemQueryIterator<UserDoc>("SELECT c.id, c.email, c.name FROM c");
        var results = new List<UserDoc>();
        while (iterator.HasMoreResults)
        {
            var page = await iterator.ReadNextAsync();
            results.AddRange(page);
        }

        var res = req.CreateResponse(HttpStatusCode.OK);
        await res.WriteStringAsync(JsonSerializer.Serialize(results));
        res.Headers.Add("Content-Type", "application/json");
        return res;
    }
}

public record UserDoc(string id, string email, string name);

Required RBAC (principle of least privilege): Assign the Function App's system-assigned identity the Cosmos DB Built-in Data Reader role scoped to the specific database or container. Avoid account-level permissions.

5) Minimal IaC for role assignment (Azure Bicep)

// main.bicep: create role assignment for Function App's managed identity
param cosmosAccountId string
param databaseRid string // scope appropriately (e.g., database or container resource ID)
param functionPrincipalId string // Function App's system-assigned identity principalId

resource roleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
  scope: subscription()
  name: '00000000-0000-0000-0000-000000000001' // placeholder, replace with Cosmos DB Built-in Data Reader GUID
}

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(functionPrincipalId, databaseRid, roleDefinition.name)
  scope: resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', cosmosAccountId, 'app')
  properties: {
    roleDefinitionId: roleDefinition.id
    principalId: functionPrincipalId
    principalType: 'ServicePrincipal'
  }
}

Note: Use azd to provision and configure environment variables like COSMOS_ENDPOINT. Never embed secrets or connection strings in code.

6) Wire up the React client to the Azure Function

// src/hooks/useUsers.ts (override the URL to your deployed Function App)
async function fetchUsers(): Promise<ReadonlyArray<User>> {
  const res = await fetch(import.meta.env.VITE_API_BASE + "/users", {
    headers: { accept: "application/json" },
    credentials: "include", // if using auth; otherwise omit
  });
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  const json = await res.json();
  return UsersSchema.parse(json);
}

7) Testing hooks and components separately

// tests/useUsers.test.tsx
import { describe, it, expect } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { useUsers } from "../src/hooks/useUsers";

function wrapper({ children }: { children: React.ReactNode }) {
  const client = new QueryClient();
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

describe("useUsers", () => {
  it("returns success after fetching", async () => {
    global.fetch = async () => new Response(JSON.stringify([]), { status: 200 });
    const { result } = renderHook(() => useUsers(), { wrapper });

    await waitFor(() => {
      expect(result.current.status).toBe("success");
    });
  });
});
// tests/UsersList.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { UsersList } from "../src/components/UsersList";

function renderWithQuery(ui: React.ReactElement) {
  const client = new QueryClient();
  return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}

describe("UsersList", () => {
  it("renders loading state", () => {
    renderWithQuery(<UsersList />);
    expect(screen.getByRole("status")).toHaveTextContent(/loading/i);
  });
});

Best Practices & Security

Hooks own side effects; components remain pure and predictable. Validate all external data with Zod and use strict TypeScript to catch issues at compile time. For Azure, prefer Managed Identity with DefaultAzureCredential and apply the smallest RBAC scope required. Keep API base URLs and configuration in environment variables managed by azd or your CI/CD system, not in source. For database reads with Entity Framework in .NET APIs, use AsNoTracking() to avoid unnecessary change tracking.

Summary

Hooks encapsulate reusable logic, I/O, and validation, while components render UI and stay testable. Strong typing with Zod and discriminated unions keeps state explicit and safe. Azure integration is secure with Managed Identity, least-privilege RBAC, and IaC via azd or Bicep.

Saturday, 24 January 2026

Avoiding the Pitfalls of Stopping Permission Inheritance: Performance Hotspots, Safer Patterns, and Azure-First Remediation

Stopping inherit permission (i.e., breaking permission inheritance) often seems like a quick fix for access control, but it introduces hidden operational and performance costs. This article explains why breaking inheritance in SharePoint and Azure RBAC leads to complexity, where performance issues occur, and how to remediate with .NET 8, TypeScript, and Azure-first patterns using Managed Identities and Infrastructure as Code.

The Problem

Breaking inheritance creates many one-off permission entries. Over time, this causes:

  • Permission sprawl: hard-to-audit, hard-to-revoke access scattered across items/resources.
  • Performance degradation: larger ACL evaluations, slower queries, and increased throttling risk.
  • Operational friction: brittle reviews, noisy exceptions, and confusing user experiences.

In SharePoint, many uniquely permissioned items slow list queries and complicate sharing. In Azure, assigning roles at leaf scopes (instead of using inherited assignments at management group or subscription levels) increases evaluation overhead and management burden.

Prerequisites

  • .NET 8 SDK
  • Node.js v20+
  • Azure CLI (az) and Azure Bicep
  • Contributor access to a test subscription (for deploying IaC) and Reader/Authorization permissions for audit scenarios

The Solution (Step-by-Step)

Step 1: What to avoid when stopping inheritance

  • SharePoint: Avoid per-item unique permissions for large lists; prefer groups at the site or library level, with exceptions gated by policy and approval.
  • Azure RBAC: Avoid many role assignments at the resource/resource-group level; grant least privilege at a higher scope when practical, and use groups instead of direct user assignments.

Step 2: .NET 8 Minimal API to detect RBAC hotspots (top-level statements, DI, Managed Identity)

// File: Program.cs (.NET 8, top-level statements, minimal API)
// Purpose: Audit Azure RBAC for non-inherited, leaf-level role assignments (hotspots).
// Auth: Uses DefaultAzureCredential (Managed Identity preferred in Azure).
// Note: This sample reads role assignments and surfaces "leaf" hotspots for review.

using Azure;
using Azure.Core;
using Azure.Identity;
using Azure.ResourceManager;
using Azure.ResourceManager.Authorization;
using Azure.ResourceManager.Authorization.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = WebApplication.CreateBuilder(args);

// Register Azure clients via DI
builder.Services.AddSingleton<TokenCredential>(_ => new DefaultAzureCredential());
// ArmClient is the entry point to Resource Manager APIs
builder.Services.AddSingleton<ArmClient>(sp => new ArmClient(sp.GetRequiredService<TokenCredential>()));

var app = builder.Build();

// GET /rbac/hotspots?subscriptionId=<subId>
// Heuristic: Identify role assignments at deeper scopes (resource, resource group) that
// could be consolidated at higher scopes to reduce sprawl and evaluation overhead.
app.MapGet("/rbac/hotspots", async (string subscriptionId, ArmClient arm) =>
{
    // Acquire subscription resource
    var sub = arm.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscriptionId}"));

    // List role assignments across the subscription
    var assignments = new List<RoleAssignmentData>();
    await foreach (var ra in sub.GetRoleAssignmentsAsync())
    {
        assignments.Add(ra.Data);
    }

    // Group by scope depth (subscription=1, resourceGroup=2, resource=3+)
    // Deeper scopes are more likely to be "breaks" from inheritance-like patterns.
    var hotspots = assignments
        .GroupBy(a => ScopeDepth(a.Scope))
        .OrderByDescending(g => g.Key)
        .Select(g => new
        {
            depth = g.Key,
            count = g.Count(),
            sampleScopes = g.Select(x => x.Scope).Distinct().Take(5).ToArray()
        });

    return Results.Ok(new
    {
        analyzed = assignments.Count,
        hotspots
    });

    // Simple helper to score scope depth by path segments.
    static int ScopeDepth(string scope)
    {
        // Example: /subscriptions/{id} => depth ~ 1
        // /subscriptions/{id}/resourceGroups/{rg} => depth ~ 2
        // /subscriptions/{id}/resourceGroups/{rg}/providers/... => depth >= 3
        return scope.Split('/', StringSplitOptions.RemoveEmptyEntries).Length / 2;
    }
})
.WithName("GetRbacHotspots")
.Produces<object>(200);

app.Run();

Why this helps: Large counts of deep-scope assignments often indicate broken inheritance patterns (per-resource grants). Consolidating to group-based roles at higher scopes can reduce policy evaluation work and administrative overhead.

Step 3: TypeScript Azure Function to list deep-scope role assignments with retry (Managed Identity)

// File: index.ts (Azure Functions v4, Node 20)
// Purpose: Enumerate role assignments and flag deep-scope patterns.
// Auth: ManagedIdentityCredential (no client secrets). Strict typing. Basic retry for 429.
//
// Ensure you enable a system-assigned identity on the Function App and grant it Reader on the subscription.
// package.json should include: @azure/identity, @azure/arm-authorization, zod

import { AzureFunction, Context } from "@azure/functions";
import { ManagedIdentityCredential } from "@azure/identity";
import { AuthorizationManagementClient, RoleAssignment } from "@azure/arm-authorization";
import { z } from "zod";

// Validate required environment variable via Zod (strict typing)
const EnvSchema = z.object({
  SUBSCRIPTION_ID: z.string().min(1)
});
const env = EnvSchema.parse(process.env);

const httpTrigger: AzureFunction = async function (context: Context): Promise<void> {
  // Create credential using Managed Identity (no secrets in code or env)
  const credential = new ManagedIdentityCredential();
  const authClient = new AuthorizationManagementClient(credential, env.SUBSCRIPTION_ID);

  // Simple retry wrapper for throttle-prone calls (e.g., large tenants)
  async function withRetry<T>(fn: () => Promise<T>, attempts = 5, delayMs = 1000): Promise<T> {
    let lastErr: unknown;
    for (let i = 0; i < attempts; i++) {
      try {
        return await fn();
      } catch (err: unknown) {
        const anyErr = err as { statusCode?: number };
        if (anyErr?.statusCode === 429 || anyErr?.statusCode === 503) {
          await new Promise((r) => setTimeout(r, delayMs * (i + 1))); // Exponential-ish backoff
          lastErr = err;
          continue;
        }
        throw err;
      }
    }
    throw lastErr;
  }

  // List role assignments at subscription scope
  const scope = `/subscriptions/${env.SUBSCRIPTION_ID}`;
  const assignments: RoleAssignment[] = [];

  // Use retry around list calls; the SDK returns an async iterator
  const pager = authClient.roleAssignments.listForSubscription();
  for await (const item of pager) {
    assignments.push(item);
  }

  // Score depth by scope path complexity
  const scoreDepth = (s: string): number => s.split("/").filter(Boolean).length / 2;
  const hotspots = assignments
    .map(a => ({ id: a.id!, scope: a.scope!, depth: scoreDepth(a.scope!) }))
    .filter(x => x.depth >= 3); // resource-level assignments

  context.res = {
    status: 200,
    headers: { "content-type": "application/json" },
    body: {
      analyzed: assignments.length,
      resourceLevelAssignments: hotspots.length,
      hotspotSamples: hotspots.slice(0, 10)
    }
  };
};

export default httpTrigger;

Why this helps: A quick serverless audit allows teams to discover where inheritance-like patterns are being bypassed in Azure RBAC, which is a frequent source of performance and governance friction.

Step 4: Azure Bicep to deploy a Function App with Managed Identity and least privilege

// File: main.bicep
// Purpose: Deploy a Function App with system-assigned managed identity,
// and assign Reader at the resource group scope (principle of least privilege).
// Includes a deterministic GUID for role assignment name.
//
// Note: Reader role definition ID is acdd72a7-3385-48ef-bd42-f606fba81ae7
// (Allows viewing resources, not making changes.)

@description('Location for resources')
param location string = resourceGroup().location

@description('Function App name')
param functionAppName string

@description('Storage account name (must be globally unique)')
param storageAccountName string

var roleDefinitionIdReader = '/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7'

// Storage (for Functions)
resource stg 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    allowBlobPublicAccess: false
    minimumTlsVersion: 'TLS1_2'
    supportsHttpsTrafficOnly: true
  }
}

// Hosting plan (Consumption)
resource plan 'Microsoft.Web/serverfarms@2022-09-01' = {
  name: '${functionAppName}-plan'
  location: location
  sku: {
    name: 'Y1'
    tier: 'Dynamic'
  }
}

// Function App with system-assigned identity
resource func 'Microsoft.Web/sites@2022-09-01' = {
  name: functionAppName
  location: location
  kind: 'functionapp'
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: plan.id
    siteConfig: {
      appSettings: [
        { name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node' }
        { name: 'WEBSITE_RUN_FROM_PACKAGE', value: '1' }
      ]
    }
  }
}

// Assign Reader at the resource group scope to the Function's identity
// Use a stable, deterministic GUID based on scope + principal to avoid duplicates.
resource readerAssign 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, func.identity.principalId, roleDefinitionIdReader)
  scope: resourceGroup()
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')
    principalId: func.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

Why this helps: You deploy secure defaults and enforce least privilege by design. The deterministic GUID prevents accidental duplicate role assignments. Reader is explicitly chosen to avoid write permissions while enabling inventory and audits.

Where performance issues occur

  • SharePoint: Many items with unique permissions increase ACL checks and can slow list queries, indexing, and certain sharing operations. Batch operations on items with unique permissions are more likely to hit throttling.
  • Azure RBAC: Thousands of per-resource role assignments increase evaluation work during authorization and complicate policy and compliance scans. It also prolongs investigations during incidents.
  • Auditing and reviews: Per-user, per-resource assignments inflate review surfaces and make access recertification slow and error-prone.

Best Practices & Security

  • Use Managed Identity or Azure AD Workload Identity. Avoid client secrets for server-side workloads. Do not store secrets in environment variables. If you must handle secrets, use Azure Key Vault with RBAC and Managed Identity.
  • Prefer group-based assignments over direct user assignments. This simplifies reviews and minimizes churn.
  • Favor higher-scope role assignments with least privilege. Start at management group or subscription only when justified and narrow to Reader or custom roles that fit the minimal required actions.
  • When exceptions are necessary, document them. Add expiration and owners to each exception.
  • For SharePoint, grant permissions at the site or library level using groups. Reserve item-level breaks for rare, time-bound cases.
  • Monitor continuously. Integrate with Azure Monitor and Azure Policy to detect excessive deep-scope assignments. Create alerts on abnormal growth of role assignments or access anomalies.
  • Implement retry and backoff for API calls that can throttle (429/503), both in audits and operational tooling.
  • Standardize terminology. Use "inheritance" consistently to avoid confusion in documentation and automation.

Recommendation: If you need read-only inventory across a subscription, assign the Reader role (roleDefinitionId acdd72a7-3385-48ef-bd42-f606fba81ae7) to a Managed Identity and call Azure SDKs or ARM REST with exponential backoff.

Summary

  • Breaking inheritance increases complexity and can degrade performance; reserve it for rare, time-bound exceptions.
  • Automate audits with Managed Identity using .NET 8 and TypeScript to find deep-scope hotspots and consolidate access.
  • Ship secure-by-default with Bicep: least privilege (Reader), deterministic role assignment IDs, and continuous monitoring via Azure services.

Friday, 23 January 2026

SharePoint Indexing: A Step-by-Step Guide for Faster Search and Precise Results

SharePoint indexing often fails silently, causing slow search and missing results. This guide shows exactly how to configure SharePoint indexing end-to-end—site settings, list-level indexing, search schema, and safe automation—so your users get fast, accurate results. Primary keyword: SharePoint indexing.

The Problem

Content updates are not discoverable, queries are slow, and filters return inconsistent items because lists aren’t indexed, sites aren’t reindexed, or managed properties are not mapped. You need a repeatable approach to fix indexing across sites and automate reindexing safely.

Prerequisites

  • Microsoft 365 tenant with SharePoint Online
  • PowerShell 7+ and PnP.PowerShell module (Install-Module PnP.PowerShell -Scope CurrentUser)
  • Role-based access (least privilege):
  • Site Owner or above for list/site indexing and reindex
  • Search Schema changes typically require SharePoint Administrator
  • Automation with app-only: Entra ID App with Sites.Selected + site-scoped permissions
  • Optional for automation: Azure Automation or Azure Functions with a System-Assigned Managed Identity

The Solution (Step-by-Step)

1) Confirm site-level indexing is allowed

Site Owners can check if content is visible to search crawlers.

  • Go to Site Settings → Search → Search and offline availability
  • Set Allow this site to appear in search results to Yes

Pro-Tip: If you changed this from No to Yes, trigger a site reindex to speed up propagation.

2) Index columns at the list/library level

Index columns used in filters, sorts, and query rules to avoid throttling and improve query performance.

  • Open your list/library → Settings → Indexed columns → Create a new index
  • Index only frequently queried columns (e.g., Status, Department, Created)

Pro-Tip: Avoid indexing lookup or multi-line rich text columns unless absolutely necessary; they can increase index size and crawl churn.

3) Reindex a list or library (targeted)

Use targeted reindex after column or schema changes on a specific list.

  • List Settings → Advanced settings → Reindex List

This flags the list for the next crawl so updated properties and new mappings are applied faster.

4) Reindex a site (broad fix)

Use this when you changed site-level search settings, content types, or many lists.

  • Site Settings → Search → Search and offline availability → Reindex site

Pro-Tip: Reindexing a site is heavier than a list reindex. Prefer list reindex when possible to reduce crawl load.

5) Map crawled properties to managed properties (search schema)

To make custom columns queryable/filterable/sortable, map crawled properties to managed properties with the right search attributes (Searchable, Queryable, Retrievable, Refinable, Sortable).

  • SharePoint Admin Center → More features → Search → Open
  • Manage Search Schema → Managed Properties
  • Create or edit a managed property (e.g., RefinableStringXX)
  • Map the relevant crawled property (e.g., ows_Status)

Pro-Tip: Use reserved RefinableStringXX or RefinableDateXX for facets and filters to avoid schema conflicts.

6) Automate reindex with PnP.PowerShell (interactive)

# Install PnP.PowerShell if needed
# Install-Module PnP.PowerShell -Scope CurrentUser

# 1) Interactive login for an admin or site owner
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/ProjectA" -Interactive

# 2) Reindex a single list by title
# This sets the reindex flag so the next crawl refreshes properties
$web = Get-PnPWeb
$list = Get-PnPList -Identity "Documents"
Set-PnPList -Identity $list -NoCrawl:$false   # Ensure list is crawlable
Request-PnPReIndexList -Identity $list        # Mark list for reindex

# 3) Reindex the entire site (use sparingly)
Request-PnPReIndexWeb

# 4) Verify a column is indexed
# Returns indexed column definitions for the list
(Get-PnPField -List $list) | Where-Object { $_.Indexed -eq $true } | Select-Object InternalName, Title

Comments:

  • Request-PnPReIndexList and Request-PnPReIndexWeb mark content for recrawl.
  • Set-PnPList -NoCrawl:$false ensures the list is included in search.
  • Use least privilege: Site Owner is sufficient for list-level operations.

7) Secure automation with Managed Identity or app-only (no secrets)

For production jobs, avoid interactive auth and stored passwords. Use a Managed Identity (Azure Automation/Functions) or an Entra ID app with Sites.Selected and scoped permissions to specific sites.

# Option A: Managed Identity (runs inside Azure with System-Assigned MI)
# Prereqs:
# - Assign the Managed Identity Sites.Selected permissions and grant site-level rights
# - Use Grant-PnPAzureADAppSitePermission to scope access to the target site

# Connect using Managed Identity
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/ProjectA" -ManagedIdentity

# Reindex specific list
Request-PnPReIndexList -Identity "Documents"
# Option B: Entra ID App with certificate (app-only, least privilege)
# Prereqs:
# - App registration with Microsoft Graph Sites.Selected (Application) permission
# - Admin consent granted
# - Grant site-level permission:
#   Grant-PnPAzureADAppSitePermission -AppId <CLIENT_ID> -DisplayName "Indexer" -Site "https://contoso.sharepoint.com/sites/ProjectA" -Permissions Read
# - Upload certificate and use its thumbprint

$tenant = "contoso.onmicrosoft.com"
$siteUrl = "https://contoso.sharepoint.com/sites/ProjectA"
$clientId = "00000000-0000-0000-0000-000000000000"
$thumb = "THUMBPRINT_HERE"

Connect-PnPOnline -Url $siteUrl -ClientId $clientId -Tenant $tenant -Thumbprint $thumb

# Safely trigger reindex
Request-PnPReIndexList -Identity "Documents"

Comments:

  • Sites.Selected lets you grant per-site permissions, enforcing least privilege.
  • Avoid legacy ACS app-only. Prefer Entra ID + Sites.Selected or Managed Identity.
  • No secrets in code; use certificates or Managed Identity.

8) Validate search consistency

  • Use Microsoft Search to query values you expect to find (e.g., Status:Active)
  • Validate refiners show the expected values (requires RefinableStringXX mapping)
  • Check list view performance with indexed columns applied as filters

Best Practices & Security

  • Principle of Least Privilege:
  • Day-to-day indexing: Site Owner
  • Search Schema: SharePoint Administrator (only when needed)
  • Automation: Managed Identity or app with Sites.Selected scoped to the target sites
  • Authentication:
  • Prefer Managed Identity in Azure Automation/Functions
  • Else use certificate-based app-only; avoid passwords and client secrets
  • Search Schema Hygiene:
  • Reuse RefinableStringXX/DateXX for facets and filters
  • Document managed property usage to prevent collisions across teams
  • Performance:
  • Index only columns that improve key queries
  • Prefer list reindex over site reindex to reduce crawl load

Pro-Tip: Create a small pilot list to test new mappings and reindex timings before applying to large libraries.

Troubleshooting

  • My column values don’t appear in search:
  • Confirm list is crawlable (NoCrawl = false)
  • Ensure a managed property is mapped and set to Queryable/Retrievable
  • Trigger Request-PnPReIndexList and wait for the next crawl cycle
  • Refiners don’t show my custom metadata:
  • Use a RefinableStringXX or RefinableDateXX managed property
  • Map the correct crawled property (often ows_ColumnInternalName)
  • App-only connection fails with 401/403:
  • Verify Sites.Selected consent and site-level grant (Grant-PnPAzureADAppSitePermission)
  • Confirm certificate thumbprint and validity
  • Reindex seems to do nothing:
  • Allow time for the next crawl; reindex sets a flag, it doesn’t force immediate recrawl
  • Check Service Health and Message Center for crawl incidents

Summary

  • Index the right columns and map to managed properties to make content queryable, refinable, and fast.
  • Use targeted list reindex first; reserve site reindex for broad changes.
  • Automate safely with Managed Identity or Sites.Selected app-only to enforce least privilege and avoid secrets.

References

Sunday, 18 January 2026

Integrate Power Apps with AI using Azure Functions (.NET 8) and Azure OpenAI with Managed Identity

Integrate Power Apps with AI by fronting Azure OpenAI behind a secure .NET 8 isolated Azure Function, authenticated via Entra ID and deployed with Azure Bicep. Why this matters: you keep your Azure OpenAI keyless via Managed Identity, enforce RBAC, and provide a stable HTTPS endpoint for Power Apps using a custom connector.

The Problem

Developers need to call AI securely from Power Apps without exposing keys, while meeting enterprise requirements for RBAC, observability, and least-privilege. Manual wiring through the portal and ad hoc security checks lead to drift and risk.

Prerequisites

  • Azure CLI 2.60+
  • .NET 8 SDK
  • Azure Developer CLI (optional) or Bicep CLI
  • Owner or User Access Administrator on the target subscription
  • Power Apps environment access for creating a custom connector

The Solution (Step-by-Step)

1) Deploy Azure resources with Bicep (Managed Identity, Function App, Azure OpenAI, RBAC, Easy Auth)

This Bicep template creates a Function App with system-assigned managed identity, Azure OpenAI with a model deployment, App Service Authentication (Easy Auth) enforced, and assigns the Cognitive Services OpenAI User role to the identity.

// main.bicep
targetScope = 'resourceGroup'

@description('Name prefix for resources')
param namePrefix string

@description('Location for resources')
param location string = resourceGroup().location

@description('Azure OpenAI model name, e.g., gpt-4o-mini')
param aoaiModelName string = 'gpt-4o-mini'

@description('Azure OpenAI model version for your region (see Azure docs for supported versions).')
param aoaiModelVersion string = '2024-07-18' // Tip: Validate the correct version via az cognitiveservices account list-models

var funcName = '${namePrefix}-func'
var aoaiName = '${namePrefix}-aoai'
var hostingPlanName = '${namePrefix}-plan'
var appInsightsName = '${namePrefix}-ai'
var storageName = toLower(replace('${namePrefix}st${uniqueString(resourceGroup().id)}', '-', ''))

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}

resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
  }
}

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
  name: hostingPlanName
  location: location
  sku: {
    name: 'Y1' // Consumption for Functions
    tier: 'Dynamic'
  }
}

resource func 'Microsoft.Web/sites@2023-12-01' = {
  name: funcName
  location: location
  kind: 'functionapp'
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: plan.id
    siteConfig: {
      appSettings: [
        { name: 'AzureWebJobsStorage', value: storage.listKeys().keys[0].value }
        { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString }
        { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
        { name: 'FUNCTIONS_WORKER_RUNTIME', value: 'dotnet-isolated' }
        // Enable Easy Auth enforcement via config below (authSettingsV2)
      ]
      http20Enabled: true
      minimumTlsVersion: '1.2'
      ftpsState: 'Disabled'
      cors: {
        allowedOrigins: [
          // Add your Power Apps domain if needed; CORS is typically managed by the custom connector
        ]
      }
    }
    httpsOnly: true
  }
}

// Enable App Service Authentication (Easy Auth) with Entra ID and enforce authentication.
resource auth 'Microsoft.Web/sites/config@2023-12-01' = {
  name: '${func.name}/authsettingsV2'
  properties: {
    globalValidation: {
      requireAuthentication: true
      unauthenticatedClientAction: 'Return401'
    }
    identityProviders: {
      azureActiveDirectory: {
        enabled: true
        registration: {
          // When omitted, system can use an implicit provider. For enterprise, specify a registered app:
          // Provide clientId and clientSecretSettingName if using a dedicated app registration.
        }
        validation: {
          // Audience (App ID URI or application ID). Power Apps custom connector should request this audience.
          // Replace with your API application ID URI when using a dedicated AAD app registration.
          allowedAudiences: [
            'api://<your-app-id>'
          ]
        }
        login: {
          disableWWWAuthenticate: false
        }
      }
    }
    platform: {
      enabled: true
      runtimeVersion: '~1'
    }
    login: {
      tokenStore: {
        enabled: true
      }
    }
  }
}

// Azure OpenAI account
resource aoai 'Microsoft.CognitiveServices/accounts@2023-05-01' = {
  name: aoaiName
  location: location
  kind: 'OpenAI'
  sku: {
    name: 'S0'
  }
  properties: {
    customSubDomainName: toLower(aoaiName)
    publicNetworkAccess: 'Enabled'
  }
}

// Azure OpenAI deployment - ensure model/version are valid for your region
resource aoaiDeployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = {
  name: 'chat-${aoaiModelName}'
  parent: aoai
  properties: {
    model: {
      format: 'OpenAI'
      name: aoaiModelName
      version: aoaiModelVersion
    }
    capacities: [
      {
        capacity: 1
        capacityType: 'Standard'
      }
    ]
  }
}

// Assign RBAC: Cognitive Services OpenAI User role to the Function's managed identity
// Built-in role: Cognitive Services OpenAI User (Role ID: 5e0bd9bd-7ac1-4c9e-8289-1b01f135d4a8)
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(aoai.id, func.identity.principalId, 'CognitiveServicesOpenAIUserRole')
  scope: aoai
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7ac1-4c9e-8289-1b01f135d4a8')
    principalId: func.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

output functionAppName string = func.name
output openAiEndpoint string = 'https://${aoai.name}.openai.azure.com/'
output openAiDeployment string = aoaiDeployment.name

Tip: To find the correct model version for your region, run: az cognitiveservices account list-models --name <aoaiName> --resource-group <rg> --output table.

2) Implement a secure .NET 8 isolated Azure Function with DI and Managed Identity

The function validates Entra ID via Easy Auth headers, performs input validation, calls Azure OpenAI using DefaultAzureCredential, and returns a minimal response. AuthorizationLevel is Anonymous because App Service Authentication is enforcing auth at the edge; the code still verifies identity defensively.

// Program.cs
using System.Text.Json;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Azure.Functions.Worker.Extensions.OpenApi.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication(builder =>
    {
        // Add global middleware if needed (e.g., correlation, exception handling)
        builder.UseMiddleware<GlobalExceptionMiddleware>();
    })
    .ConfigureServices((ctx, services) =>
    {
        var config = ctx.Configuration;

        // Bind custom options
        services.Configure<OpenAIOptions>(config.GetSection(OpenAIOptions.SectionName));

        // Register OpenAI client using Managed Identity (no keys)
        services.AddSingleton((sp) =>
        {
            var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenAIOptions>>().Value;
            // Use DefaultAzureCredential to leverage Managed Identity in Azure
            var credential = new DefaultAzureCredential();
            return new OpenAIClient(new Uri(options.Endpoint), credential);
        });

        services.AddSingleton<IPromptService, PromptService>();
        services.AddApplicationInsightsTelemetryWorkerService();
    })
    .ConfigureAppConfiguration(config =>
    {
        config.AddEnvironmentVariables();
    })
    .ConfigureLogging(logging =>
    {
        logging.AddConsole();
    })
    .Build();

await host.RunAsync();

file sealed class GlobalExceptionMiddleware : IFunctionsWorkerMiddleware
{
    public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            var logger = context.GetLogger<GlobalExceptionMiddleware>();
            logger.LogError(ex, "Unhandled exception");

            // Avoid leaking internals; return generic error with correlation ID
            var invocationId = context.InvocationId;
            var http = await context.GetHttpRequestDataAsync();
            if (http is not null)
            {
                var response = http.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
                await response.WriteStringAsync($"Request failed. CorrelationId: {invocationId}");
                context.GetInvocationResult().Value = response;
            }
        }
    }
}

file sealed class OpenAIOptions
{
    public const string SectionName = "OpenAI";
    public string Endpoint { get; init; } = string.Empty; // e.g., https://<aoaiName>.openai.azure.com/
    public string Deployment { get; init; } = string.Empty; // e.g., chat-gpt-4o-mini
}

// Service abstraction to keep function logic clean
file interface IPromptService
{
    Task<string> CreateChatResponseAsync(string userInput, CancellationToken ct);
}

file sealed class PromptService(OpenAIClient client, Microsoft.Extensions.Options.IOptions<OpenAIOptions> opts, ILogger<PromptService> logger) : IPromptService
{
    private readonly OpenAIClient _client = client;
    private readonly OpenAIOptions _opts = opts.Value;
    private readonly ILogger<PromptService> _logger = logger;

    public async Task<string> CreateChatResponseAsync(string userInput, CancellationToken ct)
    {
        // Basic input trimming and minimal length check
        var prompt = (userInput ?? string.Empty).Trim();
        if (prompt.Length < 3)
        {
            return "Input too short.";
        }

        // Create chat completion with minimal system prompt
        var req = new ChatCompletionsOptions()
        {
            DeploymentName = _opts.Deployment,
            Temperature = 0.2f,
            MaxTokens = 256
        };
        req.Messages.Add(new ChatRequestSystemMessage("You are a concise assistant.")); // Guardrail
        req.Messages.Add(new ChatRequestUserMessage(prompt));

        _logger.LogInformation("Calling Azure OpenAI deployment {Deployment}", _opts.Deployment);

        var resp = await _client.GetChatCompletionsAsync(req, ct);
        var msg = resp.Value.Choices.Count > 0 ? resp.Value.Choices[0].Message.Content[0].Text : "No response.";
        return msg ?? string.Empty;
    }
}
// HttpFunction.cs
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Api;

public sealed class ChatRequest
{
    // Strongly typed DTO for request validation
    public string Prompt { get; init; } = string.Empty;
}

public sealed class ChatFunction(IPromptService promptService, ILogger<ChatFunction> logger) 
{
    // Simple helper to read Easy Auth principal header safely
    private static ClaimsPrincipal? TryGetPrincipal(HttpRequestData req)
    {
        // Easy Auth sets x-ms-client-principal as Base64 JSON
        if (!req.Headers.TryGetValues("x-ms-client-principal", out var values)) return null;
        var b64 = values.FirstOrDefault();
        if (string.IsNullOrWhiteSpace(b64)) return null;

        try
        {
            var json = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
            using var doc = JsonDocument.Parse(json);
            var claims = new List<Claim>();
            if (doc.RootElement.TryGetProperty("claims", out var arr))
            {
                foreach (var c in arr.EnumerateArray())
                {
                    var typ = c.GetProperty("typ").GetString() ?? string.Empty;
                    var val = c.GetProperty("val").GetString() ?? string.Empty;
                    claims.Add(new Claim(typ, val));
                }
            }
            return new ClaimsPrincipal(new ClaimsIdentity(claims, "EasyAuth"));
        }
        catch
        {
            return null;
        }
    }

    [Function("chat")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "chat")] HttpRequestData req,
        FunctionContext context)
    {
        var principal = TryGetPrincipal(req);
        if (principal is null)
        {
            // Defense in depth: App Service auth should already block, but we double-check.
            var unauth = req.CreateResponse(HttpStatusCode.Unauthorized);
            await unauth.WriteStringAsync("Authentication required.");
            return unauth;
        }

        // Basic content-type check and bound read limit
        if (!req.Headers.TryGetValues("Content-Type", out var ct) || !ct.First().Contains("application/json", StringComparison.OrdinalIgnoreCase))
        {
            var bad = req.CreateResponse(HttpStatusCode.BadRequest);
            await bad.WriteStringAsync("Content-Type must be application/json.");
            return bad;
        }

        // Safe body read
        using var reader = new StreamReader(req.Body);
        var body = await reader.ReadToEndAsync();
        ChatRequest? input;
        try
        {
            input = JsonSerializer.Deserialize<ChatRequest>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
        }
        catch
        {
            var bad = req.CreateResponse(HttpStatusCode.BadRequest);
            await bad.WriteStringAsync("Invalid JSON.");
            return bad;
        }

        if (input is null || string.IsNullOrWhiteSpace(input.Prompt))
        {
            var bad = req.CreateResponse(HttpStatusCode.BadRequest);
            await bad.WriteStringAsync("Prompt is required.");
            return bad;
        }

        var loggerScope = new Dictionary<string, object> { ["UserObjectId"] = principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value ?? "unknown" };
        using (logger.BeginScope(loggerScope))
        {
            try
            {
                var result = await promptService.CreateChatResponseAsync(input.Prompt, context.CancellationToken);
                var ok = req.CreateResponse(HttpStatusCode.OK);
                await ok.WriteAsJsonAsync(new { response = result });
                return ok;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Error generating chat response");
                var err = req.CreateResponse(HttpStatusCode.BadGateway);
                await err.WriteStringAsync("AI request failed. Try again later.");
                return err;
            }
        }
    }
}
// appsettings.json (for local debug)
{
  "OpenAI": {
    "Endpoint": "https://<your-aoai-name>.openai.azure.com/",
    "Deployment": "chat-gpt-4o-mini"
  }
}

Tip: When running locally, ensure you are logged in with Azure CLI (az login) so DefaultAzureCredential can acquire a token. Assign yourself Cognitive Services OpenAI User on the AOAI resource for testing.

3) Enforce Azure AD (Easy Auth) - configuration example

The Bicep above enables Easy Auth and returns 401 for unauthenticated calls. If you maintain settings as JSON, you can also use an ARM style configuration:

// Example snippet (appsettings for site config via ARM/Bicep-style) ensures:
// - requireAuthentication: true
// - allowedAudiences must match the audience used by your client (Power Apps custom connector)
{
  "properties": {
    "authSettingsV2": {
      "platform": { "enabled": true },
      "globalValidation": {
        "requireAuthentication": true,
        "unauthenticatedClientAction": "Return401"
      },
      "identityProviders": {
        "azureActiveDirectory": {
          "enabled": true,
          "validation": {
            "allowedAudiences": [ "api://<your-app-id>" ]
          }
        }
      }
    }
  }
}

Tip: For most enterprise setups, register a dedicated App Registration for the Function API and reference its Application ID URI in allowedAudiences. Keep consent and scopes explicit.

4) Create a Power Apps Custom Connector

  1. In Power Apps, open Solutions and create a new Custom Connector.
  2. Set the Host to your Function URL (e.g., https://<func-name>.azurewebsites.net).
  3. Define the POST /chat operation that accepts a JSON body { "prompt": "..." } and returns { "response": "..." }.
  4. Security: Choose OAuth 2.0 (Azure Active Directory). Set Audience to match allowedAudiences (api://<your-app-id>). Supply Tenant ID and Client ID as provided by your admin.
  5. Test the connector. You should receive HTTP 200 with the AI response. Unauthorized calls return 401.

5) Add client-side validation in Power Apps (Power Fx)

Before invoking the connector, validate inputs in the app to fail fast and reduce server load.

// Example Power Fx for a button's OnSelect
If(
    IsBlank(Trim(txtPrompt.Text)) || Len(Trim(txtPrompt.Text)) < 3,
    Notify("Please enter at least 3 characters.", NotificationType.Error),
    Set(
        aiResult,
        YourConnector.chat(
            {
                prompt: Trim(txtPrompt.Text)
            }
        ).response
    )
);

// Display result in a Label: Text = aiResult

Tip: Add input length limits and debouncing for multi-keystroke actions. For read-only UI against data, prefer server-side pagination and AsNoTracking() on the API layer where EF Core is involved.

Best Practices & Security

Identity and RBAC

  • Use Managed Identity for the Function App; never store secrets. The sample uses DefaultAzureCredential.
  • Assign the Cognitive Services OpenAI User role to the Function’s identity on the Azure OpenAI resource. This allows API calls without exposing keys.
  • Enforce Azure AD with Easy Auth at the platform level and verify identity in code for defense in depth.

Least Privilege and Network

  • Restrict access with the minimum RBAC scope necessary. Avoid granting Contributor on the subscription.
  • Enable HTTPS only and TLS 1.2+. Disable FTP/FTPS where possible.
  • Consider Private Endpoints for Azure OpenAI and Functions behind an Application Gateway/WAF for enterprise networks.

Validation, Error Handling, and Reliability

  • Validate input on both client (Power Apps) and server (Function). The code enforces content-type and schema shape.
  • Handle exceptions using middleware and return generic messages with correlation IDs to avoid leaking internals.
  • Implement circuit breakers and retries for downstream calls with transient fault policies if you add HTTP dependencies.

Observability

  • Use Application Insights. Log prompt sizes and latency, not raw sensitive content.
  • Sample KQL to investigate errors:
// Requests by operation
requests
| where url endswith "/chat"
| summarize count() by resultCode

// Exceptions with correlation
exceptions
| where operation_Name == "chat"
| project timestamp, type, problemId, outerMessage, operation_Id

// Latency p95
requests
| where url endswith "/chat"
| summarize p95(duration) by bin(timestamp, 1h)

Cost and Abuse Controls

  • Apply rate limiting at the API Management layer if exposing broadly.
  • Set MaxTokens and Temperature conservatively; monitor usage with budgets and alerts.
  • Implement content filters as needed using Azure AI Content Safety.

Summary

  • Power Apps integrates cleanly with AI by calling a secure .NET 8 Azure Function that uses Managed Identity to access Azure OpenAI.
  • Security is enforced with Azure AD (Easy Auth) and RBAC, specifically the Cognitive Services OpenAI User role.
  • IaC with Bicep, defensive coding, and Application Insights deliver repeatable, production-grade operations.