SolidProfessor Research Library ← Research library
Proposed backend changes · Report 03

Safe LTI instructor provisioning

The backend changes required to let instructors access SolidProfessor through an existing LTI integration, with just-in-time account creation or connection on first use and recognition on return.

Important status

This page specifies proposed work. The backend has not been changed.

This proposal is tied to the pinned backend commit above. It distinguishes confirmed code behavior from product decisions that must be settled before implementation. This is the engineering companion to the feasibility assessment (Report 02): that report holds the product context, scope, sizing, and decisions; this document holds the proposed file-by-file changes. With no dedicated LTI SME today, treat it as a well-researched starting point for the implementing team — a reference to build on with your own review and judgment, not a mandate.

01 / TARGET OUTCOME

Let instructors access SolidProfessor through LTI

The scope is instructor access through the school's existing LTI integration. When an instructor selects the SolidProfessor tool in the LMS, the validated launch should sign them into SolidProfessor and create, connect, or promote their account just in time. Returning launches should recognize and reuse that same instructor account.

Expected behavior

Just-in-time access when it is needed

  • Launch directly from the LMS into SolidProfessor
  • Create or connect the instructor on first use
  • Recognize the same instructor on later launches
  • Use the signed standard roles claim
  • Keep sub as the user identifier
  • Keep lms_school_id for school mapping
Position in the onboarding model

One supported option—not the only option

  • Supports on-demand instructor onboarding through LTI
  • Does not replace SSO, rostering, or manual provisioning
  • Does not create districts or schools
  • Does not import every teacher in advance
  • Does not implement NRPS or OneRoster
  • Does not replace existing licensing decisions
Exact signed values: read https://purl.imsglobal.org/spec/lti/claim/roles and elevate only when its array contains http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor. The current backend's captured Schoology launch already contains that standard value.
02 / SCOPE BOUNDARY

Instructor access—not institution provisioning

LTI just-in-time access is one way to support instructors. It can be made available across an existing mapped school, but each instructor is created or connected only when they launch the tool. A district may still use SSO, roster sync, or administrative provisioning alongside this option.

LayerResponsibilityResult
01 District / school setup Create the SolidProfessor account and schools, configure licensing, register the LTI deployment, and map LMS school IDs. Pre-existing
02 LTI instructor launch Validate the signed launch, resolve the mapped school and role, then create or connect the instructor just in time. This proposal
03 Returning instructor launch Recognize the existing school user by the stable LTI identifier and allow the instructor to continue without creating a duplicate account. This proposal
04 Course enrollment Enroll the resulting school user when a SolidProfessor class is present in the target URL. Existing
05 District roster sync Pre-create schools, courses, users, and enrollments before launches through OneRoster, SIS, or another feed. Out of scope
03 / TARGET FLOW

Five guarded steps from launch to instructor

The Packback library already validates the LTI launch before application provisioning begins. The new logic should consume only that validated launch data.

01

Validate

Verify signature, issuer, client, deployment, nonce, and message through the existing LTI library.

02

Scope school

Resolve lms_school_id only within the deployment that validated the launch.

03

Resolve role

Exact Instructor context role maps to SolidProfessor Instructor. Everything else remains Student.

04

Provision or reuse

Create or connect the instructor on first use; recognize and reuse the same school user on later launches.

05

Authorize

Issue the normal LTI access token and avoid sending a valid instructor through a student payment path.

04 / FILE CHANGE INVENTORY

Backend touch points

These are the expected production and test changes. A final file count depends on the licensing decision described later.

