Showing posts with label RBAC. Show all posts
Showing posts with label RBAC. Show all posts

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.

Wednesday, 14 January 2026

What’s New in PnP for SPFx: PnPjs v3+, React Controls, and Secure Patterns

PnP for SPFx has evolved with practical updates that reduce bundle size, improve performance, and harden security. The problem: teams migrating or maintaining SPFx solutions are unsure which PnP changes truly matter and how to adopt them safely. The solution: adopt PnPjs v3+ modular imports, leverage updated PnP SPFx React Controls where it makes sense, and implement concrete RBAC permissions with least privilege. The value: smaller bundles, faster pages, and auditable access aligned to enterprise security.

The Problem

Developers building SPFx web parts and extensions need a clear, production-grade path to modern PnP usage. Without guidance, projects risk bloated bundles, brittle permissions, and fragile data access patterns.

Prerequisites

  • Node.js v20+
  • SPFx v1.18+ (Yo @microsoft/sharepoint generator)
  • TypeScript 5+ with strict mode enabled
  • Office 365 tenant with App Catalog and permission to deploy apps
  • PnPjs v3+ and @pnp/spfx-controls-react
  • Optional: PnP PowerShell (latest), Azure CLI if integrating with Azure services

The Solution (Step-by-Step)

1) Adopt PnPjs v3+ with strict typing, ESM, and SPFx behavior

Use modular imports and the SPFx behavior to bind to the current context. Validate runtime data with Zod for resilient web parts.

/* Strict TypeScript example for SPFx with PnPjs v3+ */
import { spfi, SPFI } from "@pnp/sp"; // Core PnPjs factory and interface
import { SPFx } from "@pnp/sp/behaviors/spfx"; // Binds SPFx context as a behavior
import "@pnp/sp/items"; // Bring in list items API surface
import "@pnp/sp/lists"; // Bring in lists API surface
import { z } from "zod"; // Runtime schema validation

// Minimal shape for data we expect from SharePoint
const TaskSchema = z.object({
  Id: z.number(),
  Title: z.string(),
  Status: z.string().optional(),
});

type Task = z.infer<typeof TaskSchema>;

// SPFx helper to create a bound SP instance. This avoids global state and is testable.
export function getSP(context: unknown): SPFI {
  // context should be the WebPartContext or Extension context
  return spfi().using(SPFx(context as object));
}

// Fetch list items with strong typing and runtime validation
export async function fetchTasks(sp: SPFI, listTitle: string): Promise<readonly Task[]> {
  // Select only the fields needed for minimal payloads
  const raw = await sp.web.lists.getByTitle(listTitle).items.select("Id", "Title", "Status")();
  // Validate at runtime to catch unexpected shapes
  const parsed = z.array(TaskSchema).parse(raw);
  return parsed;
}

Why this matters: smaller imports improve tree shaking, and behaviors keep your data layer clean, testable, and context-aware.

2) Use batching and caching behaviors for fewer round-trips

Batch multiple reads to reduce network overhead, and apply caching for read-heavy views.

import { spfi, SPFI } from "@pnp/sp";
import { SPFx } from "@pnp/sp/behaviors/spfx";
import "@pnp/sp/webs";
import "@pnp/sp/lists";
import "@pnp/sp/items";
import { Caching } from "@pnp/queryable"; // Behavior for query caching

export function getCachedSP(context: unknown): SPFI {
  return spfi().using(SPFx(context as object)).using(
    Caching({
      store: "local", // Use localStorage for simplicity; consider session for sensitive data
      defaultTimeout: 30000, // 30s cache duration; tune to your UX needs
    })
  );
}

export async function batchedRead(sp: SPFI, listTitle: string): Promise<{ count: number; first: string }> {
  // Create a batched instance
  const [batchedSP, execute] = sp.batched();

  // Queue multiple operations
  const itemsPromise = batchedSP.web.lists.getByTitle(listTitle).items.select("Id", "Title")();
  const topItemPromise = batchedSP.web.lists.getByTitle(listTitle).items.top(1).select("Title")();

  // Execute the batch
  await execute();

  const items = await itemsPromise;
  const top = await topItemPromise;

  return { count: items.length, first: (top[0]?.Title ?? "") };
}

