Reusable LTI Configuration Proposal: Internal Storage and Extension Filters for Open edX

Reusable LTI Configuration Proposal: Internal Storage and Extension Filters for Open edX

Status: Draft for discussion with Claude Support
Audience: Product (main body) + Engineering (appendix)

Builds on LTI improvements (for Willow & beyond).
It covers one thing: a single way to store reusable LTI config inside Open edX, while
still letting operators plug in outside stores.

Permissions and RBAC are out of scope. The goal is to get the data model right so
scoping and permissions can be added later.


Summary

Today you can reuse some LTI config, but only two lopsided ways:

  • internally, you can share just the credentials (the "passport"), or

  • externally, you can share a full config through a plugin (the LTI Store).

There is no built-in way to save a full, reusable config inside Open edX. That is why
copied blocks drift apart and why course teams can't manage reuse without an operator.

I propose one internal reusable config, on by default, with the plugin filter kept as
an option for outside stores. Course teams get real reuse with no drift (edit once, all
placements follow) and one thing to build a UI around.

The core piece already exists — the LTI 1.3 passport is a shared, live record. But it's
built to keep copies safe, not to be managed: editing a shared value on one block
makes a new record instead of updating the rest. So we have the building block for a
shared store; it just does copying, not "edit once, update many." The work mostly
reshapes what's there, not net-new code. It also sets up the later
scoping/permissions work, but pays off on its own first.

What I'm asking you to decide

  1. Agree on the direction: reusable config is internal by default; the filter
    becomes an add-on, not the only path.

  2. React to the field split in §3. It's a starting
    point. Settling it unblocks the build.

  3. Help work out editing behavior in §5: when you edit a shared
    config, does the change hit every placement, or fork? This needs the most discussion.


1. The reuse story today

An LTI 1.3 setup is a relationship: each side registers credentials with the other. You
want to set it up once and reuse it. Today Open edX gets partway, through pieces built at
different times.

Config can live in three places, plus a plugin filter:

Where

Stored in

Reusable across blocks?

Where

Stored in

Reusable across blocks?

On the block

Block fields, in the course

No

On the DB

A row in the LtiConfiguration table

No — one row per block

Passport

A shared record the block points at

Yes (credentials only)

External (filter)

A plugin, e.g. LTI Store

Yes (full config, but outside the core)

The per-block record is tied to one block and can't be shared. Only two things broke
free of that:

  • The Passport (the copy/paste fix from "Verawood"). A copied block points at the
    same passport, so credentials stay synced across copies. Two limits: it holds only
    credentials (client ID, keys, tool keyset URL), not a full config; and it's built for
    safe copying, not shared editing — editing a shared value on one block makes a
    new record instead of updating the others. It shows a shared record can work, but
    today it only supports copying.

  • External / LTI Store — a full reusable config, but only outside the core,
    through the filter. The core can't do this on its own.

So reuse is lopsided: internal but only credentials (passport), or full but only external
(LTI Store). There's no full, internal, reusable config. That's the gap.

This is why copied blocks drift: the passport part stays synced until someone edits it;
everything else (launch URL, custom parameters, PII/consent, deep linking) is copied and
drifts right away.


2. The proposal

Open edX should have one internal way to store reusable config, with the filter as an
add-on for outside stores.

  1. A real internal object, by default. Grow the passport from "shared credentials"
    into "the shared config" — a named record many blocks point at that stays synced when
    edited.

  2. Filter as an add-on. LTI Store and other outside stores still work through the
    filter, but as one option among several. Internal and external configs share the
    same shape, so the only difference is where they live.

  3. The block becomes a pointer. It keeps only its own per-block settings and points at
    a reusable config.

What course teams get:

  • Real reuse, no drift — one config on many blocks; edit once, all follow.

  • One thing to put a UI on — manage configs in Studio, separate from any block.

  • A place to add scoping/permissions later — the object's scope hint anchors that
    work.

Before and after, for an author

  • Today: set up a tool on a block. Copy the block and credentials stay synced, but
    everything else drifts. No named config to reuse elsewhere.

  • After: make or pick a named config, attach it to as many blocks as you want,
    edit it once and all follow. Each block still keeps its own per-block settings on top.

What this is not

  • Not removing the filter — it stays.

  • Not the permissions work — this just makes it possible.

  • Not a change to how LTI works on the wire — only how we store and reuse config.


3. Which settings are reusable?

This is the main decision and needs product input. Below is a proposed split — a starting
point. Rule of thumb: a setting is reusable if it's about the tool (registered on
both sides, or true everywhere the tool is used), and per-block if it's about how
this spot uses the tool.

Reusable by default — registration and tool identity:

Setting

Why

Setting

Why

LTI version

Part of the registered setup

Client ID

Set at registration; must match both sides

Platform keys (private key / public JWK)

Our identity to the tool

Deployment ID

From registration

Tool public key / keyset URL

The tool's identity

OIDC login URL

Tool endpoint

Redirect URIs

Tool endpoints

