Add Lti Registration model

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 <svc.cloudjenkins@instructure.com>
Reviewed-by: Xander Moffatt <xmoffatt@instructure.com>
QA-Review: Xander Moffatt <xmoffatt@instructure.com>
Migration-Review: Jacob Burroughs <jburroughs@instructure.com>
Product-Review: Paul Gray <paul.gray@instructure.com>
This commit is contained in:
Paul Gray 2023-03-14 21:07:08 -04:00
parent 928a86a289
commit 6beb8cdc4b
7 changed files with 622 additions and 40 deletions

View File

@ -41,6 +41,7 @@ class DeveloperKey < ActiveRecord::Base
has_one :tool_consumer_profile, class_name: "Lti::ToolConsumerProfile", inverse_of: :developer_key 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 :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 serialize :scopes, Array
before_validation :normalize_public_jwk_url before_validation :normalize_public_jwk_url

View File

@ -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

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU Affero General Public License along # 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/>. # with this program. If not, see <http://www.gnu.org/licenses/>.
# #
require "lti_advantage"
module Lti module Lti
class ResourcePlacement < ActiveRecord::Base class ResourcePlacement < ActiveRecord::Base
@ -36,12 +37,13 @@ module Lti
# Default placements for LTI 1 and LTI 2, ignored for LTI 1.3 # Default placements for LTI 1 and LTI 2, ignored for LTI 1.3
LEGACY_DEFAULT_PLACEMENTS = [ASSIGNMENT_SELECTION, LINK_SELECTION].freeze LEGACY_DEFAULT_PLACEMENTS = [ASSIGNMENT_SELECTION, LINK_SELECTION].freeze
PLACEMENTS = %i[account_navigation PLACEMENTS_BY_MESSAGE_TYPE = {
similarity_detection LtiAdvantage::Messages::ResourceLinkRequest::MESSAGE_TYPE => %i[
account_navigation
assignment_edit assignment_edit
assignment_menu
assignment_index_menu
assignment_group_menu assignment_group_menu
assignment_index_menu
assignment_menu
assignment_selection assignment_selection
assignment_view assignment_view
collaboration collaboration
@ -50,11 +52,10 @@ module Lti
course_home_sub_navigation course_home_sub_navigation
course_navigation course_navigation
course_settings_sub_navigation course_settings_sub_navigation
discussion_topic_menu
discussion_topic_index_menu discussion_topic_index_menu
editor_button discussion_topic_menu
file_menu
file_index_menu file_index_menu
file_menu
global_navigation global_navigation
homework_submission homework_submission
link_selection link_selection
@ -62,18 +63,47 @@ module Lti
module_group_menu module_group_menu
module_index_menu module_index_menu
module_index_menu_modal module_index_menu_modal
module_menu
module_menu_modal module_menu_modal
module_menu
post_grades post_grades
quiz_menu
quiz_index_menu quiz_index_menu
quiz_menu
resource_selection resource_selection
submission_type_selection similarity_detection
student_context_card student_context_card
submission_type_selection
tool_configuration tool_configuration
user_navigation user_navigation
wiki_index_menu wiki_index_menu
wiki_page_menu].freeze 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 = { PLACEMENT_LOOKUP = {
"Canvas.placements.accountNavigation" => ACCOUNT_NAVIGATION, "Canvas.placements.accountNavigation" => ACCOUNT_NAVIGATION,

View File

@ -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

View File

@ -22,12 +22,21 @@ module Schemas
class Base class Base
delegate :validate, :valid?, to: :schema_checker 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 error = new.validate(json_hash).to_a.first
return nil if error.blank? return nil if error.blank?
if error["data_pointer"].present? if error["data_pointer"].present?
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"]}" return "#{error["data"]} #{error["data_pointer"]}. Schema: #{error["schema"]}"
end end
end
"The following fields are required: #{error.dig("schema", "required").join(", ")}" "The following fields are required: #{error.dig("schema", "required").join(", ")}"
end end

View File

@ -0,0 +1,93 @@
# 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/>.
#
# Schema for a https://purl.imsglobal.org/spec/lti-tool-configuration
module Schemas::Lti::IMS
class LtiToolConfiguration < Schemas::Base
SCHEMA = {
"type" => "object",
"required" => %w[
domain
messages
claims
].freeze,
"properties" => {
"domain" => { "type" => "string" }.freeze,
"secondary_domains" => {
"type" => "array",
"items" => {
"type" => "string"
}.freeze
}.freeze,
"target_link_uri" => { "type" => "string" }.freeze,
"custom_parameters" => {
"type" => "object",
"additionalProperties" => {
"type" => "string"
}.freeze
}.freeze,
"description" => { "type" => "string" }.freeze,
"messages" => {
"type" => "array",
"items" => {
"type" => "object",
"required" => [
"type"
].freeze,
"properties" => {
"type" => {
"type" => "string",
"enum" => Lti::ResourcePlacement::PLACEMENTS_BY_MESSAGE_TYPE.keys
}.freeze,
"target_link_uri" => { "type" => "string" }.freeze,
"label" => { "type" => "string" }.freeze,
"icon_uri" => { "type" => "string" }.freeze,
"custom_parameters" => {
"type" => "object",
"additionalProperties" => {
"type" => "string"
}.freeze
}.freeze,
"placements" => {
"type" => "array",
"items" => {
"type" => "string",
"enum" => Lti::ResourcePlacement::PLACEMENTS.map(&:to_s)
}.freeze
}.freeze,
}.freeze
}
}.freeze,
"claims" => {
"type" => "array",
"items" => {
"type" => "string"
}.freeze
}.freeze,
}.freeze
}.freeze
TYPE = "https://purl.imsglobal.org/spec/lti-tool-configuration"
def schema
SCHEMA
end
end
end

View File

@ -0,0 +1,242 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - 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/>.
#
module Lti::IMS
describe Registration do
let(:application_type) { :web }
let(:grant_types) { [:client_credentials, :implicit] }
let(:response_types) { [:id_token] }
let(:redirect_uris) { ["http://example.com"] }
let(:initiate_login_uri) { "http://example.com/login" }
let(:client_name) { "Example Tool" }
let(:jwks_uri) { "http://example.com/jwks" }
let(:logo_uri) { "http://example.com/logo.png" }
let(:client_uri) { "http://example.com/" }
let(:tos_uri) { "http://example.com/tos" }
let(:policy_uri) { "http://example.com/policy" }
let(:token_endpoint_auth_method) { "private_key_jwt" }
let(:lti_tool_configuration) do
{
domain: "example.com",
messages: [],
claims: []
}
end
let(:scopes) { [] }
let(:registration) do
r = Registration.new({
application_type: application_type,
grant_types: grant_types,
response_types: response_types,
redirect_uris: redirect_uris,
initiate_login_uri: initiate_login_uri,
client_name: client_name,
jwks_uri: jwks_uri,
logo_uri: logo_uri,
client_uri: client_uri,
tos_uri: tos_uri,
policy_uri: policy_uri,
token_endpoint_auth_method: token_endpoint_auth_method,
lti_tool_configuration: lti_tool_configuration,
scopes: scopes
}.compact)
r.developer_key = developer_key
r
end
let(:developer_key) { DeveloperKey.create }
describe "validations" do
subject { registration.validate }
context "when valid" do
it { is_expected.to eq true }
end
context "application_type" do
context "is \"web\"" do
it { is_expected.to eq true }
end
context "is not \"web\"" do
let(:application_type) { "native" }
it { is_expected.to eq false }
end
context "is not included" do
let(:application_type) { nil }
it { is_expected.to eq false }
end
end
context "grant_types" do
context "includes other types" do
let(:grant_types) { %i[client_credentials implicit foo bar] }
it { is_expected.to eq true }
end
context "does not include implicit" do
let(:grant_types) { [:client_credentials, :foo] }
it { is_expected.to eq false }
end
context "does not include client_credentials" do
let(:grant_types) { [:implicit, :foo] }
it { is_expected.to eq false }
end
end
context "response_types" do
context "includes other types" do
let(:response_types) { %i[id_token foo bar] }
it { is_expected.to eq true }
end
context "is not included" do
let(:response_types) { nil }
it { is_expected.to eq false }
end
context "does not include id_token" do
let(:response_types) { [:foo, :bar] }
it { is_expected.to eq false }
end
end
context "redirect_uris" do
context "includes valid uris" do
let(:redirect_uris) { ["https://example.com", "https://example.com/foo"] }
it { is_expected.to eq true }
end
context "is not included" do
let(:redirect_uris) { nil }
it { is_expected.to eq false }
end
context "includes a non-url" do
let(:redirect_uris) { ["https://example.com", "asdf"] }
it { is_expected.to eq false }
end
end
context "initiate_login_uri" do
context "is not included" do
let(:initiate_login_uri) { nil }
it { is_expected.to eq false }
end
context "is a valid uri" do
let(:initiate_login_uri) { "http://example.com/login" }
it { is_expected.to eq true }
end
context "is not a valid uri" do
let(:initiate_login_uri) { "asdf" }
it { is_expected.to eq false }
end
end
context "client_name" do
context "is not included" do
let(:client_name) { nil }
it { is_expected.to eq false }
end
end
context "jwks_uri" do
context "is not included" do
let(:jwks_uri) { nil }
it { is_expected.to eq false }
end
context "is not a valid uri" do
let(:jwks_uri) { "asdf" }
it { is_expected.to eq false }
end
end
context "token_endpoint_auth_method" do
context "is not \"private_key_jwt\"" do
let(:token_endpoint_auth_method) { "asdf" }
it { is_expected.to eq false }
end
end
context "logo_uri" do
context "is not a valid uri" do
let(:logo_uri) { "asdf" }
it { is_expected.to eq false }
end
end
context "client_uri" do
context "is not a valid uri" do
let(:client_uri) { "asdf" }
it { is_expected.to eq false }
end
end
context "tos_uri" do
context "is not a valid uri" do
let(:tos_uri) { "asdf" }
it { is_expected.to eq false }
end
end
context "policy_uri" do
context "is not a valid uri" do
let(:policy_uri) { "asdf" }
it { is_expected.to eq false }
end
end
context "scopes" do
context "contains invalid scopes" do
let(:scopes) { ["asdf"] }
it { is_expected.to eq false }
end
end
end
end
end