Skip to content

feat: LTI 1.3 Passport Refactor + Database Cleanup Support #627

Merged
feanil merged 50 commits intoopenedx:masterfrom
open-craft:navin/fal-4318/split-config
Apr 20, 2026
Merged

feat: LTI 1.3 Passport Refactor + Database Cleanup Support #627
feanil merged 50 commits intoopenedx:masterfrom
open-craft:navin/fal-4318/split-config

Conversation

@navinkarkera
Copy link
Copy Markdown
Contributor

@navinkarkera navinkarkera commented Mar 19, 2026

Description

Split LTI 1.3 Configuration into Passport Model

  • Introduce Lti1p3Passport model to centralize LTI 1.3 keys and credentials
  • Move lti_1p3_internal_private_key, lti_1p3_internal_private_key_id, lti_1p3_internal_public_jwk, lti_1p3_client_id, lti_1p3_tool_public_key, and lti_1p3_tool_keyset_url fields from LtiConfiguration to Lti1p3Passport
  • Add ForeignKey relationship from LtiConfiguration to Lti1p3Passport
  • Implement passport-based key generation and retrieval
  • Add clean() validation to Lti1p3Passport to ensure at least one of lti_1p3_tool_public_key or lti_1p3_tool_keyset_url is set
  • Update validation in LtiConfiguration.clean() to check for passport presence instead of tool key fields
  • Refactor get_or_create_local_lti_config() to handle passport creation and sync block/passport key configurations
  • Update API endpoints to work with passport ID instead of configuration ID
  • Add admin interface for Lti1p3Passport model
  • Refactor access_token_endpoint and public_keyset_endpoint to use passport ID
  • Update API and views to work with the new passport model
  • Generate migration to remove fields from LtiConfiguration table
  • Update data migration to copy existing configurations to the new Passport model
  • Update XBlock to store passport ID instead of config ID
  • Fix copy-paste issue in resource_link_id generation

Support Database Cleanup for Deleted Blocks

  • New signal handlers to:
    • Automatically delete orphaned configurations during pre_item_delete (block/children).
    • Purge unused passport objects from the LTI 1.3 Passport model.

Related

Test instructions

  • Use sandbox to test.
  • Create a working 1.3 LTI xblock.
  • Copy and paste the xblock.
  • Make sure both xblocks are working.
  • Go to <studio_url>/admin/lti_consumer/ and check configuration and passport entries.
  • Both configuration should use a single passport.
  • Now go and modify lti_1p3_tool_keyset_url in one of the xblocks and save. This should create a new passport entry instead of modifying the original one to avoid changing other block.
  • Finally test all LTI 1.3 features to make sure that we have not broken existing features.

@openedx-webhooks openedx-webhooks added open-source-contribution PR author is not from Axim or 2U core contributor PR author is a Core Contributor (who may or may not have write access to this repo). labels Mar 19, 2026
@openedx-webhooks
Copy link
Copy Markdown

openedx-webhooks commented Mar 19, 2026

Thanks for the pull request, @navinkarkera!

This repository is currently maintained by @Faraz32123.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@navinkarkera
Copy link
Copy Markdown
Contributor Author

@ayub02 Please use the sandbox to test.

@navinkarkera navinkarkera force-pushed the navin/fal-4318/split-config branch from 08ac0d2 to 7becfcb Compare March 20, 2026 12:41
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 20, 2026

Codecov Report

❌ Patch coverage is 98.46323% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.64%. Comparing base (4fb7bba) to head (45f83c0).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...ssport_context_key_lti1p3passport_name_and_more.py 76.92% 6 Missing ⚠️
lti_consumer/models.py 96.80% 3 Missing ⚠️
lti_consumer/lti_xblock.py 83.33% 2 Missing ⚠️
lti_consumer/signals/signals.py 97.01% 2 Missing ⚠️
...onsumer/migrations/0021_create_lti_1p3_passport.py 96.87% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #627      +/-   ##
==========================================
+ Coverage   97.59%   97.64%   +0.04%     
==========================================
  Files          79       84       +5     
  Lines        6871     7594     +723     
==========================================
+ Hits         6706     7415     +709     
- Misses        165      179      +14     
Flag Coverage Δ
unittests 97.64% <98.46%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@navinkarkera navinkarkera force-pushed the navin/fal-4318/split-config branch from 1369128 to b7c06db Compare March 22, 2026 15:05
Copy link
Copy Markdown
Contributor

@feanil feanil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@navinkarkera I like the approach of adding a new model for storing lti credentials independent of the blocks, this would obviate the need for external storage and make it easier to re-use storage. I've got a few questions specific to the implementation but I think this is the idea that we should try to land.

@ayub02 a question for you: Should import/export work for LTI blocks from one open edx instance to another? This has not worked before but this change will not really fix that either.

Comment thread lti_consumer/plugin/compat.py
Comment thread lti_consumer/models.py Outdated
f'Failed to parse main LTI configuration location: {self.location}',
)

def create_lti_1p3_passport(self):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a get or create in practice right? So let's update the name.