Pro-Tip: Combine select, filter, and top to minimize payloads and speed up rendering.

3) Use PnP SPFx React Controls when they save time

Prefer controls that encapsulate complex, well-tested UX patterns. Examples:

  • PeoplePicker for directory-aware selection
  • FilePicker for consistent file selection
  • ListView for performant tabular data
import * as React from "react";
import { PeoplePicker, PrincipalType } from "@pnp/spfx-controls-react/lib/PeoplePicker";

// Strongly typed shape for selected people
export type Person = {
  id: string;
  text: string;
  secondaryText?: string;
};

type Props = {
  onChange: (people: readonly Person[]) => void;
};

export function PeopleSelector(props: Props): JSX.Element {
  return (
    <div>
      <PeoplePicker
        context={(window as unknown as { spfxContext: unknown }).spfxContext}
        titleText="Select people"
        personSelectionLimit={3}
        principalTypes={[PrincipalType.User]}
        showtooltip
        required={false}
        onChange={(items) => {
          const mapped: readonly Person[] = items.map((i) => ({
            id: String(i.id),
            text: i.text,
            secondaryText: i.secondaryText,
          }));
          props.onChange(mapped);
        }}
      />
    </div>
  );
}

Pro-Tip: Keep these controls behind thin adapters so you can swap or mock them in tests without touching business logic.

4) Streamline deployment with PnP PowerShell

Automate packaging and deployment to ensure consistent, auditable releases.

# Install: https://pnp.github.io/powershell/
# Deploy an SPFx package to the tenant app catalog and install to a site
Connect-PnPOnline -Url https://contoso-admin.sharepoint.com -Interactive

# Publish/overwrite SPPKG into the tenant catalog
Add-PnPApp -Path .\sharepoint\solution\my-solution.sppkg -Scope Tenant -Publish -Overwrite

# Install the app to a specific site
Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/ProjectX -Interactive
$pkg = Get-PnPApp | Where-Object { $_.Title -eq "My Solution" }
Install-PnPApp -Identity $pkg.Id -Scope Site -Overwrite

Pro-Tip: Run these commands from CI using OIDC to Azure AD (no stored secrets) and conditional approvals for production sites.

5) Security and RBAC: explicit, least-privilege permissions

Be explicit about the minimal roles required:

  • SharePoint site and list permissions: Read (for read-only web parts), Edit or Contribute (only when creating/updating items). Prefer item- or list-scoped permissions over site-wide.
  • Graph delegated permissions in SPFx: User.Read, User.ReadBasic.All, Sites.Read.All (only if cross-site reads are required). Request via API access in the package solution. Avoid .All scopes unless necessary.
  • Azure service calls via backend API: If your SPFx calls an Azure Function or App Service, secure the backend with Entra ID and assign a Managed Identity to the backend. Grant that identity minimal roles such as Storage Blob Data Reader or Storage Blob Data Contributor on specific storage accounts or containers only.

Pro-Tip: Prefer resource-specific consent to SharePoint or Graph endpoints and scope consents to the smallest set of sites or resources.

6) Add an error boundary for resilient UI

SPFx runs inside complex pages; isolate failures so one component does not break the whole canvas.

import * as React from "react";

type BoundaryState = { hasError: boolean };

export class ErrorBoundary extends React.Component<React.PropsWithChildren<unknown>, BoundaryState> {
  state: BoundaryState = { hasError: false };

  static getDerivedStateFromError(): BoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: unknown): void {
    // Log to a centralized telemetry sink (e.g., Application Insights)
    // Avoid PII; sanitize messages before sending
    console.error("ErrorBoundary caught:", error);
  }

  render(): React.ReactNode {
    if (this.state.hasError) {
      return <div role="alert">Something went wrong. Please refresh or try again later.</div>;
    }
    return this.props.children;
  }
}

Wrap your data-heavy components with ErrorBoundary and fail gracefully.

7) Modernize imports for tree shaking and smaller bundles

Only import what you use. Avoid star imports.

// Good: minimal surface
import { spfi } from "@pnp/sp";
import "@pnp/sp/items";
import "@pnp/sp/lists";

