Setting Up LMS Integration
This walks through connecting the SIS to a learning management system end to end: creating an instance, storing its credential, mapping terms and roles, deciding which sections get course shells, provisioning them, syncing enrollments, and — if you want it — passing final grades back.
Work through it in order. Each step depends on the one before it, and the most common support question ("nothing happened") is almost always a step that was skipped.
The design, separately
This page is the operator's guide. If you need to know why the subsystem is shaped the way it is — the connector definition format, the template engine, the job queue's idempotency rules — read LMS Integration Design.
What you are configuring
An LMS type is data, not code. Canvas, Moodle and D2L Brightspace are not three code paths; they are three rows in lms.connector_types plus a set of versioned operation definitions describing how to call them. That is why adding a new LMS, or adjusting how an existing one is called, is a configuration change rather than a release.
Three seeded types ship:
| Code | Name | Auth strategy | Paging | Default rate |
|---|---|---|---|---|
CANVAS | Instructure Canvas | BEARER | LINK_HEADER | 5 req/s |
MOODLE | Moodle | QUERY_API_KEY | NONE | 3 req/s |
BRIGHTSPACE | D2L Brightspace | D2L_OAUTH2 | BOOKMARK | 5 req/s |
OAUTH2_CLIENT_CREDENTIALS is also supported as a strategy for a type you define yourself.
Canvas carries the most operations (16, including sis_import and post_grade), Moodle 11, Brightspace 10. An operation your connector does not define simply is not available — for example Moodle's seed has no post_grade, so grade passback is not offered for a Moodle instance.
Step 1 — Configure the credential keyring (server, once)
Do this first. Credentials are sealed with a rotating AES keyring before they reach the database, and the server cannot seal anything without keys.
Set both variables in .env.local on the server, using the SIS_-prefixed names that reach the containers:
SIS_LMS_CRED_KEYS="k1:<base64 of 32 random bytes>"
SIS_LMS_CRED_ACTIVE_KEY="k1"Locally, sis_backendgo/.env uses the unprefixed names:
LMS_CRED_KEYS="k1:<base64 32B>,k2:<base64 32B>"
LMS_CRED_ACTIVE_KEY="k2"LMS_CRED_KEYS is a comma-separated list of id:key pairs, so an old key can stay available for decryption while a new one becomes active. LMS_CRED_ACTIVE_KEY names the one used to encrypt new secrets.
Read this before you file a bug
With no keyring, the configuration screens still work — you can create an instance, define mappings, edit rules. Only the paths that touch a secret fail, and they fail with HTTP 503 and the message "LMS credential encryption keys are not configured on this server".
A credential save that fails is a missing keyring, not a duplicate credential. This used to be reported as a 409 conflict, which sent people hunting for an existing credential that was never there. There is no reachable duplicate on this path: the only unique index is partial (WHERE is_active) and creating a credential retires the incumbent in the same transaction.
Generate a key with:
openssl rand -base64 32Step 2 — Grant the permissions
The subsystem is gated by four permission nodes (migration 200), not one:
| Node | Covers |
|---|---|
ADMIN.LMS.CONNECTORS | Connector types, operations, operation versions |
ADMIN.LMS.INSTANCES | Instances, credentials, mappings, assignment rules, policy |
ADMIN.LMS.SECTIONS | Provisioning, shells, drift findings, CSV exports |
ADMIN.LMS.LOGS | Integration log, job queue, and operation preview |
Two of those placements are deliberate and worth knowing:
- Preview is a log-level permission, not an editor-level one. Previewing an operation renders it against real student data, so it sits with
ADMIN.LMS.LOGSrather than with the connector editor. - Export download is on
ADMIN.LMS.SECTIONSlike everything else on that surface, even though it is a read. The files contain education records.
A screen you were not granted is not there
The sidebar is built entirely from core.permission_nodes. Without a grant the screen does not appear at all — it does not appear greyed out. If an administrator reports that "LMS is missing from the menu", check the grant before checking anything else.
Step 3 — Create the instance
Administration → LMS Integration → Instances (/admin/lms/instances).
An instance is one LMS tenant. You can run several — a production Canvas and a sandbox Canvas, or Canvas alongside a Moodle for one school.
| Field | Notes |
|---|---|
| Connector type | One of the three seeded types, or your own |
| Code, Name | code identifies the instance in rules and logs |
| Base URL | The LMS API root |
| Sandbox | Marks a non-production tenant |
| Dry run | Calls are rendered and logged but never sent. Start here |
| Match strategy | SIS_ID (default) or EMAIL — how an SIS person is matched to an LMS user |
| Drop behavior | DEACTIVATE (default), DELETE, or CONCLUDE — what a dropped enrollment does in the LMS |
| Create missing users | Default on; see step 6 |
| Grade passback | Default off; see step 9 |
| Shell name / code template | How a course shell is titled and keyed |
| Rate limit, burst | Override the connector type's defaults for this tenant |
Leave dry_run on until you have watched the integration log. A dry-run instance renders every request and records it without sending, which is the cheapest way to find a wrong base URL or a bad template.
Deactivating an instance stops all traffic to it. An inactive instance is sent nothing: shell creation, enrollment sync, reconcile, conclude and SIS CSV export jobs for it are dead-lettered with instance is deactivated before any request is made, and the daily reconcile and conclude scans skip its shells. Deactivate an instance whose credential has been revoked, rather than leaving it to fail every job with a 401.
Step 4 — Store and activate a credential
On the instance, add a credential. What it needs depends on the auth strategy:
BEARER(Canvas) — the API token as the secret.QUERY_API_KEY(Moodle) — the web-service token as the secret.D2L_OAUTH2/OAUTH2_CLIENT_CREDENTIALS— client ID, secret, token URL, and scopes.
Secrets are write-only. Once saved, the value is never returned by the API and never shown in the UI; listing credentials returns metadata only (purpose, client ID, key ID, activation and retirement timestamps).
Rotation. Add the new credential and activate it — activation retires the incumbent in the same transaction, recording rotated_from_credential_id. A credential can also be retired without a successor, which is the right move if a token leaked and you would rather the integration stop than keep running.
Then use Test connection, which runs the connector's verify operation. Every seeded type defines one.
Step 5 — Map terms, roles and org units
Mappings translate SIS identifiers into the LMS's own.
- Term mappings — SIS term → the LMS term/session id.
- Role mappings — SIS role → LMS enrollment role (student, teacher, TA, …).
- Org unit mappings — SIS org unit → the LMS account, department or org-unit id the shell should be created under.
An unmapped term never provisions
Provisioning resolves a shell's term through the term mapping. A section in a term with no mapping produces no shell and no error on the screen you are looking at — the job records the reason in the integration log. If shells are "not being created" for one term only, check its mapping first.
Step 6 — Decide which sections get a shell
Two mechanisms, in priority order.
Assignment rules (lms.assignment_rules) route sections to an instance by org unit and/or campus, with a numeric priority (default 100). Lowest priority wins. This is how you say "everything in the School of Nursing goes to Canvas" or "the Ukiah campus goes to the sandbox".
Section overrides pin one specific section to one instance regardless of the rules — the escape hatch for the section that does not fit the pattern.
create_missing_users (migration 252) is set per instance and defaults to on. With it on, a student with no LMS account yet is created during enrollment sync. With it off, that enrollment is left unfulfilled and flagged rather than silently dropped. Turn it off when account creation belongs to another system — an identity provider or an HR feed — and you do not want the SIS minting accounts behind its back.
Step 7 — Set the sync policy
Administration → LMS Integration → Sync (/admin/lms/sync). The policy is global, not per instance.
| Setting | Default | Meaning |
|---|---|---|
| Provisioning mode | OPTIONAL | Whether sections are expected to have shells |
| Provision trigger | MANUAL | MANUAL, or automatic ahead of term start |
| Provision days before | 30 | How far ahead automatic provisioning runs |
| Enrollment sync trigger | MANUAL | As above, for enrollments |
| Enrollment days before | 7 | |
| Conclude policy | LEAVE_OPEN | What happens to a shell after the term ends |
| Conclude days after | 30 | |
| Drift: missing enrollment | FLAG | Someone enrolled in the SIS but not the LMS |
| Drift: extra enrollment | FLAG | Enrolled in the LMS but not the SIS |
| Drift: missing shell | FLAG | A section that should have a shell and does not |
Start every drift setting on FLAG. Flagging tells you what reconciliation would do without doing it.
Step 8 — Provision and sync
With dry run still on, click Provision now and watch Administration → LMS Integration → Logs (/admin/lms/logs).
The engine is a job queue worked with SKIP LOCKED, so several workers can run without stepping on each other, and a job that fails lands in the dead-letter state — which the job browser exposes as a status filter, not a separate screen. Jobs can be retried or cancelled individually.
When the dry-run log looks right, turn dry run off and provision again. Then:
- Sync enrollments per shell, or let the trigger do it.
sis_course_id(migration 251) records the SIS course id actually sent to the LMS, so a shell can be traced back even after someone renames it on the far side.- A shell that never reached the LMS can be abandoned. Deleting one refuses once it holds a course, so this cleans up a failed attempt without touching a live shell.
- A shell can carry several sections — add and remove them individually. This is how cross-listed sections share one LMS course.
Step 9 — Grade passback (optional)
Off by default per instance. Turn it on only when the registrar's release process is understood, because it is what gates the whole feature.
A grade is publishable when its latest change is Colleague-sourced, or when a registrar release covers it (records.grade_releases, migration 203). Grades still awaiting release are skipped.
Two properties are worth stating plainly:
- It fails closed. If the release gate is not wired, passback holds everything back rather than publishing ungated grades.
- Enabling it does not flood the queue. The first scan for an instance only initialises that instance's watermark to the current maximum. History is not replayed. From then on the scanner walks
records.grade_change_logforward, and the release hook enqueues held grades the moment the registrar acts.
Step 10 — SIS CSV export (alternative to the API)
Some institutions prefer feeding the LMS a file. Each connector defines an export_profile describing the CSV shape, and the SIS produces runs and files against it.
Preview a run before creating one. The database enforces one active run per instance, so a second attempt while one is in flight is rejected rather than queued. A run can be cancelled, and its files uploaded to the LMS or downloaded for a manual load.
Step 11 — Watch for drift
Drift findings compare what the SIS believes against what the LMS reports: enrollments missing on one side, extra on the other, shells that should exist and do not. Each finding can be resolved once handled.
Review findings after the first term you run the integration live. Steady-state drift usually means a mapping is wrong, not that the sync is broken.
Troubleshooting
| Symptom | Cause |
|---|---|
| 503 saving a credential, "encryption keys are not configured" | SIS_LMS_CRED_KEYS / SIS_LMS_CRED_ACTIVE_KEY missing on the server. Not a duplicate. See step 1 |
| LMS missing from the sidebar | No grant on the relevant ADMIN.LMS.* node. The menu is built from permission nodes, so an ungranted screen is absent, not disabled |
| Shells not created for one term | That term has no term mapping (step 5) |
| Shells not created for some sections | No assignment rule matches their org unit or campus (step 6) |
| Requests logged but nothing changes in the LMS | The instance is still in dry_run |
| Enrollments skipped, users not created | create_missing_users is off for that instance — by design |
| Grades never appear in the LMS | Passback disabled on the instance, or the grades are not released. Passback fails closed |
post_grade not offered | The connector type has no such operation — Moodle's seed does not define one |
| A second export refuses to start | One active run per instance is a database constraint |
| Test connection fails after a rotation | The new credential was added but never activated |
| Jobs dead-lettered with "instance is deactivated" | The instance is inactive, so nothing is sent to it. Reactivate it if it should still be in use |
Where to look, in order: the integration log (/admin/lms/logs) records every rendered request and its outcome; the job browser shows queued, failed and dead-lettered work; lms.integration_log holds the same detail in SQL if you need to query across it.
Related
- LMS Integration Overview — what the subsystem is
- LMS Integration Design — schema, connector format, engine
- LMS API Reference — all 54 endpoints
- Grade Release — the registrar gate passback depends on