#File or componentRequired changeType
01 Launch/Enums/LtiContextRole.php New allowlist for the standard roles claim and exact Instructor, Learner, and Teaching Assistant context-role URIs. New
02 Launch/Actions/ResolveLtiProvisioningRole.php New action that maps only the exact Instructor URI to Role::INSTRUCTOR; missing, malformed, or unrecognized roles fall back to Student. New
03 Launch/Actions/FindSchoolByLmsId.php Accept the validated deployment and query only its mapped schools. Do not resolve an LMS school ID through another deployment. Security
04 Launch/Actions/NormalizeLtiUserIdentifier.php New compatibility helper used by resource launch, Deep Linking, and account connection so Schoology-style sub values are handled consistently. New
05 Launch/Actions/FindOrCreateUserForLtiContext.php Resolve the launch role once; provision new instructors; promote an existing Student on a valid Instructor launch; never demote existing Instructor or administrator roles. Core
06 Launch/Actions/DoLtiDeepLinkLaunch.php Require a signed Instructor role for automatic provisioning and reuse the central provisioning action when the instructor's first interaction is Deep Linking. Core
07 Api/v1/.../LtiConnectController.php Use the same identifier normalization helper as both launch paths when linking an existing account. Modify
08 Launch/Actions/DoLtiResourceLinkLaunch.php Make the inactive-user/payment redirect role-aware so a newly recognized instructor is not treated as an inactive known-LMS student. Policy
09 Mapping request + database migration Reject duplicate lms_school_id values within one deployment and add a unique deployment/mapping constraint after auditing existing data. Hardening
10 LtiTestHelpers.php + LTI system tests Add signed-role fixtures and regression coverage for role mapping, mapping scope, Deep Linking, identity normalization, and payment behavior. Tests
11 Academic/Lti/Setup/Contracts/Lti13Service.php Remove three dd($e) calls at lines 154, 176, 191. Replace with proper error handling and logging. These debug statements halt execution and expose sensitive data. Gap found
12 UpdateLtiRegistrationDeploymentMappingRequest.php Add uniqueness validation for lms_school_id within a deployment. Current rules only check ['nullable', 'string']—no duplicate prevention. Gap found
No change expected in ProvisionUserToSchool.php: it already accepts Role::INSTRUCTOR, synchronizes that role, and can create the instructor's default class. The caller must supply the safe role and activation policy. (Update: ASE-1221 has since modified this file for an unrelated primary-account fix — see the addendum below.)
04b / UPDATE SINCE THIS PROPOSAL

Re-reviewed at cc6a47a28 — what landed since, and what to fold in

This proposal is pinned to baseline c8799473d5a5 and remains accurate against it. Re-reviewed against main at cc6a47a28 (Sept 2026): the conclusions still hold — the LTI roles claim is still unread, and new users still default to Student. Two things to fold in: changes that landed in ASE-1221, and additional touch points surfaced by the feasibility review (Report 02).

Changes that landed since (ASE-1221)

  • Item #05's file was refactored. FindOrCreateUserForLtiContext.php now has a flag-gated (ACADEMIC_LTI_PRESERVE_MIGRATED_MEMBER_STATUS) path that claims an unlinked migrated member and preserves their status and role, and the new-user role is now a ternary. The #05 change (resolve role once, provision/promote, never demote) must integrate with this structure — there are now two privileged-role decision points plus the flag. No conflict: the roles claim is still unread.
  • ProvisionUserToSchool.php did change. ASE-1221 added a primary-account fix (match null or false) that was 500ing LTI launches. The "no change expected" note still holds in intent (it accepts INSTRUCTOR and creates the default class), but the file has since changed for that unrelated reason.
  • Item #08's scope narrows. The migrated known-LMS-inactive symptom that partly motivated the role-aware redirect is now separately fixed by ASE-1221's status preservation. #08 is now specifically: do not route a valid instructor to the student payment path.
  • Item #11 line numbers. The dd($e) calls are at 154 / 176 / 191 at current HEAD (Lti13Service.php was untouched by ASE-1221; the pinned-baseline "175, 190" were correct for c8799473d5a5). The inventory row above has been updated to current HEAD.

Additional touch points from the feasibility review (Report 02)