// Avoid: broad or legacy preset imports that include APIs you don't need
// import "@pnp/sp/presets/all";

Pro-Tip: Run webpack-bundle-analyzer to confirm reductions as you trim imports.

Best Practices & Security

  • Principle of Least Privilege: grant Read before Edit or Contribute; avoid tenant-wide Sites.Read.All unless essential.
  • Runtime validation: use Zod to guard against content type or field drift.
  • Behavior-driven PnPjs: keep SPFx context in a factory; never in globals.
  • Resiliency: add retries/backoff for throttling with PnPjs behaviors; display non-blocking toasts for transient failures.
  • No secrets in client code: if integrating with Azure, call a backend secured with Entra ID; use Managed Identities on the backend instead of keys.
  • Accessibility: ensure controls include aria labels and keyboard navigation.
  • Observability: log warnings and errors with correlation IDs to diagnose issues across pages.

Pro-Tip: For heavy reads, combine batching with narrow select filters and increase cache duration carefully; always provide a user-initiated refresh.

Summary

  • PnPjs v3+ with behaviors, batching, and caching delivers smaller, faster, and cleaner SPFx data access.
  • PnP SPFx React Controls accelerate complex UX while remaining testable behind adapters.
  • Explicit RBAC and runtime validation raise your security bar without slowing delivery.

Saturday, 10 January 2026

Binding a Dropdown List in ASP.NET Core with EF Core and Azure Managed Identity

The Problem

Developers often need to populate dropdown lists dynamically from a database in ASP.NET Core applications. The challenge is to do this securely and efficiently, avoiding hardcoded connection strings and ensuring the solution is production-ready with Azure best practices.

Prerequisites

  • .NET 8 SDK installed
  • Azure CLI configured
  • Azure SQL Database with Managed Identity enabled
  • Visual Studio Code or IDE of choice

The Solution (Step-by-Step)

Step 1: Configure Azure Managed Identity and RBAC

Assign your App Service or Azure Function a System-Assigned Managed Identity. Then, grant the identity the Azure SQL Database Contributor role or a custom role with least privilege to access the database.

Step 2: Configure EF Core with DefaultAzureCredential

using Azure.Identity; // For DefaultAzureCredential
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Use DefaultAzureCredential for Managed Identity authentication
var credential = new DefaultAzureCredential();
var connectionString = builder.Configuration["AZURE_SQL_CONNECTION_STRING"];

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.EnableRetryOnFailure(); // Resiliency for transient faults
    });
});

var app = builder.Build();
app.Run();

Explanation: We use DefaultAzureCredential to authenticate via Managed Identity, avoiding hardcoded secrets. The connection string should reference the server and database but omit credentials.

Step 3: Create the Model and DbContext

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Category> Categories => Set<Category>();
}

Explanation: The Category entity represents the dropdown data source.

Step 4: Implement the Controller Action

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;

[ApiController]
[Route("api/[controller]")]
public class CategoriesController(AppDbContext context) : ControllerBase
{
    [HttpGet("dropdown")]
    public IActionResult GetDropdown()
    {
        // Fetch categories without tracking for performance
        var categories = context.Categories.AsNoTracking()
            .Select(c => new SelectListItem
            {
                Value = c.Id.ToString(),
                Text = c.Name
            }).ToList();

        return Ok(categories);
    }
}

Explanation: The action retrieves categories and maps them to SelectListItem for dropdown binding. AsNoTracking() improves performance for read-only queries.

Step 5: Bind in Razor View

@model YourViewModel

<select asp-for="SelectedCategoryId" asp-items="Model.Categories"></select>

Explanation: The Razor view uses asp-items to bind the dropdown list.

Best Practices & Security

  • Use Managed Identity with DefaultAzureCredential instead of storing credentials in code or configuration files.
  • Apply the Principle of Least Privilege by assigning only necessary RBAC roles like Azure SQL Database Contributor.
  • Validate user input in controllers to prevent injection attacks.
  • Use AsNoTracking() for read-only queries to improve performance.

Summary

  • Bind dropdown lists using EF Core and strongly typed models.
  • Secure database access with Azure Managed Identity and RBAC roles.
  • Follow best practices for performance and security in production.