canvas-lms/lib/microsoft_sync/graph_service_helpers.rb

154 lines
5.4 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2021 - 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/>.
#
#
# Wraps GraphService, which provides lower-level access to the Microsoft Graph API, with
# functions specific to canvas models and the particular Microsoft API fields we use.
#
module MicrosoftSync
class GraphServiceHelpers
attr_reader :graph_service
class UnexpectedResponseError < Errors::PublicError
def self.public_message
I18n.t("Unexpected response from Microsoft API. This is likely a bug. " \
"Please contact support.")
end
end
MAX_MAIL_NICKNAME_LENGTH = 64
GET_GROUP_USERS_BATCH_SIZE = 999 # Max batch size to minimize API calls
def initialize(tenant, extra_statsd_tags)
@graph_service = GraphService.new(tenant, extra_statsd_tags)
end
def list_education_classes_for_course(course)
graph_service.education_classes.list(filter: { externalId: course.uuid })
end
# Returns the hash of the new course, including the 'id' key
def create_education_class(course)
graph_service.education_classes.create(
description: course.public_description.presence&.truncate(1024),
displayName: course.name,
externalId: course.uuid,
externalName: course.name,
externalSource: "manual",
mailNickname: mail_nickname_for(course)
)
end
def update_group_with_course_data(ms_group_id, course)
graph_service.groups.update(
ms_group_id,
microsoft_EducationClassLmsExt: {
ltiContextId: course.lti_context_id || Lti::Asset.opaque_identifier_for(course),
lmsCourseId: course.uuid,
lmsCourseName: course.name,
lmsCourseDescription: course.public_description&.truncate(256),
},
microsoft_EducationClassSisExt: {
sisCourseId: course.sis_source_id,
}
)
end
USERS_ULUVS_TO_AADS_BATCH_SIZE = 15 # Max number of "OR"s in filter clause
# Returns a hash from ULUV -> AAD. Accepts 15 at a time. A ULUV (User
# LookUp Value) is a value we use to look up users. It is derived from
# something in Canvas (e.g. a user's email address, username, or SIS ID
# -- see UsersUluvsFinder). We expect the ULUV to correspond to the
# property of the Microsoft user indicated by the `remote_attribute`
# argument: e.g. userPrincipalName (default if nil is passed) or
# mailNickname.
# We then return a hash of ULUV -> AAD object ID. An AAD [Azure Active
# Directory] object ID, referred to here as just an "aad", is the ID for
# the user on the Microsoft side, which is what Microsoft references in
# their groups/teams.
#
# The properties on Microsoft's user objects are case-insensitive, so this
# method downcases and uniqs the ULUVs before requesting them from
# Microsoft. But whatever casing the Microsoft response uses, this function
# makes sure the keys in the return hash match the case of the ULUVs that
# were passed in.
def users_uluvs_to_aads(remote_attribute, uluvs)
remote_attribute ||= "userPrincipalName"
downcased_uniqued = uluvs.map(&:downcase).uniq
if downcased_uniqued.length > USERS_ULUVS_TO_AADS_BATCH_SIZE
raise ArgumentError, "Can't look up #{uluvs.length} ULUVs at once"
end
uluvs_downcased_to_given_forms = uluvs.group_by(&:downcase)
unexpected = []
result_hash = {}
graph_service.users.list(
select: ["id", remote_attribute],
filter: { remote_attribute => downcased_uniqued }
).each do |user_object|
given_forms = uluvs_downcased_to_given_forms[user_object[remote_attribute].downcase]
if given_forms
given_forms.each do |given_form|
result_hash[given_form] = user_object["id"]
end
else
unexpected << user_object[remote_attribute]
end
end
if unexpected.present?
raise UnexpectedResponseError,
"/users returned users with unexpected #{remote_attribute} values " \
"#{unexpected.inspect}, asked for #{downcased_uniqued}"
end
result_hash
end
def get_group_users_aad_ids(group_id, owners: false)
method = owners ? :list_owners : :list_members
[].tap do |aad_ids|
graph_service.groups.send(
method, group_id, select: ["id"], top: GET_GROUP_USERS_BATCH_SIZE
) do |users|
aad_ids.concat(users.pluck("id"))
end
end
end
private
def mail_nickname_for(course)
prefix = "Course_"
postfix = "-#{course.uuid.first(13)}"
safe_course_code = course.course_code.strip.parameterize.underscore.first(
MAX_MAIL_NICKNAME_LENGTH - (postfix.length + prefix.length)
)
"#{prefix}#{safe_course_code}#{postfix}"
end
end
end