#File or componentRequired changeType
13 UpdateOrCreateLtiLaunch.php Stop persisting the entire launch payload ('request' => $launchData) to the DB; whitelist non-PII keys. This is the primary "no PII in our database" surface for privacy-strict districts (e.g. LAUSD). Privacy
14 Setup/Contracts/Lti13Service.php Guard the accepts_pii auto-flip (set from the presence of an email claim) so a stray claim can't silently un-mask an anonymous deployment. Hardening
15 DeepLinking/Actions/CreateDeepLinks.php Attach lms_school_id (and any needed custom params) to deep-linked resources. Schoology does not re-inject app/district custom params, and the later resource launch requires lms_school_id or it throws. Required
16 Launch/Actions/FindOrCreateUserForLtiContext.php Give an anonymous instructor a distinct display name ("Anonymous Instructor") so CS can find and deactivate a specific one in admin. UX
17 Launch/Actions/FindOrCreateUserForLtiContext.php Activation rule: provision net-new instructors active (an inactive academic instructor resolves to zero permissions); never reactivate an existing email-matched account on launch — deactivation is CS's access-control lever. Policy
Full context: these additions are detailed in the feasibility assessment (Report 02), which covers the known and anonymous (LAUSD / Schoology) cases, production evidence, and sizing.
05 / PROPOSED CODE SHAPE

Small, isolated decisions—not role logic scattered across controllers

These snippets illustrate the intended contracts. They are proposed implementation shapes, not code already present in the backend.

1. Resolve the standard context role

new action
final class ResolveLtiProvisioningRole
{
    public function handle(array $launchData): string
    {
        $roles = Arr::wrap(Arr::get(
            $launchData,
            LtiContextRole::CLAIM,
            []
        ));

        return in_array(LtiContextRole::INSTRUCTOR, $roles, true)
            ? Role::INSTRUCTOR
            : Role::STUDENT;
    }
}

2. Preserve privileged users while allowing promotion

FindOrCreateUserForLtiContext
$launchRole = ResolveLtiProvisioningRole::run($launchData);

$roleToApply = $schoolUser
    ->accountUser
    ->roles()
    ->whereIn('name', [
        Role::SUPER_ADMIN,
        Role::DISTRICT_ADMIN,
        Role::INSTRUCTOR,
    ])
    ->exists()
        ? null       // Never demote a protected role.
        : $launchRole; // Student can be promoted to Instructor.

3. Scope school lookup to the validated deployment

FindSchoolByLmsId
public function handle(
    LtiRegistrationDeployment $deployment,
    array $custom
): School {
    $lmsSchoolId = Arr::get($custom, 'lms_school_id');

    return $deployment
        ->schools()
        ->wherePivot('lms_school_id', $lmsSchoolId)
        ->firstOr(fn () => throw new SchoolNotLinkedToDeployment);
}

4. Use one identifier rule everywhere

shared helper
final class NormalizeLtiUserIdentifier
{
    public function handle(string $subject): string
    {
        // Phase 1 preserves current Schoology compatibility.
        return Str::of($subject)->before('::')->toString();
    }
}

5. Provision first-time Deep Linking instructors

DoLtiDeepLinkLaunch
$launchData = $ltiMessageLaunch->getLaunchData();
$role = ResolveLtiProvisioningRole::run($launchData);

throw_unless(
    $role === Role::INSTRUCTOR,
    new InstructorRoleRequiredForDeepLinking
);

$schoolUser = FindOrCreateUserForLtiContext::run(
    $deployment,
    $launchData
);
Compatibility choice: centralizing the existing before('::') behavior fixes today's resource/Deep Linking mismatch without orphaning existing Schoology users. A later, spec-oriented identity migration should store the exact sub with issuer and deployment scope; that is intentionally outside this phase.
06 / SECURITY CONTRACT

Rules that make role mapping safe

An LMS role becomes a SolidProfessor permission boundary. These constraints should be treated as acceptance criteria, not optional polish.

Trust boundary

Validated claims only

Read roles only from launch data returned after Packback validation. Never accept a browser field or custom is_instructor parameter.

Allowlist

Only exact Instructor elevates

Learner, Administrator, Teaching Assistant, unknown URIs, empty arrays, and malformed values must not grant Instructor or administrator access.

Role lifecycle

Promote, never auto-demote

A signed Instructor launch can promote a Student. A later Learner launch must not silently remove Instructor, District Admin, or Super Admin privileges.

Tenant boundary

Deployment-scoped school mapping

