Naalya Handbook
Microsoft 365 Sync

The Sync Flow

How a notification is authenticated, queued, and processed into a created, restored, or retired user — plus operating the pipeline and the gotchas.

This page traces a single change from Entra to a database row: the service that authenticates a notification before queueing it, the processor that consumes the job, the handlers that do the real work, and how to turn the whole thing on, watch it, and turn it off.

Authenticate before enqueueing

enqueueNotification is where a doorbell becomes a queued job — but only after three independent checks pass. First it maps the change type to an internal action and pulls the Microsoft user id off the notification, then it resolves and authenticates the school:

apps/server/src/app/microsoft-sync/microsoft-sync.service.ts
async enqueueNotification(notification: MicrosoftChangeNotification): Promise<void> {
  const action = CHANGE_TYPE_TO_ACTION[notification.changeType];
  if (!action) return;                                  // unknown change type — skip

  const microsoftUserId = notification.resourceData?.id;
  if (!microsoftUserId) return;                          // malformed — skip

  const schoolId = await this.resolveNotificationSchool(notification);
  if (!schoolId) return;                                 // failed auth — skip

  await this.syncQueue
    .add(notification.changeType, { action, microsoftUserId, schoolId })
    .catch((err) => this.logger.error('Failed to enqueue sync job', err));
}

resolveNotificationSchool

resolveNotificationSchool is the security boundary. It applies three layers, and any failure returns null (logged, skipped, never enqueued):

apps/server/src/app/microsoft-sync/microsoft-sync.service.ts
private async resolveNotificationSchool(notification): Promise<string | null> {
  // (1) the subscription must be in our registry
  const row = await this.subscriptionRepository.findBySubscriptionId(notification.subscriptionId);
  if (!row) return null;

  // (2) the clientState secret must match — timing-safe
  if (!this.secretsMatch(notification.clientState, row.clientStateSecret)) return null;

  // (3) the Graph tenantId must match the school's configured tenant
  const config = await runUnscoped(() =>
    this.schoolConfigRepository.findOneWhere({ schoolId: row.schoolId }),
  );
  if (notification.tenantId && config?.microsoftTenantId &&
      notification.tenantId !== config.microsoftTenantId) return null;

  return row.schoolId;
}

The clientState comparison uses timingSafeEqual, not ===. A naive string compare returns faster when the first characters differ, which leaks information an attacker can use to guess the secret one character at a time. A constant-time compare closes that side channel:

apps/server/src/app/microsoft-sync/microsoft-sync.service.ts
private secretsMatch(received: string, expected: string): boolean {
  const a = Buffer.from(received ?? '');
  const b = Buffer.from(expected ?? '');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

runUnscoped is not optional here

The SchoolConfig lookup is wrapped in runUnscoped because the webhook has no ambient tenant context — we're still in the middle of figuring out the school. A normal scoped read would add school_id = :ctx to the query, find nothing, and silently break routing for every notification. Until the school is known, reads are explicitly unscoped; after it's known, they're explicitly pinned with withSchool.

The processor

The job is now on the microsoft-sync queue. Here is the part that surprises people: the processor that consumes it runs inside the server app, not the worker app. Most background jobs in this codebase live in the dedicated worker (see Queues & Messaging), but this processor depends on so many server modules — UserService, CampusRepository, AuditLogService, the Graph provider — that co-locating it with the server is simpler than re-importing all of that into the worker. The module registers the queue, the Bull Board adapter, and the processor together:

apps/server/src/app/microsoft-sync/microsoft-sync.module.ts
imports: [
  BullModule.registerQueue({ name: MICROSOFT_SYNC_QUEUE }),
  BullBoardModule.forFeature({ name: MICROSOFT_SYNC_QUEUE, adapter: BullMQAdapter }),
  UserModule, AuditLogModule, CampusModule, RoleModule,
],
providers: [MicrosoftSyncService, MicrosoftSyncProcessor, /* ... */],

The processor itself is a thin dispatcher — a switch on the action that calls the matching handler:

apps/server/src/app/microsoft-sync/microsoft-sync.processor.ts
async process(job: Job<MicrosoftSyncJobPayload>): Promise<void> {
  const { action, microsoftUserId, subscriptionId, schoolId } = job.data;
  switch (action) {
    case MicrosoftSyncAction.USER_CREATED:
      if (schoolId) await this.syncService.handleUserCreated(microsoftUserId, schoolId);
      break;
    case MicrosoftSyncAction.USER_UPDATED:
      if (schoolId) await this.syncService.handleUserUpdated(microsoftUserId, schoolId);
      break;
    case MicrosoftSyncAction.USER_DELETED:
      await this.syncService.handleUserDeleted(microsoftUserId);
      break;
    case MicrosoftSyncAction.RENEW_SUBSCRIPTION:
      if (subscriptionId) await this.syncService.renewSubscription(subscriptionId, schoolId);
      break;
  }
}

The create handler

Each handler does the real work, and it does it inside withSchool(schoolId, …). The processor has no request behind it and therefore no tenant context, so the handler establishes one explicitly — that's how the campus lookup, the role assignment, and the new user all land in the right tenant:

apps/server/src/app/microsoft-sync/microsoft-sync.service.ts
async handleUserCreated(microsoftUserId: string, schoolId: string): Promise<void> {
  return withSchool(schoolId, async () => {
    const profile = await this.graphService.getUserById(microsoftUserId, schoolId);
    if (!profile) return;                                  // gone from tenant — nothing to create

    const existing = await this.userService.findByEmailWithDeleted(profile.mail);
    if (existing && !existing.deletedAt) return;           // already active — skip

    const licenses = await this.graphService.getUserLicenses(profile.mail, schoolId);
    const type = this.graphService.resolveUserTypeFromLicenses(licenses);
    const campus = await this.campusRepository.findByLooseName(/* office/department hint */);

    if (existing) {
      // Soft-deleted match → restore the original row instead of colliding on
      // the unique email, and refresh microsoftSub (Microsoft mints a fresh
      // GUID when a tenant user is re-created).
      await this.userService.restoreUser(existing.id, microsoftUserId);
      await this.userService.updateUserProfile(existing.id, existing.type, {
        firstName: profile.givenName, lastName: profile.surname,
      });
    } else {
      await this.userService.createMicrosoftUser({
        email: profile.mail, microsoftSub: microsoftUserId, type,
        campusId: campus?.id, schoolId, /* + names */
      });
    }
    // Staff then get assignDefaultStaffRole (a no-op if already assigned, so
    // it's safe for both paths), and finally a DIRECTORY_SYNC_* audit row.
  });
}

Restore vs retire

Two design rules in the handlers are worth internalizing:

  • created and updated are the same intent. Graph is unreliable about which it sends, so the handlers converge. handleUserUpdated falls through to handleUserCreated when there's no local user yet (an upsert), and handleUserCreated dedupes by email. A soft-deleted match means a delete-then-recreate cycle, so the original row is restored and its microsoftSub refreshed rather than colliding on the unique email constraint.
  • Deletes are conservative. handleUserDeleted only soft-deletes a user who has never logged in. Once someone has signed in they may own drafts, audit history, and attribution we don't want to lose silently — so the delete is denied and the skip is itself audited:
apps/server/src/app/microsoft-sync/microsoft-sync.service.ts
if (user.lastLoginAt) {
  // preserve the record; audit a denied delete
  this.auditLogService.log({ /* ... */ action: ActionAuditAction.DIRECTORY_SYNC_DELETE,
    decision: 'deny', reason: 'User has logged in; preserving local record' });
  return;
}
await this.userService.softDeleteUser(user.id);

Every handler writes a DIRECTORY_SYNC_* audit row attributed to the affected user, not an operator — because no operator triggered it. See Auditing for how those rows are read.

An 'updated' notification can really mean 'deleted'

Admin removals in Entra frequently arrive as updated notifications where the Graph user is already a 404. handleUserUpdated catches the null from getUserById and routes through handleUserDeleted so the never-logged-in policy still applies. Never assume deleted is the only path to a deletion.

Operating it

Turning sync on, watching it, and turning it off are all done through the /api/v1/microsoft-sync endpoints, gated to the Software Developer system role.

Step 1: Confirm Microsoft credentials

A subscription can't be created for a school that has no Microsoft app configured — getConfidentialClient throws a BadRequestException if the client id, secret, or tenant id is missing on SchoolConfig. Confirm those three values exist before going further. (Wiring them up is part of school configuration; see Config & Environment for where settings live.)

Step 2: Create the subscription

POST to subscriptions with the school id. Creation is idempotent — if an unexpired registry row already exists for the school, the existing subscription is returned instead of creating a duplicate:

terminal
curl -X POST https://<host>/api/v1/microsoft-sync/subscriptions \
  -H "Authorization: Bearer <software-developer-token>" \
  -H "Content-Type: application/json" \
  -d '{ "schoolId": "7f105c7d-3142-436d-a875-919c3836c7e2" }'

Under the hood this builds the school's Graph client, generates a fresh clientState secret, POSTs to Graph's /subscriptions with the webhook URL and a 29-day expiry, persists the registry row, and schedules the first renewal. Graph's validation handshake fires against your webhook during this call — which is why the webhook must be publicly reachable for creation to succeed.

Step 3: Watch it work

Sync jobs flow through Bull Board at /queues, the same dashboard every queue uses. Look for the renew-{subscriptionId} job in the delayed set — that's your proof the renewal chain is alive. You can also list a school's registered subscriptions:

terminal
curl "https://<host>/api/v1/microsoft-sync/subscriptions?schoolId=<schoolId>" \
  -H "Authorization: Bearer <software-developer-token>"

Each response carries the subscription's expirationDateTime and a status of active, expired, or not_configured.

Step 4: Trigger a sync manually

You don't need an Entra admin to exercise the pipeline. POST a notification batch to the webhook yourself, using a real subscriptionId and the school's stored clientState secret so it passes authentication:

terminal
curl -X POST https://<host>/api/v1/microsoft-sync/webhook \
  -H "Content-Type: application/json" \
  -d '{ "value": [ {
        "changeType": "updated",
        "subscriptionId": "<registered-subscription-id>",
        "clientState": "<that-subscription-s-secret>",
        "tenantId": "<school-tenant-id>",
        "resourceData": { "id": "<microsoft-user-id>" }
      } ] }'