Comment thread lti_consumer/utils.py
# Remove private and excluded fields.
for key in list(object_fields):
if key.startswith('_') or key in exclude:
if key.startswith('_') or key in exclude or (include and key not in include):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to allow us to include some fields instead of excluding lot of fields incase we only need few of them.

Comment thread lti_consumer/models.py
"""
Model to store LTI 1.3 keys.
"""
passport_id = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add a passport_name field now and let the users set it so that when we make this re-usable they will already have human readable names and we can show them to the user? Also currently these settings have no scope. Since we're newly introducing it, does it make sense to make it scoped to a context key of some sort to begin with?

Copy link
Copy Markdown
Contributor Author

@navinkarkera navinkarkera Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add a passport_name field now

Yes, good idea!

Since we're newly introducing it, does it make sense to make it scoped to a context key of some sort to begin with?

Makes sense. Should we worry about them being used out of context for now? Like if you copy and paste in different courses, they will be using the same passport.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About passport_name:

  • Should we make this field unique in combination with the context_key?
  • We'll still need passport_id as it needs to be unique across the table.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think let's not worry about the name uniqueness for now, think of it more as a display name. In the future, we may want to have some passports shared across an org or across a whole site, and so that course or library context wouldn't make sense then. I think when we display the key to the user, it could get confusing but only if the authors are creating multiple credentials with the same name. As a future option we will need to add a way to edit all the existing configs but something like that is already in the conversation for Willow so we can worry about it then.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it.

How would the Author specify the name and context_key? Should we add them to the xblock settings editor?

For existing blocks, we could use xblock name as default value for passport name, something like: f"Passport for: {xblock_name}" and add course key as context_key.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also be dropping these fields from the xblock at the same time? We're not really using them for storage as much as to make it easier to use the old studio block rendering helper. Since we're redoing the frontend, do we need those fields to exist on the block? @rpenido perhaps you're the right person to answer that question?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • We are moving them (including existing data) to passport model.
  • AFAIK, the frontend doesn't really depend on the database models.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, so maybe it's a follow up PR to drop the keys from the block, in-case there are issues and we need to rollback the migration or re-run it we can leave them in for now. Can you make a ticket in this repo to follow up on the removal once the code has been live for a release?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are not present in the xblock. They were only present in the LtiConfiguration table.

Comment thread lti_consumer/migrations/0021_create_lti_1p3_passport.py Outdated
@mphilbrick211 mphilbrick211 added the FC Relates to an Axim Funded Contribution project label Mar 23, 2026
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Waiting on Author in Contributions Mar 23, 2026

@receiver(post_save, sender=LtiConfiguration, dispatch_uid='create_lti_1p3_passport')
def create_lti_1p3_passport(sender, instance: LtiConfiguration, **kwargs): # pylint: disable=unused-argument
instance.get_or_create_lti_1p3_passport()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you call this function as a post-save function for LtiConfiguration for it also calls lti_configuration.save() conditionally. So you have a situation where we get a double save and a double call to the get_or_create function at the creation of each new LtiConfiguration instance. Take a look at the suggestion in the get_or_create function to see how we could avoid the double save firing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch!

Comment thread lti_consumer/models.py Outdated
block.save()
compat.save_xblock(block)
self.lti_1p3_passport = passport
self.save()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.save()
LtiConfiguration.objects.filter(pk=self.pk).update(lti_1p3_passport=passport)

The self.lti_1p3_passport = passport keeps the in-memory instance in sync. The update() writes the FK directly to the DB without going through save(), so the signal doesn't re-fire. The sync_configurations() bypass is fine here since we're only updating this FK field. But we should add a comment explaining this here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here!

Comment on lines +22 to +23
passport.name = f"Passport of {block.display_name}"
passport.context_key = block.context_id
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the try/catch above could throw an exception in which case block might not be set. In that case we'll get further exceptions. Do we need a continue in the Exception clause so that we don't error out here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@feanil Nice catch! I was fighting with sandbox deployment, most probably this is the issue.

Comment thread lti_consumer/api.py Outdated
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This save() fires on every call to get_or_create_local_lti_config, which includes every LTI 1.3 author view render in Studio (via get_lti_1p3_launch_data → config_id_for_block) and every LTI launch. That means an author simply opening a block to check the client ID triggers a DB write even when nothing has changed.

The fix is to only save when something actually changed:

  dirty = (                                               
      lti_config.config_store != config_store or                                                                                
      lti_config.external_id != block.external_config or
      lti_config.version != lti_version or                                                                                      
      lti_config.lti_1p3_passport != passport                                                                                   
  )                                                                                                                             
  if dirty:                                                                                                                     
      lti_config.save()                                                                                                         

The longer-term fix would be to move the reconciliation logic into an override of submit_studio_edits so it only fires when an author actually saves changes in Studio. That doesn't cover all cases though (imports, duplicates) so a dirty-check fallback would still be needed. Worth a follow-up ticket.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, even I am not happy about the DB changes on each render.

For now I have refactored the whole function with some help from AI. I still need to test it a bit more.

instance.get_or_create_lti_1p3_passport()


