Showing posts with label Microsoft Graph. Show all posts
Showing posts with label Microsoft Graph. 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.

Saturday, 24 January 2026

Configuring Permission in SharePoint with .NET 8 and Microsoft Graph (Azure-first)

If you need to automate permission in SharePoint reliably, use Microsoft Graph with .NET 8 and Azure-managed identities. The goal: grant site-scoped access (least privilege via Sites.Selected), verify effective roles, and perform read/write operations without client secrets.

The Problem

SharePoint permissions are often over-provisioned or managed manually. That leads to audit gaps, break-glass patterns, and production drift. You need a repeatable, least-privilege approach that grants only the required access to specific sites, automates verification, and avoids client secrets.

Prerequisites

Required tools and permissions:

  • .NET 8 SDK
  • Azure CLI v2.58+ (logged in as a tenant admin for one-time grants)
  • Microsoft Graph application permissions consent capability (tenant admin)
  • Azure subscription access to create a user-assigned managed identity (Contributor on resource group)

The Solution (Step-by-Step)

Step 1. Choose the authentication model

Use managed identity for workloads in Azure (Functions, Container Apps). This removes client secrets entirely. For CI/CD, use workload identity federation instead of secrets.

  • Runtime principal: user-assigned/system-assigned managed identity
  • Graph permission model: application permission Sites.Selected for the runtime principal
  • Grant site-scoped roles: read or write at the specific SharePoint site level

Why Sites.Selected: it blocks blanket access (e.g., Sites.Read.All) and forces explicit grants per site.

Step 2. Infrastructure as Code (Bicep): create a user-assigned managed identity

// main.bicep
targetScope = 'resourceGroup'

// User-assigned managed identity that will call Microsoft Graph
resource uami 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
  name: 'sp-sites-selected-uami'
  location: resourceGroup().location
}

output uamiClientId string = uami.properties.clientId
output uamiPrincipalId string = uami.properties.principalId

After deployment, assign Microsoft Graph application permission Sites.Selected to the managed identity’s service principal. This is a one-time admin action.

# Assign Graph app role (Sites.Selected) to the managed identity service principal
# 1) Get Graph service principal (well-known)
GRAPH_SP_ID=$(az ad sp list --filter "appId eq '00000003-0000-0000-c000-000000000000'" --query "[0].id" -o tsv)

# 2) Get the Sites.Selected app role ID
SITES_SELECTED_ROLE_ID=$(az ad sp show --id $GRAPH_SP_ID --query "appRoles[?value=='Sites.Selected' && allowedMemberTypes[@]=='Application'].id" -o tsv)

# 3) Get your managed identity's service principal object id
UAMI_PRINCIPAL_ID=<uamiPrincipalId from bicep output>

# 4) Assign the app role to the managed identity
az ad sp add-approle-assignment \
  --id $UAMI_PRINCIPAL_ID \
  --principal-object-id $UAMI_PRINCIPAL_ID \
  --resource-id $GRAPH_SP_ID \
  --app-role-id $SITES_SELECTED_ROLE_ID

Admin consent is implicit when you add an app role assignment to Graph for an enterprise application (service principal). Validate in Entra ID under Enterprise applications > Your Managed Identity > Permissions.

Step 3. One-time site-scoped grant to the managed identity

Sites.Selected requires a per-site grant. Use an admin-only tool to grant read/write on a single site to the managed identity. Below is a .NET 8 minimal API you can run locally as a tenant admin via Azure CLI authentication to grant permissions. It uses DI, file-scoped namespaces, and the Microsoft Graph SDK.

using Azure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.Kiota.Abstractions.Authentication;

namespace SharePointPermAdmin;

var builder = WebApplication.CreateBuilder(args);

// Admin credential for local, one-time operations only.
// Uses Azure CLI token of a tenant admin. No client secrets.
builder.Services.AddSingleton((sp) =>
{
    // Authenticate as the signed-in Azure CLI account
    var credential = new AzureCliCredential();

    // Adapter for Microsoft Graph SDK
    var authProvider = new TokenCredentialAuthenticationProvider(
        credential,
        // Microsoft Graph default scope for app-permission endpoints
        new[] { "https://graph.microsoft.com/.default" });

    return new GraphServiceClient(authProvider);
});

