Skip to content

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:

CodeNameAuth strategyPagingDefault rate
CANVASInstructure CanvasBEARERLINK_HEADER5 req/s
MOODLEMoodleQUERY_API_KEYNONE3 req/s
BRIGHTSPACED2L BrightspaceD2L_OAUTH2BOOKMARK5 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:

bash
SIS_LMS_CRED_KEYS="k1:<base64 of 32 random bytes>"
SIS_LMS_CRED_ACTIVE_KEY="k1"

Locally, sis_backendgo/.env uses the unprefixed names:

bash
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:

bash
openssl rand -base64 32

Step 2 — Grant the permissions ​

The subsystem is gated by four permission nodes (migration 200), not one:

NodeCovers
ADMIN.LMS.CONNECTORSConnector types, operations, operation versions
ADMIN.LMS.INSTANCESInstances, credentials, mappings, assignment rules, policy
ADMIN.LMS.SECTIONSProvisioning, shells, drift findings, CSV exports
ADMIN.LMS.LOGSIntegration 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.LOGS rather than with the connector editor.
  • Export download is on ADMIN.LMS.SECTIONS like 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.

FieldNotes
Connector typeOne of the three seeded types, or your own
Code, Namecode identifies the instance in rules and logs
Base URLThe LMS API root
SandboxMarks a non-production tenant
Dry runCalls are rendered and logged but never sent. Start here
Match strategySIS_ID (default) or EMAIL — how an SIS person is matched to an LMS user
Drop behaviorDEACTIVATE (default), DELETE, or CONCLUDE — what a dropped enrollment does in the LMS
Create missing usersDefault on; see step 6
Grade passbackDefault off; see step 9
Shell name / code templateHow a course shell is titled and keyed
Rate limit, burstOverride 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.

SettingDefaultMeaning
Provisioning modeOPTIONALWhether sections are expected to have shells
Provision triggerMANUALMANUAL, or automatic ahead of term start
Provision days before30How far ahead automatic provisioning runs
Enrollment sync triggerMANUALAs above, for enrollments
Enrollment days before7
Conclude policyLEAVE_OPENWhat happens to a shell after the term ends
Conclude days after30
Drift: missing enrollmentFLAGSomeone enrolled in the SIS but not the LMS
Drift: extra enrollmentFLAGEnrolled in the LMS but not the SIS
Drift: missing shellFLAGA 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_log forward, 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 ​

SymptomCause
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 sidebarNo 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 termThat term has no term mapping (step 5)
Shells not created for some sectionsNo assignment rule matches their org unit or campus (step 6)
Requests logged but nothing changes in the LMSThe instance is still in dry_run
Enrollments skipped, users not createdcreate_missing_users is off for that instance — by design
Grades never appear in the LMSPassback disabled on the instance, or the grades are not released. Passback fails closed
post_grade not offeredThe connector type has no such operation — Moodle's seed does not define one
A second export refuses to startOne active run per instance is a database constraint
Test connection fails after a rotationThe 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.

Student Information System Documentation