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
Agree on the direction: reusable config is internal by default; the filter
becomes an add-on, not the only path.React to the field split in §3. It's a starting
point. Settling it unblocks the build.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? |
|---|---|---|
On the block | Block fields, in the course | No |
On the DB | A row in the | 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.
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.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.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 |
|---|---|
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 |
|---|---|
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 |
|---|---|
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 — suggestedReusableLtiConfiguration (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):
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"?Which scopes — course run, course, org, site, or some of these? (Shared with the
parent proposal.)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 toxblock-lti-consumer@c15994d
and the LTI Store plugin.
Three storage modes + filter
Mode enum (
CONFIG_ON_XBLOCK/CONFIG_ON_DB/CONFIG_EXTERNAL):models.pyL199–L211.Picked at launch — platform credentials come from the passport in every mode; tool
keys come from block fields underCONFIG_ON_XBLOCK, the passport underCONFIG_ON_DB,
the filter underCONFIG_EXTERNAL:_get_lti_1p3_consumer,models.pyL659–L732.
One record per block
LtiConfiguration.locationisunique:models.pyL225–L232.
The passport (the shared object)
Model (credentials, UUID,
name,context_key):Lti1p3Passport,models.pyL51.Many blocks → one passport (FK):
models.pyL261.Credentials are read live (why edits to a shared passport reach all blocks):
models.pyL478–L499.Added in PR #627.
Synced for credentials, drift for the rest
Copy reuses the passport by reference:
duplicate_xblock_lti_configuration,signals.pyL154.Passport ID written into export/copy XML:
add_xml_to_node,lti_xblock.pyL1967.Fields that drift are the block's editable ones:
editable_field_names,lti_xblock.pyL560.
The sync code exists (basis for Step 3)
Block fields → passport, makes a new record when shared:
_ensure_lti_passport,api.pyL30–L91.FK re-linked on save via a signal:
create_lti_1p3_passport,signals.pyL89.
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. SuggestedReusableLtiConfiguration (pairs with ExternalLtiConfiguration); altLtiToolRegistration — 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.