Skip to content

Commit

Permalink
Add Lti Registration model
Browse files Browse the repository at this point in the history
refs INTEROP-7952

flags=none

why

This commit adds a table and matching ActiveRecord model to facilitate
Lti Registrations. This will be further used in building support for
Dynamic Registration.

test plan:

Make sure the migrations run, and the `lti_ims_registrations` table is
created.

Change-Id: I1d3f6b46d08de7dd68254553191de65fdf72138e
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/313519
Tested-by: Service Cloud Jenkins <[email protected]>
Reviewed-by: Xander Moffatt <[email protected]>
QA-Review: Xander Moffatt <[email protected]>
Migration-Review: Jacob Burroughs <[email protected]>
Product-Review: Paul Gray <[email protected]>
  • Loading branch information
pfgray committed Mar 22, 2023
1 parent 928a86a commit 6beb8cd
Show file tree
Hide file tree
Showing 7 changed files with 622 additions and 40 deletions.
1 change: 1 addition & 0 deletions app/models/developer_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def find_target

has_one :tool_consumer_profile, class_name: "Lti::ToolConsumerProfile", inverse_of: :developer_key
has_one :tool_configuration, class_name: "Lti::ToolConfiguration", dependent: :destroy, inverse_of: :developer_key
has_one :lti_registration, class_name: "Lti::IMS::Registration", dependent: :destroy, inverse_of: :developer_key
serialize :scopes, Array

before_validation :normalize_public_jwk_url
Expand Down
155 changes: 155 additions & 0 deletions app/models/lti/ims/registration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# frozen_string_literal: true

#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.

class Lti::IMS::Registration < ApplicationRecord
self.table_name = "lti_ims_registrations"

REQUIRED_GRANT_TYPES = ["client_credentials", "implicit"].freeze
REQUIRED_RESPONSE_TYPES = ["id_token"].freeze
REQUIRED_APPLICATION_TYPE = "web"
REQUIRED_TOKEN_ENDPOINT_AUTH_METHOD = "private_key_jwt"

validates :application_type,
:grant_types,
:response_types,
:redirect_uris,
:initiate_login_uri,
:client_name,
:jwks_uri,
:token_endpoint_auth_method,
:lti_tool_configuration,
:developer_key,
presence: true

validate :required_values_are_present,
:redirect_uris_contains_uris,
:lti_tool_configuration_is_valid,
:scopes_are_valid

validates :initiate_login_uri,
:jwks_uri,
:logo_uri,
:client_uri,
:tos_uri,
:policy_uri,
format: { with: URI::DEFAULT_PARSER.make_regexp(["http", "https"]) }, allow_blank: true

belongs_to :developer_key, inverse_of: :lti_registration

def new_external_tool(context, existing_tool: nil)
tool = existing_tool || ContextExternalTool.new(context: context)
Importers::ContextExternalToolImporter.import_from_migration(
importable_configuration,
context,
nil,
tool,
false
)
tool.developer_key = developer_key
tool.workflow_state = "active"
tool.use_1_3 = true
tool
end

private

def required_values_are_present
if (REQUIRED_GRANT_TYPES - grant_types).present?
errors.add(:grant_types, "Must include #{REQUIRED_GRANT_TYPES.join(", ")}")
end
if (REQUIRED_RESPONSE_TYPES - response_types).present?
errors.add(:response_types, "Must include #{REQUIRED_RESPONSE_TYPES.join(", ")}")
end

if token_endpoint_auth_method != REQUIRED_TOKEN_ENDPOINT_AUTH_METHOD
errors.add(:token_endpoint_auth_method, "Must be 'private_key_jwt'")
end

if application_type != REQUIRED_APPLICATION_TYPE
errors.add(:application_type, "Must be 'web'")
end
end

def redirect_uris_contains_uris
return if redirect_uris.all? { |uri| uri.match? URI::DEFAULT_PARSER.make_regexp(["http", "https"]) }

errors.add(:redirect_uris, "Must only contain valid URIs")
end

def scopes_are_valid
invalid_scopes = scopes - TokenScopes::LTI_SCOPES.keys
return if invalid_scopes.empty?

errors.add(:scopes, "Invalid scopes: #{invalid_scopes.join(", ")}")
end

def lti_tool_configuration_is_valid
config_errors = Schemas::Lti::IMS::LtiToolConfiguration.simple_validation_errors(
lti_tool_configuration,
error_format: :hash
)
return if config_errors.blank?

errors.add(
:lti_tool_configuration,
# Convert errors represented as a Hash to JSON
config_errors.is_a?(Hash) ? config_errors.to_json : config_errors
)
end

# TODO: this method of only supports message/placement properties defined in
# the Dynamic Registration specification. In the future we will need to add
# support for all our custom top-level and placement-level properties
# ("icon_url", "selection_height", etc.)
def importable_configuration
{
"title" => client_name,
"scopes" => scopes,
"settings" => {
"client_id" => global_developer_key_id
}.merge(importable_placements),
"public_jwk_url" => jwks_uri,
"description" => lti_tool_configuration["description"],
"custom_fields" => lti_tool_configuration["custom_parameters"],
"target_link_uri" => lti_tool_configuration["target_link_uri"],
"oidc_initiation_url" => initiate_login_uri,
# TODO: How do we want to handle privacy level?
"privacy_level" => "public",
"url" => lti_tool_configuration["target_link_uri"],
"domain" => lti_tool_configuration["domain"]
}
end