Default launch URL

Tool's base launch address

LTI 1.1 launch URL / key / secret

Registration credentials

Deep linking enabled

The tool supporting it is a tool trait

Per-block by default — how this spot uses the tool:

Setting

Why

Setting

Why

Display name / description

Label for this instance

Weight / graded / scoring

This block's grading

Selected deep-link content

The item this block launches

Launch target (inline / new window)

How it shows here

Debatable — needs discussion:

Setting

The tension

Setting

The tension

Custom parameters

Some are tool-wide, some per-block

AGS / grading mode

Tool trait is reusable; whether this block grades is per-block

NRPS (roster) enabled

Same as AGS

PII / consent sharing

A reusable rule, or a per-block choice (see parent proposal)

Likely answer for the debatable ones: a reusable default the block can override. The
config sets a shared default; a block can differ when it needs to. Which settings allow an
override is part of this discussion.


4. How we'd build it

The core piece exists: the passport is a shared, live record, with code that syncs a
block's fields into it (making a new record when shared). We mostly generalize that.
Each step ships on its own:

Step 1 — Grow the object. Widen the passport from credentials-only to the full
reusable set, matching the external model so both use one shape. Rename it — suggested
ReusableLtiConfiguration (alt: LtiToolRegistration). The scope hint (context_key)
starts mattering here.

Step 2 — Move reusable fields off the block. The block keeps only per-block fields
plus a pointer to the shared object.

Step 3 — Make "config on the block" just an editing buffer. Extend the sync code
(today only tool keys) to cover the full reusable set. Editing the block syncs into a
reusable config. Storage drops from three modes to two: internal DB or filter.

Step 4 — Manage it directly (sets up UI + permissions). Add a Studio screen to make,
name, edit, and attach a config, and see which blocks use it — no per-block editing, no
Django Admin. This is the payoff. Scoping and permissions build on this; Steps 1–3 come
first.

When do users notice?

Steps 1–3 are groundwork — model changes that keep things working but users won't see.
Step 4 is the visible win. We do the plumbing first so Step 4 is clean.


5. Open decisions

Besides the field split (§3):

  1. Editing behavior — needs the most discussion. When you edit a shared config, does
    it update every placement or fork a copy? Today credentials fork to protect
    other blocks. For a config you named on purpose, the point is usually "edit once, all
    follow," so I lean toward update all, with the UI showing what's affected and a
    separate "clone" action to diverge. Still open: is a block always tied to a reusable
    config, and how does the UI switch between "shared" and "just this block"?

  2. Which scopes — course run, course, org, site, or some of these? (Shared with the
    parent proposal.)

  3. Migrating existing data — move existing on-block configs over without breaking live
    launches. Lean: do it lazily — launches keep working as-is until a block is
    touched, then it adopts a reusable config on the next edit (the sync code already works
    this way for credentials). No big one-time move.


Appendix: product story → code

Links pinned to
xblock-lti-consumer@c15994d
and the LTI Store plugin.

Three storage modes + filter

  • Mode enum (CONFIG_ON_XBLOCK / CONFIG_ON_DB / CONFIG_EXTERNAL):
    models.py L199–L211.

  • Picked at launch — platform credentials come from the passport in every mode; tool
    keys come from block fields under CONFIG_ON_XBLOCK, the passport under CONFIG_ON_DB,
    the filter under CONFIG_EXTERNAL:
    _get_lti_1p3_consumer, models.py L659–L732.

One record per block

  • LtiConfiguration.location is unique:
    models.py L225–L232.

The passport (the shared object)

  • Model (credentials, UUID, name, context_key):
    Lti1p3Passport, models.py L51.

  • Many blocks → one passport (FK):
    models.py L261.

  • Credentials are read live (why edits to a shared passport reach all blocks):
    models.py L478–L499.

  • Added in PR #627.

Synced for credentials, drift for the rest

  • Copy reuses the passport by reference:
    duplicate_xblock_lti_configuration, signals.py L154.

  • Passport ID written into export/copy XML:
    add_xml_to_node, lti_xblock.py L1967.

  • Fields that drift are the block's editable ones:
    editable_field_names, lti_xblock.py L560.

The sync code exists (basis for Step 3)

  • Block fields → passport, makes a new record when shared:
    _ensure_lti_passport, api.py L30–L91.

  • FK re-linked on save via a signal:
    create_lti_1p3_passport, signals.py L89.

External model to match (Step 1)

  • Full flat config; the internal one should use this shape:
    ExternalLtiConfiguration, lti_store/models.py.

Note — widen in place. Not a product call. The passport table is new, so Step 1 likely
widens Lti1p3Passport in place (less risk), then renames it. Suggested
ReusableLtiConfiguration (pairs with ExternalLtiConfiguration); alt
LtiToolRegistration — accurate to the spec but undersells the reusable behavior
fields and may clash with the parent proposal's dynamic-registration work.

Note — cleanup. The lti_config JSON field on LtiConfiguration holds only two PII
flags today; worth removing.