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:
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):
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:
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:
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:
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:
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:
createdandupdatedare the same intent. Graph is unreliable about which it sends, so the handlers converge.handleUserUpdatedfalls through tohandleUserCreatedwhen there's no local user yet (an upsert), andhandleUserCreateddedupes by email. A soft-deleted match means a delete-then-recreate cycle, so the original row is restored and itsmicrosoftSubrefreshed rather than colliding on the unique email constraint.- Deletes are conservative.
handleUserDeletedonly 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:
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:
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:
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:
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:
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
| Route | Operation | Auth |
|---|---|---|
POST /microsoft-sync/webhook | Receive a Graph notification (or the validation handshake) | @Public() — authenticated by clientState inside the handler |
POST /microsoft-sync/subscriptions | Create (or reuse) a school's subscription | Software Developer role |
GET /microsoft-sync/subscriptions | List a school's subscriptions | Software Developer role |
DELETE /microsoft-sync/subscriptions/:subscriptionId | Delete a subscription + its renewal job | Software 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
?validationTokenhandshake must echo the token back astext/plainwith 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.tsalongside 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
SchoolConfigwith ambient scoping in this flow. The webhook has no tenant context. The subscription lookup is unscoped by design (the entity isn't@TenantScoped), and theSchoolConfigread is wrapped inrunUnscoped. Add a normal scoped query in the routing path and it'll quietly match zero rows. -
Don't trust the change type.
created,updated, anddeletedare routed to different handlers, but the handlers cross-call each other because Graph mislabels them constantly — anupdatedfor a brand-new user, anupdatedfor a removed one. The handlers are written to converge regardless; if you add logic, keep that tolerance.
Where to go next
Webhooks & Subscriptions
Where the notification comes from and the registry row resolveNotificationSchool reads.
The Graph Provider
The getUserById / getUserLicenses reads the handlers call inside withSchool.
Auditing
How the DIRECTORY_SYNC_* rows are written and read.
Multi-Tenancy
What withSchool, runUnscoped, and @TenantScoped actually do.
Webhooks & Subscriptions
The public webhook that receives Graph notifications, the registry entity that routes them to a school, and the chain that self-renews subscriptions.
Notifications
What a notification is, what happens from the moment one is triggered, and the vocabulary you need before you add one.