@receiver(SignalHandler.pre_item_delete if SignalHandler else [])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a pre_item_delete signal in the modulestore SignalHandler. Is this defined elsewhere?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is part of this PR: openedx/openedx-platform#38192, I have added this in the description as a dependency.

Comment thread lti_consumer/api.py Outdated
def _get_or_create_local_lti_config(lti_version, block_location,
config_store=LtiConfiguration.CONFIG_ON_XBLOCK, external_id=None):

def get_or_create_local_lti_config(lti_version, block, config_store=LtiConfiguration.CONFIG_ON_XBLOCK):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Does this need to be a public function? Seems like it's only called from a different private function and should remain an internal function (prefix with an underscore?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, at some point I used it outside but not needed anymore.

@feanil
Copy link
Copy Markdown
Contributor

feanil commented Apr 6, 2026

@navinkarkera let me know when you want me to take another Pass at reviewing this.

@navinkarkera navinkarkera force-pushed the navin/fal-4318/split-config branch from fe8f80a to f809795 Compare April 7, 2026 14:26
@navinkarkera
Copy link
Copy Markdown
Contributor Author

@feanil Yes, it is ready for another round.

Copy link
Copy Markdown
Contributor

@feanil feanil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR generally looks good, I'm going to do some more testing of it next week before final approval in-case I find any issues but no major new issues from my review.

What's the status of the openedx-platform PR? Are we ready to review/land that?

Comment thread lti_consumer/signals/signals.py Outdated
return

src_lti_config = LtiConfiguration.objects.get(location=str(xblock_data.source_usage_key))
copy = src_lti_config
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The pk=None will make this into a new object but it's a bit of a trick. Add a comment to say we're using it to duplicate this object without having to enumerate all the fields here and making this function more brittle.

There's also a risk that if we introduce a new generated key in the future like config_id that this function would not update that correctly. This is hypothetical so no need to code to defensively around it unless you can think of an easy way to do so. Nothing obvious comes to mind for me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored it to use model_to_dict and also handle possible errors. c2c7ec7

@navinkarkera navinkarkera force-pushed the navin/fal-4318/split-config branch from f809795 to 01fb5bf Compare April 13, 2026 11:23
@navinkarkera navinkarkera marked this pull request as ready for review April 13, 2026 14:51
@navinkarkera
Copy link
Copy Markdown
Contributor Author

What's the status of the openedx-platform PR? Are we ready to review/land that?

@feanil Yes.

@navinkarkera navinkarkera force-pushed the navin/fal-4318/split-config branch from 1a87017 to 5fae419 Compare April 20, 2026 06:39
As we add passport_id from database while exporting xml, we don't need to
add it to the block explicitly. This avoids modifying the xblock outside
of author edit cycle.
@navinkarkera
Copy link
Copy Markdown
Contributor Author

@feanil Thanks for the in depth review! I made a small change after your review here: 8e5b926
We don't need to modify xblock fields now that we add passport_id directly to the xml.

Comment thread CHANGELOG.rst Outdated
@feanil
Copy link
Copy Markdown
Contributor

feanil commented Apr 20, 2026

@navinkarkera I'm not actually sure that we should remove the save logic completely. Right now, the id does not get saved at all on the original block which seems like it wolud lead to future confusion. I think it's worth making sure that when we instantiate the LtiPassport, that it's passport_id makes it back to the block. The add_xml_to_node fix generally bypasses this now but if someone makes changes there assuming the id is also on the block, it could lead to errors in the future.

What do you think? I think this is okay to land as is also if you disagree but given how hard it is to reason through this code right now, my inclination is to make it more robust and unsurprising where possible.

@navinkarkera
Copy link
Copy Markdown
Contributor Author

@feanil I wanted to avoid calling save_xblock at that point as it is also called in LMS but I agree that it could be confusing in future. I'll revert the changes.

@navinkarkera navinkarkera force-pushed the navin/fal-4318/split-config branch from bc92fca to c0267e7 Compare April 20, 2026 13:52
@feanil
Copy link
Copy Markdown
Contributor

feanil commented Apr 20, 2026

Right, it makes sense to not have that call in the LMS and I guess it's really hard right now to differentiate the call from the LMS vs an instantiation of the block in studio? The critical thing is to save the id to the block when we create the new instance of the block in studio. This happens on copy paste but not on other instantiations.

@navinkarkera navinkarkera force-pushed the navin/fal-4318/split-config branch from c0267e7 to 45f83c0 Compare April 20, 2026 13:53
@feanil feanil merged commit 11d7df5 into openedx:master Apr 20, 2026
4 checks passed
@github-project-automation github-project-automation Bot moved this from Waiting on Author to Done in Contributions Apr 20, 2026
@feanil
Copy link
Copy Markdown
Contributor

feanil commented Apr 20, 2026

Merged and is being released now: https://github.com/openedx/xblock-lti-consumer/releases/tag/v11.0.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core contributor PR author is a Core Contributor (who may or may not have write access to this repo). FC Relates to an Axim Funded Contribution project open-source-contribution PR author is not from Axim or 2U

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants