SMBs struggle to approve or deny access requests in Microsoft Entra ID quickly while ensuring sensitive data isn't exposed and that requests comply with internal policies.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks you through building an automated access-request guardrail system using Azure AI and Microsoft Entra ID. You’ll create a Next.js API that receives Entra ID access requests, runs them through a PII redaction layer, evaluates them with Azure OpenAI, checks confidence with a routing engine, and automatically provisions roles via Microsoft Graph API — all traced through an observability layer. It’s designed for SMB deployments where a small IT team needs to approve or deny access requests quickly without exposing sensitive data.
Prerequisites
Node.js >= 22 and pnpm >= 10 installed
An Azure OpenAI endpoint, API key, and deployment name
A Microsoft Entra ID tenant with an app registration (client ID and secret) that has permission to read users and assign directory roles
A shared secret for webhook authentication (you pick one)
Step 1: Scaffold the Next.js project
Create a new Next.js 16 project with the App Router and install all dependencies.
Then re-export everything from a barrel file at src/types/index.ts:
ts
export type { AccessRequest } from "./access-request.js";export type { AiEvaluation } from "./ai-evaluation.js";export type { UserDetails } from "./user-details.js";export type { ApprovalResult } from "./approval.js";export type { FullFlowResult } from "./full-flow.js";export type { GuardrailChainInput, GuardrailChainOutput } from "./guardrail-types.js";
Step 4: Build configuration loaders with Zod
Use Zod schemas to validate environment variables at startup. Create two config modules.
src/config/azure-config.ts:
ts
import { z } from "zod";const AzureConfigSchema = z.object({ endpoint: z.url(), apiKey: z.string().min(1), deployment: z.string().min(1),});export type AzureConfig = z.infer<typeof AzureConfigSchema>;export function getAzureConfig(): AzureConfig { return AzureConfigSchema.parse({ endpoint: process.env.AZURE_OPENAI_ENDPOINT, apiKey: process.env.AZURE_OPENAI_API_KEY, deployment: process.env.AZURE_OPENAI_DEPLOYMENT, });}export function validateAzureConfig(config: unknown): AzureConfig { return AzureConfigSchema.parse(config);}
src/config/graph-config.ts:
ts
import { z } from "zod";const GraphConfigSchema = z.object({ tenantId: z.string().min(1), clientId: z.string().min(1), clientSecret: z.string().min(1),});export type GraphConfig = z.infer<typeof GraphConfigSchema>;export function getGraphConfig(): GraphConfig { return GraphConfigSchema.parse({ tenantId: process.env.MICROSOFT_GRAPH_TENANT_ID, clientId: process.env.MICROSOFT_GRAPH_CLIENT_ID, clientSecret: process.env.MICROSOFT_GRAPH_CLIENT_SECRET, });}export function validateGraphConfig(config: unknown): GraphConfig { return GraphConfigSchema.parse(config);}
src/config/guardrail-config.ts — loads the guardrail chain configuration, falling back to defaults:
The engine runs three checks on every message: prompt injection detection (heuristic mode, threshold 0.7), PII scanning for all entity types, and secret scanning for all types. If any guard fails, hasPii is true and the findings array lists what was detected.
Step 6: Build the Azure AI reasoning service
This service calls Azure OpenAI (via the openai SDK configured with an Azure base URL) to evaluate whether an access request should be approved. Create src/services/ai-service.ts:
ts
import "@azure/openai";import OpenAI from "openai";import type { AiEvaluation } from "../types/ai-evaluation.js";import type { AccessRequest } from "../types/access-request.js";import type { AzureConfig } from "../config/azure-config.js";export class AiReasoningService { private client: OpenAI; private deployment: string; constructor(config: AzureConfig) { this.client = new OpenAI({ apiKey: config.apiKey, baseURL: `${config.endpoint}/openai/deployments/${config.deployment}`, defaultQuery: { "api-version": "2024-10-21" }, defaultHeaders: { "api-key": config.apiKey }, }); this.deployment = config.deployment; } async evaluateAccessRequest(request: AccessRequest): Promise<AiEvaluation> { const systemPrompt = `You are an AI access-control evaluator. Analyze the following Microsoft Entra ID access request and determine if it should be approved.Rules:- Approve only if the user's justification is reasonable and the requested role matches their job function.- Reject if the request appears suspicious, the justification is weak, or the role is too privileged.- Return a JSON object with: { "approved": boolean, "confidence": number (0-1), "reasoning": string, "suggestedRole"?: string }`; const userPrompt = `Access Request:- User ID: ${request.userId}- Requested Role: ${request.requestedRole}- Justification: ${request.justification}- Requester Email: ${request.requesterEmail}- Timestamp: ${request.timestamp}`; const response = await this.client.chat.completions.create({ model: this.deployment, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ], response_format: { type: "json_object" }, }); const content = response.choices[0]?.message?.content ?? "{}"; let evaluation: AiEvaluation; try { evaluation = JSON.parse(content) as AiEvaluation; } catch { return { approved: false, confidence: 0, reasoning: "Failed to parse AI response" }; } return { approved: evaluation.approved, confidence: typeof evaluation.confidence === "number" ? evaluation.confidence : 0, reasoning: evaluation.reasoning, suggestedRole: evaluation.suggestedRole, }; }}
The client is configured with the Azure-specific base URL format (/openai/deployments/{name}) and the api-key header. The response format is forced to JSON objects so you can parse structured reasoning out of the model output.
Step 7: Build the Microsoft Graph API service
This service acquires an OAuth2 client-credentials token and calls Microsoft Graph to read user details and assign roles. Create src/services/graph-service.ts:
ts
import "isomorphic-fetch";import { Client } from "@microsoft/microsoft-graph-client";import type { UserDetails } from "../types/user-details.js";import type { GraphConfig } from "../config/graph-config.js";export async function acquireToken(config: GraphConfig): Promise<string> { const url = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`; const params = new URLSearchParams({ client_id: config.clientId, client_secret: config.clientSecret, scope: "https://graph.microsoft.com/.default", grant_type: "client_credentials", }); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params, }); if (!response.ok) { throw new Error(`Token acquisition failed: ${String(response.status)} ${await response.text()}`); } const data = await response.json() as { access_token: string }; return data.access_token;}export class GraphService { private client: Client; constructor(config: GraphConfig) { this.client = Client.initWithMiddleware({ authProvider: { getAccessToken: async () => acquireToken(config), }, }); } async getUserDetails(userId: string): Promise<UserDetails> { if (!userId) { throw new Error("userId must not be empty"); } const result = await this.client.api(`/users/${userId}`).get() as UserDetails & { mail?: string }; return { id: result.id, displayName: result.displayName, mail: result.mail, userPrincipalName: result.userPrincipalName, }; } async assignRole(userId: string, roleId: string): Promise<void> { if (!userId || !roleId) { throw new Error("userId and roleId must not be empty"); } await this.client.api(`/users/${userId}/memberOf`).post({ "@odata.id": `https://graph.microsoft.com/v1.0/directoryRoles/${roleId}`, }); } getPendingAccessRequests(): Promise<Array<{ id: string; userId: string; requestedRole: string; justification: string; requesterEmail: string; timestamp: string }>> { return Promise.resolve([]); }}
acquireToken() calls the Microsoft identity platform OAuth2 token endpoint with client_credentials grant. The GraphService passes an authProvider callback to the Graph client so every API call automatically refreshes the token.
Step 8: Build the confidence router and approval store
The confidence router uses @reaatech/confidence-router-core to decide whether a request can be auto-approved or needs manual review. The approval store keeps results in an LRU cache.
import { LRUCache } from "@reaatech/guardrail-chain";import type { FullFlowResult } from "../types/full-flow.js";export class ApprovalStore { private cache: LRUCache<string, FullFlowResult>; private recentKeys: string[]; constructor() { this.cache = new LRUCache<string, FullFlowResult>({ maxSize: 1000, ttlMs: 3600_000 }); this.recentKeys = []; } store(requestId: string, result: FullFlowResult): void { this.cache.set(requestId, result); this.recentKeys.unshift(requestId); if (this.recentKeys.length > 1000) { this.recentKeys.pop(); } } get(requestId: string): FullFlowResult | undefined { return this.cache.get(requestId); } listRecent(limit = 10): FullFlowResult[] { const results: FullFlowResult[] = []; for (const key of this.recentKeys) { const item = this.cache.get(key); if (item) { results.push(item); if (results.length >= limit) break; } } return results; }}
The router maps the AI evaluation’s confidence score into a RoutingDecision — if the difference between top predictions is high enough, it returns { type: "ROUTE" } (auto-approve/reject); otherwise { type: "CLARIFY" } (manual review). The store keeps up to 1,000 results with a 1-hour TTL.
Step 9: Build the guardrail chain service (orchestrator)
This is the core orchestrator. It builds the guardrail chain, runs PII detection, calls Azure AI, routes the decision, and optionally provisions the role. Create src/services/chain-service.ts:
ts
import { ChainBuilder } from "@reaatech/guardrail-chain";import { PIIRedaction, PromptInjection, ToxicityFilter, RateLimiter } from "@reaatech/guardrail-chain-guardrails";import { initObservability } from "../lib/observability-init.js";import { PiiService } from "./pii-service.js";import { AiReasoningService } from "./ai-service.js";import { RoutingService } from "./routing-service.js";import { GraphService } from "./graph-service.js";import { ApprovalStore } from "./approval-store.js";import type { AccessRequest } from "../types/access-request.js";import type { FullFlowResult } from "../types/full-flow.js";import
processFullFlow() is the main pipeline: redact PII -> run guardrail chain -> evaluate with Azure AI -> route the decision -> optionally assign the role via Graph API -> store the result. If Graph API throws during role assignment, the request is marked rejected with the error in the reasoning.
Create the barrel file at src/services/index.ts:
ts
export { PiiService } from "./pii-service.js";export { AiReasoningService } from "./ai-service.js";export { GraphService } from "./graph-service.js";export { RoutingService } from "./routing-service.js";export { ApprovalStore } from "./approval-store.js";export { GuardrailChainService } from "./chain-service.js";
Step 10: Set up observability
The observability layer adapts @reaatech/guardrail-chain-observability to Pino for logging, an in-memory metrics collector, and a lightweight tracer.
src/lib/observability-init.ts — wires the adapters into the observability globals:
ts
import { setLogger, setMetrics, setTracer } from "@reaatech/guardrail-chain-observability";import { createPinoLogger, createMetricsCollector, createTracer } from "./observability.js";export function initObservability(): void { setLogger(createPinoLogger()); setMetrics(createMetricsCollector()); setTracer(createTracer());}
src/instrumentation.ts — Next.js calls register() at server startup. Dynamic import() is needed because initObservability pulls in Node-only packages (pino, crypto):
ts
export async function register(): Promise<void> { if (process.env.NEXT_RUNTIME === "nodejs") { const { initObservability } = await import("./lib/observability-init.js"); initObservability(); }}
Without experimental.instrumentationHook: true, the register() function in instrumentation.ts is dead code — this flag is mandatory.
Step 11: Create the main webhook route handler
This is the primary API endpoint that receives Entra ID access requests. It validates the webhook secret, parses the body with Zod, and kicks off the full guardrail pipeline.
The POST handler authenticates via the x-webhook-secret header, validates the payload with Zod, then calls processFullFlow. The GET handler lets you poll the result of a previous request by requestId.
Step 12: Create the manual review route handler
Requests that the confidence router flags as uncertain end up needing a manual decision. The review endpoint lets an admin approve or reject them, and on approval it triggers the Graph API role assignment.
The review handler loads the existing request from the store, validates the decision payload, conditionally provisions the role on approval, and writes back the updated result.
Step 13: Run the tests
The project ships with a comprehensive Vitest test suite. Run it to verify everything works end-to-end with mocked externals.
terminal
pnpm typecheckpnpm lintpnpm test
Expected output: TypeScript type-checking passes with zero errors. ESLint passes. Vitest reports:
Expected output: Next.js starts on http://localhost:3000. The instrumentation logs “info” messages from Pino showing that observability initialized. The API is ready to receive webhook POSTs at http://localhost:3000/api/approvals.
Next steps
Add Slack notifications — wire processFullFlow results to a Slack webhook so the IT team gets a message when a request needs manual review
Persist the approval store — replace the in-memory LRU cache with PostgreSQL or Redis so results survive server restarts
Add a dashboard — build a Next.js page that calls GET /api/approvals?requestId=... or a new GET /api/approvals/recent endpoint and displays pending manual reviews in a queue
Extend guardrail rules — add a custom guardrail via @reaatech/guardrail-chain that checks requests against a YAML policy file for role-scope compliance
type
{ ApprovalResult }
from
"../types/approval.js"
;
import type { AzureConfig } from "../config/azure-config.js";
import type { GraphConfig } from "../config/graph-config.js";