var app = builder.Build();

// Code Overview: Grants site-level permission (read or write) to a target application or managed identity
// Endpoint parameters:
//  - hostname: e.g., "contoso.sharepoint.com"
//  - sitePath: e.g., "/sites/Engineering"
//  - targetAppId: the clientId of the target app or managed identity (UAMI clientId)
//  - role: "read" or "write" (least privilege)
app.MapPost("/grant", async (GraphServiceClient graph,
    string hostname, string sitePath, string targetAppId, string role) =>
{
    // 1) Resolve the site by hostname and site path
    var site = await graph.Sites["{hostname}:{sitePath}"].GetAsync();
    if (site is null) return Results.NotFound("Site not found.");

    // 2) Prepare the permission grant
    var requestedRole = role.Equals("write", StringComparison.OrdinalIgnoreCase) ? "write" : "read";

    var permission = new Permission
    {
        // Roles supported for Sites.Selected are "read" and "write"
        Roles = new List<string> { requestedRole },
        GrantedToIdentities = new List<IdentitySet>
        {
            new IdentitySet
            {
                Application = new Identity
                {
                    // targetAppId can be the clientId of a user-assigned managed identity
                    // or an app registration clientId
                    Id = targetAppId,
                    DisplayName = "Sites.Selected App"
                }
            }
        }
    };

    // 3) Grant the permission at the site-level
    var created = await graph.Sites[site.Id].Permissions.PostAsync(permission);

    // 4) Return what was granted for audit
    return Results.Ok(new
    {
        SiteId = site.Id,
        GrantedRoles = created?.Roles,
        GrantedTo = created?.GrantedToIdentities?.Select(x => x.Application?.Id)
    });
});

app.Run();

Run this locally with an Azure CLI context that has tenant admin privileges. This API grants the site-scoped role to your managed identity (identified by its clientId). Keep this tool restricted and audit its use.

Step 4. Runtime workload: access the site using the managed identity

Your production service (Azure Functions or Container Apps) uses DefaultAzureCredential to get a token for Graph and perform read actions (if granted read) or write actions (if granted write).

using Azure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Graph;
using Microsoft.Kiota.Abstractions.Authentication;

namespace SharePointRuntime;

var builder = WebApplication.CreateBuilder(args);

// Graph client using managed identity in Azure (no secrets)
// In local dev, DefaultAzureCredential falls back to Azure CLI login.
builder.Services.AddSingleton((sp) =>
{
    var credential = new DefaultAzureCredential();
    var authProvider = new TokenCredentialAuthenticationProvider(
        credential,
        new[] { "https://graph.microsoft.com/.default" });

    return new GraphServiceClient(authProvider);
});

var app = builder.Build();

// Code Overview: Example read-only endpoint listing drive root items for a given site.
// Requires the managed identity to have Sites.Selected + "read" on that site.
app.MapGet("/sites/{hostname}/{*sitePath}/drive-items", async (GraphServiceClient graph, string hostname, string sitePath) =>
{
    // 1) Resolve site by hostname and site path
    var site = await graph.Sites[$"{hostname}:{sitePath}"].GetAsync();
    if (site is null) return Results.NotFound("Site not found.");

    // 2) Read drive root items (read permission is sufficient)
    var items = await graph.Sites[site.Id].Drive.Root.Children.GetAsync();

    return Results.Ok(items?.Value?.Select(i => new { i.Id, i.Name, i.Size, i.LastModifiedDateTime }));
});

app.Run();

Step 5. Optional: strict front-end validation for an internal admin form

If you build an admin UI to call the grant API, validate inputs with Zod and strict TypeScript types.

import { z } from 'zod';

// Discriminated union for role
const RoleSchema = z.union([z.literal('read'), z.literal('write')]);

// Strict payload schema
export const GrantPayloadSchema = z.object({
  hostname: z.string().min(1).regex(/^([a-zA-Z0-9-]+)\.sharepoint\.com$/),
  sitePath: z.string().min(1).startsWith('/'),
  targetAppId: z.string().uuid(),
  role: RoleSchema
});