def importable_placements
lti_tool_configuration["messages"].each_with_object({}) do |message, hash|
# In an IMS Tool Registration, a single message can have multiple placements.
# To correctly import this, we need to duplicate the message for each desired
# placement.
message["placements"].each do |placement|
hash[placement] = {
"custom_fields" => message["custom_parameters"],
"message_type" => message["type"],
"placement" => placement,
"target_link_uri" => message["target_link_uri"],
"text" => message["label"]
}
end
end
end
end
106 changes: 68 additions & 38 deletions app/models/lti/resource_placement.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require "lti_advantage"

module Lti
class ResourcePlacement < ActiveRecord::Base
Expand All @@ -36,44 +37,73 @@ class ResourcePlacement < ActiveRecord::Base
# Default placements for LTI 1 and LTI 2, ignored for LTI 1.3
LEGACY_DEFAULT_PLACEMENTS = [ASSIGNMENT_SELECTION, LINK_SELECTION].freeze

PLACEMENTS = %i[account_navigation
similarity_detection
assignment_edit
assignment_menu
assignment_index_menu
assignment_group_menu
assignment_selection
assignment_view
collaboration
conference_selection
course_assignments_menu
course_home_sub_navigation
course_navigation
course_settings_sub_navigation
discussion_topic_menu
discussion_topic_index_menu
editor_button
file_menu
file_index_menu
global_navigation
homework_submission
link_selection
migration_selection
module_group_menu
module_index_menu
module_index_menu_modal
module_menu
module_menu_modal
post_grades
quiz_menu
quiz_index_menu
resource_selection
submission_type_selection
student_context_card
tool_configuration
user_navigation
wiki_index_menu
wiki_page_menu].freeze
PLACEMENTS_BY_MESSAGE_TYPE = {
LtiAdvantage::Messages::ResourceLinkRequest::MESSAGE_TYPE => %i[
account_navigation
assignment_edit
assignment_group_menu
assignment_index_menu
assignment_menu
assignment_selection
assignment_view
collaboration
conference_selection
course_assignments_menu
course_home_sub_navigation
course_navigation
course_settings_sub_navigation
discussion_topic_index_menu
discussion_topic_menu
file_index_menu
file_menu
global_navigation
homework_submission
link_selection
migration_selection
module_group_menu
module_index_menu
module_index_menu_modal
module_menu_modal
module_menu
post_grades
quiz_index_menu
quiz_menu
resource_selection
similarity_detection
student_context_card
submission_type_selection
tool_configuration
user_navigation
wiki_index_menu
wiki_page_menu
],
LtiAdvantage::Messages::DeepLinkingRequest::MESSAGE_TYPE => %i[
assignment_index_menu
assignment_menu
assignment_selection
collaboration
conference_selection
discussion_topic_index_menu
discussion_topic_menu
editor_button
file_menu
homework_submission
link_selection
migration_selection
module_index_menu
module_index_menu_modal
module_menu_modal
module_menu
quiz_index_menu
quiz_menu
resource_selection
submission_type_selection
wiki_index_menu
wiki_page_menu
]
}.freeze

PLACEMENTS = PLACEMENTS_BY_MESSAGE_TYPE.values.flatten.uniq.freeze

PLACEMENT_LOOKUP = {
"Canvas.placements.accountNavigation" => ACCOUNT_NAVIGATION,
Expand Down
52 changes: 52 additions & 0 deletions db/migrate/20230313161715_create_lti_ims_registrations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.

class CreateLtiIMSRegistrations < ActiveRecord::Migration[7.0]
tag :predeploy

def up
create_table :lti_ims_registrations do |t|
t.jsonb :lti_tool_configuration, null: false
t.references :developer_key, null: false, foreign_key: true, index: true
t.string :application_type, null: false
t.text :grant_types, array: true, default: [], null: false
t.text :response_types, array: true, default: [], null: false
t.text :redirect_uris, array: true, default: [], null: false
t.text :initiate_login_uri, null: false
t.string :client_name, null: false
t.text :jwks_uri, null: false
t.text :logo_uri
t.string :token_endpoint_auth_method, null: false
t.string :contacts, array: true, default: [], null: false, limit: 255
t.text :client_uri
t.text :policy_uri
t.text :tos_uri
t.text :scopes, array: true, default: [], null: false

t.references :root_account, foreign_key: { to_table: :accounts }, null: false, index: false
t.timestamps
end

add_replica_identity "Lti::IMS::Registration", :root_account_id, 0
end

def down
drop_table :lti_ims_registrations
end
end
13 changes: 11 additions & 2 deletions lib/schemas/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,20 @@ module Schemas
class Base
delegate :validate, :valid?, to: :schema_checker

def self.simple_validation_errors(json_hash)
def self.simple_validation_errors(json_hash, error_format: :string)
error = new.validate(json_hash).to_a.first
return nil if error.blank?

if error["data_pointer"].present?
return "#{error["data"]} #{error["data_pointer"]}. Schema: #{error["schema"]}"
if error_format == :hash
return {
error: error["data"],
field: error["data_pointer"],
schema: error["schema"]
}
else
return "#{error["data"]} #{error["data_pointer"]}. Schema: #{error["schema"]}"
end
end

"The following fields are required: #{error.dig("schema", "required").join(", ")}"
Expand Down
Loading

0 comments on commit 6beb8cd

Please sign in to comment.