Naalya Handbook
Microsoft 365 Sync

The Graph Provider

The per-school MSAL client and the directory reads that the sync handlers depend on — built once, cached, and used by both login and sync.

Before any sync logic runs, you need a way to talk to Graph as a given school. That lives in providers/microsoft, and it's deliberately separate from the sync logic so login and sync can share it.

The client factory

A school's Microsoft app credentials — client id, client secret, tenant id — are stored on its SchoolConfig. The MicrosoftClientFactory reads those and builds an MSAL (Microsoft Authentication Library) confidential client for that school, caching it so we don't rebuild it on every call:

apps/server/src/app/providers/microsoft/microsoft-client.factory.ts
async getConfidentialClient(schoolId: string): Promise<CachedClient> {
  const cached = this.cache.get(schoolId);
  if (cached) return cached;

  const config = await runUnscoped(() =>
    this.schoolConfigRepository.findOneWhere({ schoolId }),
  );
  if (!config) throw new NotFoundException('School configuration not found');

  const { microsoftClientId, microsoftClientSecret, microsoftTenantId } = config;
  if (!microsoftClientId || !microsoftClientSecret || !microsoftTenantId) {
    throw new BadRequestException('This school has no Microsoft app configured');
  }
  // ...build + cache the MSAL ConfidentialClientApplication
}

Notice the runUnscoped wrapper. The short version is that this read happens before we have a tenant context, so it explicitly opts out of tenant scoping. The webhooks page and Multi-Tenancy both go deeper on why that matters.

The Graph client

MicrosoftGraphService wraps that MSAL client into an actual Graph API client. The key call is getGraphClient — it acquires an app-only token (no user signed in; the app acts on its own behalf) and hands back a ready-to-use client. Pass a schoolId to use that school's app; omit it to fall back to a global app for legacy subscriptions:

apps/server/src/app/providers/microsoft/microsoft-graph.service.ts
/** App-only Graph client. Uses the school's app when `schoolId` is given, else the global app. */
async getGraphClient(schoolId?: string): Promise<Client> {
  const msal = schoolId
    ? (await this.clientFactory.getConfidentialClient(schoolId)).client
    : this.globalClient;
  const tokenResponse = await msal.acquireTokenByClientCredential({
    scopes: ['https://graph.microsoft.com/.default'],
  });
  return Client.init({ authProvider: (done) => done(null, tokenResponse?.accessToken ?? '') });
}

The directory reads

The same service exposes the three reads the sync handlers depend on: getUserById (the full profile), getUserLicenses (to tell a student from staff), and a helper that maps license SKUs to a UserType.

One detail in getUserById is load-bearing for the whole delete story:

apps/server/src/app/providers/microsoft/microsoft-graph.service.ts
// Return null ONLY for 404 (user genuinely doesn't exist in the tenant).
// Other errors (network, 5xx, auth) must propagate so sync handlers
// don't mistake a transient failure for a deletion.
if (error?.statusCode === 404) return null;
throw error;

A null here means "this user is gone from the tenant" — and the handlers treat that as a delete. A 500 or a timeout must not look like a deletion, so everything except a clean 404 is re-thrown and lets BullMQ retry the job. The sync flow page shows how the handlers consume that null.

Student vs staff comes from the license, not a flag

resolveUserTypeFromLicenses checks each license's SKU part number against known Microsoft 365 Education SKUs — student SKUs map to STUDENT, faculty SKUs to STAFF, and when nothing matches it defaults to STAFF. There's no "is this a student?" field in Entra; the license assignment is the signal.

Where to go next

On this page