export type GrantPayload = z.infer<typeof GrantPayloadSchema>;

// Example safe submit
export async function submitGrant(payload: GrantPayload) {
  const parsed = GrantPayloadSchema.parse(payload); // throws on invalid input
  const res = await fetch('/grant?'
    + new URLSearchParams({
      hostname: parsed.hostname,
      sitePath: parsed.sitePath,
      targetAppId: parsed.targetAppId,
      role: parsed.role
    }), { method: 'POST' });

  if (!res.ok) throw new Error('Grant failed');
  return res.json();
}

Best Practices & Security

  • Best Practice: Use Sites.Selected and grant per-site roles (read/write) instead of tenant-wide Sites.Read.All or Sites.FullControl.All.
  • Best Practice: Prefer managed identity (Azure) or workload identity federation (CI/CD) over client secrets.
  • Best Practice: Separate duties. Keep the grant tool under tenant admin control; the runtime app should never be able to self-elevate.
  • Best Practice: Log every permission grant with who, what, when, and siteId. Store in an immutable audit store.
  • Best Practice: Implement retries with exponential backoff for Graph calls and handle 429/5xx responses gracefully.
  • Best Practice: Validate all inputs server-side. Reject unknown hostnames and unexpected site paths.
  • Best Practice: Monitor with Microsoft 365 audit logs and alert on unexpected grants or role changes.

Minimum permissions required (explicit)

  • Runtime principal (managed identity): Microsoft Graph application permission Sites.Selected.
  • Admin/grant principal: Microsoft Graph application permission Sites.FullControl.All or delegated SharePoint admin role sufficient to create site-level grants via Graph.
  • SharePoint site roles used for Sites.Selected: read (equivalent to site Read), write (equivalent to Edit). Grant the lowest role that satisfies requirements.

Error handling notes

  • Catch GraphServiceException and map common statuses: 403 (insufficient role), 404 (site not found), 429 (throttle) with Retry-After.
  • Add circuit breakers/timeouts. Do not leak raw exception messages in responses.
  • Return correlation IDs in responses to aid troubleshooting.

Summary

  • Grant permission in SharePoint using Sites.Selected for least privilege and auditability.
  • Use managed identities or workload identity federation to remove secrets and simplify compliance.
  • Automate grants and access with .NET 8, Microsoft Graph SDK, and IaC for consistent, repeatable operations.

Friday, 16 January 2026

Implementing PnP People Picker in React for SPFx: A Ready-to-Use Example with Strict TypeScript and Zod

The primary keyword pnp people picker control in react for SPfx with example sets the scope: implement a production-grade People Picker in a SharePoint Framework (SPFx) web part using React, strict TypeScript, and Zod validation. Why this matters: avoid vague selections, respect tenant boundaries and theming, and ship a fast, accessible control that your security team can approve.

The Problem

Developers often wire up People Picker quickly, then face issues with invalid selections, poor performance in large tenants, theming mismatches, and missing API permissions. The goal is a robust People Picker that validates data, performs well, and aligns with SPFx and Microsoft 365 security constraints.

Prerequisites

  • Node.js v20+
  • SPFx v1.18+ (React and TypeScript template)
  • @pnp/spfx-controls-react v3.23.0+ (PeoplePicker)
  • TypeScript strict mode enabled ("strict": true)
  • Zod v3.23.8+ for schema validation
  • Tenant admin rights to approve Microsoft Graph permissions for the package

The Solution (Step-by-Step)

1) Install dependencies and pin versions

npm install @pnp/spfx-controls-react@3.23.0 zod@3.23.8

Recommendation: pin versions to prevent accidental breaking changes in builds.

2) Configure delegated permissions (least privilege)

In config/package-solution.json, request the minimum Graph scopes needed to resolve people:

{
  "solution": {
    "webApiPermissionRequests": [
      { "resource": "Microsoft Graph", "scope": "User.ReadBasic.All" },
      { "resource": "Microsoft Graph", "scope": "People.Read" }
    ]
  }
}

After packaging and deploying, a tenant admin must approve these scopes. These are delegated permissions tied to the current user; no secrets or app-only access are required for the People Picker scenario.