The LMS school ID must resolve only inside the deployment that validated the launch, and one deployment cannot map the same LMS school ID twice.

Privacy

PII remains optional

Anonymous instructors can use a generated internal email and no password. A stable signed sub is still required for a persistent account.

Observability

Audit without leaking tokens

Record deployment, mapped school, resolved role, and provisioning outcome. Do not log the raw ID token, full request payload, or PII.

Do not map LMS Administrator to SolidProfessor District Admin or Super Admin. Those roles have different organizational scope and should continue to require an explicit SolidProfessor administrative process.
07 / PRODUCT DECISIONS

Four decisions required before implementation

The code answers how roles work today, but it cannot decide licensing and permission policy. These decisions are owned and tracked in Report 02 §4 (several are already settled there); they are summarized here for the implementer.

DECISION 01

Known-LMS instructor activation

New identified LTI users are currently inactive and routed toward student payment. Recommended: define an instructor entitlement rule and never send a valid instructor through the student payment flow.

DECISION 02

Context role versus account role

LTI Instructor is course-contextual, while SolidProfessor's Instructor role is synchronized on AccountUser. Confirm that promotion is intentionally durable across the user's mapped school memberships in that account.

DECISION 03

Teaching Assistant treatment

Recommended: leave Teaching Assistants out of the scope of this work unless product discovery deems otherwise. Do not elevate TAs by default; add a separate mapping only after SolidProfessor defines the intended permission set.

DECISION 04

Non-instructor Deep Linking

Recommended: reject the automatic path for Learner or unknown roles, while preserving the current account-link fallback only where product explicitly wants it.

08 / TEST PLAN

Regression coverage required before rollout

Tests should be written around the role resolver first, then the provisioning and launch flows. Existing Student behavior must remain unchanged.

Exact Instructor URI resolves to Role::INSTRUCTOR.
Learner, Teaching Assistant, Administrator, unknown, empty, and malformed roles resolve to Student.
Multiple roles containing Instructor resolve to Instructor regardless of order.
A new anonymous Instructor receives an anonymous account, Instructor role, active school membership, and LTI ID.
A new known-LMS Instructor follows the approved activation and entitlement policy.
An existing Student is promoted after a valid Instructor launch.
Existing Instructor, District Admin, and Super Admin roles are not downgraded by Learner or missing-role launches.
A school mapping from another deployment cannot satisfy the current launch.
Duplicate LMS school IDs within one deployment fail validation and the database constraint.
Schoology sub values containing :: match consistently in resource, Deep Linking, and connect flows.
An unknown Deep Linking Instructor is provisioned without the manual account-link step.
Learner Deep Linking cannot create or promote an Instructor.
A valid Instructor resource launch does not enter the student payment redirect.
Existing Student resource launch, activation, payment, and class-enrollment tests remain green.
Likely test locations: FindOrCreateUserForLtiContext/*, DoLtiDeepLinkLaunchTest.php, a real DoLtiResourceLinkLaunchTest.php, mapping request tests, and a new focused resolver test.
09 / CODE EVIDENCE

Current backend sources behind this proposal

Links are pinned to the reviewed commit so the proposal remains auditable if the main branch changes.

10 / DEFINITION OF DONE

The backend change is complete when:

  1. An instructor can enter SolidProfessor directly from the LMS through the existing LTI integration without a separate SolidProfessor sign-in step.
  2. A first validated Instructor launch can create, connect, or promote the Instructor in the correct mapped school, and later launches reuse that account.
  3. Learner, Teaching Assistant, Administrator, missing, and malformed roles cannot elevate access.
  4. Existing privileged roles are never automatically downgraded.
  5. Resource and Deep Linking paths use one identifier rule.
  6. School lookup cannot cross deployment boundaries or select an ambiguous mapping.
  7. Instructor licensing and activation behavior is explicitly approved and tested.
  8. Current Student LTI behavior and LMS setup parameters remain unchanged.
  9. No debug functions (dd(), dump(), ray()) remain in LTI-related files.
  10. The change is released behind a controlled backend feature flag, tested with both Canvas and Schoology launch payloads, and monitored before broad enablement.