Naalya Handbook
Microsoft 365 Sync

Microsoft 365 Sync

How a Microsoft Graph change notification flows through a webhook and a BullMQ processor to keep each school's user directory in sync.

Naalya's schools run their identities in Microsoft 365 — Entra (formerly Azure AD) is the source of truth for who works and studies there. When someone is hired, transferred, or off-boarded, that change happens in the school's Microsoft tenant first. The job of this subsystem is to make the matching Naalya user follow along automatically: created, updated, restored, or retired without anyone touching the admin UI.

The mental model

The whole design is one short phrase: don't poll, subscribe. Rather than asking Graph "anything changed?" every few minutes, each school registers a Microsoft Graph change-notification subscription — a standing request that says "call this URL whenever a user changes." Graph then pushes a notification to a webhook the moment something happens.

The webhook does almost nothing itself: it authenticates the call and drops a job on a queue. A background processor does the real work — fetching the user from Graph, deciding whether to create, update, or retire the local record, and writing an audit row. That split matters because Graph is impatient: if your webhook is slow to answer, Graph gives up and may disable the subscription. Acknowledge fast, work later.

This is directory sync, not login

Signing in with Microsoft (the OAuth authorization-code flow) is a separate concern handled in Auth & Permissions. This page is about the directory staying in step. They share the same per-school Graph client, but the flows are independent — a user can exist in Naalya from directory sync before they ever log in.

Everything is school-scoped

Each school brings its own Microsoft app credentials, its own subscription, and its own secret. A notification that arrives at the shared webhook has to be routed back to the right school before any work runs, and once routed, every database read and write is pinned to that school's tenant. The provider page covers the per-school client, and the webhooks page covers the registry row that makes routing possible.

What gets synced

The subscription watches one Graph resource: users. That's the whole directory — every account in the school's tenant. The subscription is created watching three change types at once:

apps/server/src/app/microsoft-sync/microsoft-sync.service.ts
/** All subscriptions are created watching the same change types. */
const CHANGE_TYPES = 'created,updated,deleted';

When a user changes, Graph sends a notification carrying the change type and the affected user's id — but, crucially, not the user's data. A change notification is a doorbell, not a delivery. The processor still has to call Graph to fetch the current profile and licenses. That round trip is exactly why the work belongs on a queue and not in the webhook.

The shape of an incoming notification is the contract Graph promises us, defined in the shared library so both the webhook and the processor agree on it:

libs/shared/src/microsoft-sync/microsoft-sync.types.ts
export interface MicrosoftChangeNotification {
  changeType: 'created' | 'updated' | 'deleted';
  clientState: string;            // our per-school secret, echoed back
  resourceData: { id: string; /* ... */ };  // the Microsoft user id
  subscriptionId: string;         // the routing key
  tenantId: string;               // the school's Entra tenant
  // ...
}

Read these in order

The subsystem breaks into three moving parts. Read them in this order: the client that talks to Graph, the webhook and registry that route notifications, then the processor that does the work.

Where to go next

On this page