3) Implement a strict, validated People Picker component

/* PeoplePickerField.tsx */
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { PeoplePicker, PrincipalType } from "@pnp/spfx-controls-react/lib/PeoplePicker";
import { z } from "zod";

// Define the shape we accept from People Picker selections
// The control returns IPrincipal-like objects; we validate subset we rely upon.
const PersonSchema = z.object({
  id: z.union([z.string(), z.number()]), // Graph or SP ID can be number or string
  secondaryText: z.string().nullable().optional(), // usually email or subtitle
  text: z.string().min(1), // display name
});

const SelectedPeopleSchema = z.array(PersonSchema).max(5); // enforce cardinality

export type ValidPerson = z.infer<typeof PersonSchema>;

export interface PeoplePickerFieldProps {
  context: WebPartContext; // SPFx context to ensure tenant and theme alignment
  label?: string;
  required?: boolean;
  maxPeople?: number; // override default of 5
  onChange: (people: ValidPerson[]) => void; // emits validated data only
}

// Memoized to avoid unnecessary re-renders in large forms
const PeoplePickerField: React.FC<PeoplePickerFieldProps> = ({
  context,
  label = "Assign to",
  required = false,
  maxPeople = 5,
  onChange,
}) => {
  // Internal state to show validation feedback
  const [error, setError] = useState<string | null>(null);

  // Enforce hard cap
  const personSelectionLimit = useMemo(() => Math.min(Math.max(1, maxPeople), 25), [maxPeople]);

  // Convert PeoplePicker selections through Zod
  const handleChange = useCallback((items: unknown[]) => {
    // PeoplePicker sends unknown shape; validate strictly before emitting
    const parsed = SelectedPeopleSchema.safeParse(items);
    if (!parsed.success) {
      setError("Invalid selection. Please choose valid users only.");
      onChange([]);
      return;
    }

    // Optional business rule: ensure each user has an email-like secondaryText
    const withEmail = parsed.data.filter(p => (p.secondaryText ?? "").includes("@"));
    if (withEmail.length !== parsed.data.length) {
      setError("Some selections are missing a valid email.");
      onChange([]);
      return;
    }

    setError(null);
    onChange(parsed.data);
  }, [onChange]);

  return (
    <div>
      <label>{label}{required ? " *" : ""}</label>
      {/**
       * PeoplePicker respects SPFx theme through provided context.
       * Use PrincipalType to limit search to users only, avoiding groups for clarity.
       */}
      <PeoplePicker
        context={context}
        titleText={label}
        personSelectionLimit={personSelectionLimit}
        ensureUser={true} // resolves users to the site collection to avoid auth issues
        showHiddenInUI={false}
        principalTypes={[PrincipalType.User]}
        resolveDelay={300} // debounce for performance in large tenants
        onChange={handleChange}
        required={required}
      />

      {/** Live region for accessibility */}
      <div aria-live="polite">{error ? error : ""}</div>
    </div>
  );
};

export default React.memo(PeoplePickerField);

Notes: resolveDelay reduces repeated queries while typing. principalTypes avoids unnecessary group matches unless you require them.

4) Use the field in a web part with validated submit

/* MyWebPartComponent.tsx */
import * as React from "react";
import { useCallback, useState } from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import PeoplePickerField, { ValidPerson } from "./PeoplePickerField";

interface MyWebPartProps { context: WebPartContext; }