If everything authenticates, a job lands on the queue and you'll see the resulting DIRECTORY_SYNC_UPDATE (or create/delete) audit row appear. A wrong clientState produces a 202 with nothing enqueued — exactly as a forged notification would.

Step 5: Turn it off

Deleting removes the Graph subscription (using the owning school's app), drops the registry row, and cancels the pending renewal job by its deterministic id:

terminal
curl -X DELETE https://<host>/api/v1/microsoft-sync/subscriptions/<subscriptionId> \
  -H "Authorization: Bearer <software-developer-token>"

This writes a DIRECTORY_SYNC_UNSUBSCRIBE audit row attributed to the operator who did it — the one sync action a human actually triggers.

The API surface

RouteOperationAuth
POST /microsoft-sync/webhookReceive a Graph notification (or the validation handshake)@Public() — authenticated by clientState inside the handler
POST /microsoft-sync/subscriptionsCreate (or reuse) a school's subscriptionSoftware Developer role
GET /microsoft-sync/subscriptionsList a school's subscriptionsSoftware Developer role
DELETE /microsoft-sync/subscriptions/:subscriptionIdDelete a subscription + its renewal jobSoftware Developer role

Gotchas

  • The webhook always answers fast, and always succeeds. It returns 202 for every batch, even when enqueueing fails — Graph must never be made to retry the HTTP call, or it'll throttle and eventually disable the subscription. Enqueue errors are logged; BullMQ's own retries (the global config sets attempts: 3) cover the actual job. Don't add error handling that makes the webhook return a 5xx.

  • Validation has a ten-second deadline. The ?validationToken handshake must echo the token back as text/plain with a 200, fast. If your webhook URL isn't public, or it's behind slow middleware, creation itself fails — not just notifications. Test reachability before debugging the sync logic.

  • The processor runs in the server, not the worker. If you go looking for it in the worker app you won't find it. It's registered in microsoft-sync.module.ts alongside the queue because it leans on so many server modules. Treat this as the documented exception to "processors live in the worker," not a pattern to copy.

  • Never read the registry or SchoolConfig with ambient scoping in this flow. The webhook has no tenant context. The subscription lookup is unscoped by design (the entity isn't @TenantScoped), and the SchoolConfig read is wrapped in runUnscoped. Add a normal scoped query in the routing path and it'll quietly match zero rows.

  • Don't trust the change type. created, updated, and deleted are routed to different handlers, but the handlers cross-call each other because Graph mislabels them constantly — an updated for a brand-new user, an updated for a removed one. The handlers are written to converge regardless; if you add logic, keep that tolerance.

Where to go next

On this page