LMS Roster Sync
Amal can import and keep in sync the student and teacher roster from your school’s learning management system. Two providers are supported in Wave 1: Google Classroom (OAuth-based, live token) and OneRoster (CSV file set upload, no live credential). Both providers share the same preview-then-commit review workflow.
All write operations on this surface (creating a connection, uploading a file set, saving mappings, running a preview, committing, or triggering a sync) are blocked on demo tenants. Read-only operations (listing connections, browsing courses, viewing sync history) work normally on demo tenants.
Two connection types
Google Classroom (OAuth)
A Google Classroom connection is established through a standard OAuth 2.0 consent flow:
- Call
POST /api/lms/connections/google_classroom/oauth-start. The response carries anauthUrlfield pointing to Google’s consent screen. - Redirect the admin user to that URL. After they authorise, Google redirects back to
GET /api/lms/connections/google_classroom/oauth-callback?code=...&state=.... - The callback route exchanges the authorisation code, stores the access and refresh tokens, and returns a
200 LmsConnectionJSON body. There is no HTTP redirect from the callback; the client handles navigation after reading the response body.
The connection is upserted on the natural key (organizationId, provider, scope, createdByUserId), so
re-authorising an expired credential replaces tokens in place without creating a duplicate connection.
OneRoster (CSV file set)
A OneRoster connection requires no OAuth. Upload your OneRoster 1.1 CSV file set to
POST /api/lms/connections/oneroster/upload as a JSON body with a files map:
{
"files": {
"classes.csv": "<csv text>",
"users.csv": "<csv text>",
"enrollments.csv": "<csv text>"
}
}The server stores the CSV content on the connection record. Preview and commit re-parse it locally without
requiring re-upload. A successful upload returns 201 LmsConnection. Re-uploading a new file set replaces
the stored content and clears the course cache for that connection.
Permission split
Two permissions gate the LMS surface. They have different levels of trust because connection management and course configuration do not write any People rows, while preview and commit do.
| Permission | Gates |
|---|---|
MANAGE_LMS_CONNECTIONS | List connections; OAuth start; OAuth callback; OneRoster upload; delete connection; list courses; save mappings; list sync runs |
MANAGE_ROSTER | Build preview; patch preview rows; commit; trigger sync-now |
An admin who holds MANAGE_LMS_CONNECTIONS but not MANAGE_ROSTER can configure and map a connection but
cannot initiate any operation that creates or updates user accounts. Grant MANAGE_ROSTER only to admins
who are authorised to provision accounts.
Course discovery and mapping
Once a connection is active, retrieve its available courses with
GET /api/lms/{provider}/courses. Results are served from a 5-minute in-process cache per connection.
Pass ?cache=bypass to force a fresh fetch from the provider (useful immediately after a roster change).
The response includes a cached: boolean field so callers can tell which path was taken.
Map one or more provider courses to Amal classes with POST /api/lms/{provider}/mappings. Provide a
connectionId and an array of { externalCourseId, amalClassId } pairs. Every amalClassId must belong
to the caller’s organisation; a cross-org class ID produces a 422. Mappings upsert on the
(organizationId, providerCourseId) unique key, so re-mapping a course to a different class replaces the
prior mapping.
Preview and commit
Provisioning is a two-step process. Nothing is written to user accounts until you explicitly commit.
Step 1: Build a preview
POST /api/lms/preview reads the external roster for the connection’s mapped courses and produces a
read-only diff. It writes nothing to User, StudentProfile, TeacherProfile, ClassStudent,
ClassTeacher, or ParentStudent. The response is 200 LmsPreviewResponse:
{
"previewId": "<cuid>",
"expiresAt": "<ISO-8601>",
"summary": { "create": 4, "update": 12, "conflict": 2, "skip": 0 },
"rows": [ ... ]
}The preview has a 24-hour TTL. After expiry, commit and patch-row calls return 410 Preview expired.
Build a new preview to start again.
Each row carries an action field with one of four values:
| Action | Meaning |
|---|---|
create | No matching Amal user found; commit will create a new account |
update | Matched an existing Amal user with no diverging fields; commit will update class membership |
conflict | Matched an existing user but the LMS data diverges from Amal’s (name or grade level); must be resolved before commit |
skip | Row is intentionally excluded from the diff (e.g. a duplicate sub-identity) |
Identity matching follows a priority chain: first by provider external-identity link (provider, externalSub),
then by exact email match within the same organisation. There is no fuzzy auto-merge.
Step 2: Resolve conflicts
Rows with action = conflict must have a resolution set before you can commit. Use
PATCH /api/lms/preview/{previewId}/rows/{rowId} with a body of { "resolution": "<value>" }.
Two resolution values are accepted:
| Value | Effect at commit |
|---|---|
accept_lms | Overwrite the Amal field with the LMS value |
keep_amal | Keep the Amal field; update only the class membership |
Any other value produces a Zod validation error. Rows you do not want to import at all should be left
unresolved; commit rejects with 422 Unresolved conflict rows until every conflict row has a resolution.
Step 3: Commit
POST /api/lms/commit/{previewId} provisions the entire reviewed roster in a single Postgres
transaction: all creates, updates, and resolved-conflict rows apply atomically or none do. A successful
commit returns 200 LmsCommitResponse with counts of rows created, updated, and skipped.
Commit is idempotent: if the same preview has already been committed, the response returns
{ alreadyCommitted: true } with no further writes.
Automatic sync (autoApplySimpleChanges)
Each connection has an autoApplySimpleChanges flag. When the cron or a manual sync-now runs, the flag
controls what happens after the diff is built:
- Flag OFF (default): the preview is always queued for admin review, regardless of what the diff
contains.
LmsSyncRun.statusis set topending_admin_review. No People rows are written automatically. - Flag ON, no conflict rows: the diff is committed automatically in one transaction.
LmsSyncRun.statusis set tosuccess. - Flag ON, conflict rows present: the system cannot auto-resolve a conflict, so the preview is queued
for admin review exactly as if the flag were OFF.
LmsSyncRun.statusis set topending_admin_review.
In all cases lastSyncAt is updated on the connection after the sync run. lastSuccessfulSyncAt is
updated only when an auto-commit succeeds.
Manual sync-now
To trigger an immediate sync outside the cron schedule, call
POST /api/lms/connections/{id}/sync-now. The route returns 202 with a body of
{ syncRunId, status } where status is one of completed, pending_admin_review, or error. The
connection must be active; a connection in any other status returns 404.
Token revocation
When the provider returns an HTTP 401 or 403 during any adapter call (course fetch or roster fetch), the sync service treats it as a token revocation. The following happens atomically:
LmsConnection.connectionStatusis set torevoked.LmsConnection.syncCronEnabledis set tofalse(cron is disabled).- An
lms_connection_revokednotification is dispatched to every active admin in the organisation via the notification system. - A
LmsSyncRunrow is appended withstatus = erroranderrorMessage = token_revoked.
Re-connecting requires the admin to run the OAuth flow (or re-upload the CSV file set for OneRoster) to
restore the connection to active status and re-enable the cron.
Sync history
GET /api/lms/sync-runs returns paginated LmsSyncRun records for the caller’s organisation, newest
first. Use ?page= and ?limit= (max 100) to page. Each run records whether it was triggered by the
cron or manually, whether it was auto-applied, start/finish times, row counts, and an error message when
applicable.
API reference
All routes require an authenticated session. organizationId is always derived from the session context
and never accepted in request bodies or path parameters.
| Method | Path | Permission | Demo | Returns |
|---|---|---|---|---|
GET | /api/lms/connections | MANAGE_LMS_CONNECTIONS | allowed | 200 LmsConnectionListResponse |
POST | /api/lms/connections/{provider}/oauth-start | MANAGE_LMS_CONNECTIONS | blocked | 200 LmsOAuthStartResponse |
GET | /api/lms/connections/{provider}/oauth-callback | MANAGE_LMS_CONNECTIONS | blocked | 200 LmsConnection |
POST | /api/lms/connections/oneroster/upload | MANAGE_LMS_CONNECTIONS | blocked | 201 LmsConnection |
DELETE | /api/lms/connections/{id} | MANAGE_LMS_CONNECTIONS | blocked | 200 { ok: true } |
GET | /api/lms/{provider}/courses | MANAGE_LMS_CONNECTIONS | allowed | 200 LmsCoursesResponse |
POST | /api/lms/{provider}/mappings | MANAGE_LMS_CONNECTIONS | blocked | 200 LmsMappingResponse |
POST | /api/lms/preview | MANAGE_ROSTER | blocked | 200 LmsPreviewResponse |
PATCH | /api/lms/preview/{previewId}/rows/{rowId} | MANAGE_ROSTER | blocked | 200 LmsPreviewResponse |
POST | /api/lms/commit/{previewId} | MANAGE_ROSTER | blocked | 200 LmsCommitResponse |
POST | /api/lms/connections/{id}/sync-now | MANAGE_ROSTER | blocked | 202 LmsSyncNowResponse |
GET | /api/lms/sync-runs | MANAGE_LMS_CONNECTIONS | allowed | 200 LmsSyncRunsResponse |
For full schema definitions see the API Reference.
Related guides
- Provision Users: manual user creation and class enrollment
- Classes and Rosters: how class membership drives teacher authorization
- Notification Center: where
lms_connection_revokedalerts appear