const MyWebPartComponent: React.FC<MyWebPartProps> = ({ context }) => {
  const [assignees, setAssignees] = useState<ValidPerson[]>([]);
  const [submitStatus, setSubmitStatus] = useState<"idle" | "saving" | "success" | "error">("idle");

  const handlePeopleChange = useCallback((people: ValidPerson[]) => setAssignees(people), []);

  const handleSubmit = useCallback(async () => {
    try {
      setSubmitStatus("saving");
      // Example: persist only the IDs or emails to a list/Graph to avoid storing PII redundantly
      const payload = assignees.map(p => ({ id: String(p.id), email: p.secondaryText }));
      // TODO: call a secure API (e.g., SPHttpClient to SharePoint list) using current user's context
      await new Promise(r => setTimeout(r, 600)); // simulate network
      setSubmitStatus("success");
    } catch {
      setSubmitStatus("error");
    }
  }, [assignees]);

  return (
    <div>
      <h3>Create Task</h3>
      <PeoplePickerField
        context={context}
        label="Assignees"
        required={true}
        maxPeople={3}
        onChange={handlePeopleChange}
      />
      <button onClick={handleSubmit} disabled={assignees.length === 0 || submitStatus === "saving"}>
        Save
      </button>
      <div aria-live="polite">{submitStatus === "saving" ? "Saving..." : ""}</div>
      <div aria-live="polite">{submitStatus === "success" ? "Saved" : ""}</div>
      <div aria-live="polite">{submitStatus === "error" ? "Save failed" : ""}</div>
    </div>
  );
};

export default MyWebPartComponent;

5) Authentication in SPFx context

SPFx provides delegated authentication via the current user. For Microsoft Graph calls, use MSGraphClientFactory; for SharePoint calls, use SPHttpClient. You do not need to store tokens; SPFx handles tokens and consent. Avoid manual token acquisition unless implementing advanced scenarios.

6) Minimal test to validate the component contract

// PeoplePickerField.test.tsx
import React from "react";
import { render } from "@testing-library/react";
import PeoplePickerField from "./PeoplePickerField";

// Mock SPFx WebPartContext minimally for the control; provide shape your test runner needs
const mockContext = {} as unknown as any; // In real tests, provide a proper mock of the context APIs used by the control

test("renders label and enforces required", () => {
  const { getByText } = render(
    <PeoplePickerField context={mockContext} label="Assignees" required onChange={() => {}} />
  );
  expect(getByText(/Assignees/)).toBeTruthy();
});

Note: In integration tests, mount within an SPFx test harness or mock the PeoplePicker dependency. For unit tests, focus on validation logic paths invoked by onChange.

Best Practices & Security

  • Least privilege permissions. Request only User.ReadBasic.All and People.Read for resolving users. Do not request write scopes unless necessary.
  • Azure RBAC and Microsoft 365 roles. This scenario uses delegated permissions within Microsoft 365; no Azure subscription RBAC role is required. Users need a valid SharePoint license and access to the site. Tenant admin must approve Graph scopes. For directory-read scenarios beyond basics, Directory Readers role may be required by policy.
  • PII hygiene. Persist only identifiers (e.g., user IDs or emails) rather than full profiles. Avoid logging personal data. Mask PII in telemetry.
  • Performance. Use resolveDelay to debounce search. Limit personSelectionLimit to a realistic value (e.g., 3–5). Memoize the field (React.memo) and callbacks (useCallback) to reduce re-renders in complex forms.
  • Accessibility. Provide aria-live regions for validation and submit status. Ensure color contrast via SPFx theming; the PeoplePicker uses SPFx theme tokens when context is provided.
  • Theming. Always pass the SPFx context to ensure the control inherits the current site theme.
  • Error resilience. Wrap parent forms with an error boundary to display a fallback UI if a child component throws.
  • Versioning. Pin dependency versions in package.json to avoid unexpected changes. Regularly update to the latest stable to receive security fixes.
  • No server-side tech references here. Entity Framework patterns such as AsNoTracking are not applicable in SPFx client-side code.

Example package.json pins

{
  "dependencies": {
    "@pnp/spfx-controls-react": "3.23.0",
    "zod": "3.23.8"
  }
}

Optional: Error boundary pattern

import React from "react";

class ErrorBoundary extends React.Component<{}, { hasError: boolean }> {
  constructor(props: {}) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError() { return { hasError: true }; }
  render() { return this.state.hasError ? <div>Something went wrong.</div> : this.props.children; }
}

export default ErrorBoundary;

Wrap your form: ErrorBoundary around MyWebPartComponent to ensure a graceful fallback.

Summary

  • Implemented a strict, validated People Picker for SPFx with React, Zod, and tenant-aware theming via context.
  • Applied least privilege delegated permissions with admin consent, clear performance tuning, and accessibility patterns.
  • Hardened production readiness through validation-first design, memoization, testing hooks, and pinned dependencies.