canvas-lms/lib/microsoft_sync/graph_service.rb

284 lines
10 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/>.
#
#
# Client to access Microsoft's Graph API, used to administer groups and teams
# in the MicrosoftSync project (see app/models/microsoft_sync/group.rb). Make
# a new client with `GraphService.new(tenant_name)`
#
# This class is a lower-level interface, akin to what a Microsoft API gem which
# provide, which has no knowledge of Canvas models. So many operations will be
# used via GraphServiceHelpers, which does have knowledge of Canvas models.
#
module MicrosoftSync
class GraphService
DIRECTORY_OBJECT_PREFIX = 'https://graph.microsoft.com/v1.0/directoryObjects/'
GROUP_USERS_BATCH_SIZE = 20
attr_reader :http
delegate :request, :expand_options, :get_paginated_list, :run_batch, :quote_value, to: :http
def initialize(tenant, extra_statsd_tags)
@http = MicrosoftSync::GraphService::Http.new(tenant, extra_statsd_tags)
end
# ENDPOINTS:
# === Education Classes: ===
# Yields (results, next_link) for each page, or returns first page of results if no block given.
def list_education_classes(options = {}, &blk)
get_paginated_list(
'education/classes',
quota: [1, 0],
special_cases: [
SpecialCase.new(
400, /Education_ObjectType.*does not exist as.*property/,
result: Errors::NotEducationTenant
)
],
**options, &blk
)
end
def create_education_class(params)
request(:post, 'education/classes', quota: [1, 1], body: params)
end
# === Groups: ===
def update_group(group_id, params)
request(:patch, "groups/#{group_id}", quota: [1, 1], body: params)
end
# Used for debugging. Example:
# get_group('id', select: %w[microsoft_EducationClassLmsExt microsoft_EducationClassSisExt])
def get_group(group_id, options = {})
request(:get, "groups/#{group_id}", quota: [1, 0], query: expand_options(**options))
end
# Yields (results, next_link) for each page, or returns first page of results if no block given.
def list_group_members(group_id, options = {}, &blk)
get_paginated_list("groups/#{group_id}/members", quota: [3, 0], **options, &blk)
end
# Yields (results, next_link) for each page, or returns first page of results if no block given.
def list_group_owners(group_id, options = {}, &blk)
get_paginated_list("groups/#{group_id}/owners", quota: [2, 0], **options, &blk)
end
BATCH_REMOVE_GROUP_USERS_SPECIAL_CASES = [
SpecialCase.new(
404, /does not exist or one of its queried reference-property objects are not present/i,
result: :ignored
),
SpecialCase.new(
400, /One or more removed object references do not exist for the following modified/i,
result: :ignored
),
SpecialCase.new(
400, /must have at least one owner, hence this owner cannot be removed/i,
result: Errors::MissingOwners
),
].freeze
# Returns nil if all removed, or a hash with a list of :members and/or :owners that did
# not exist in the group (e.g. {owners: ['a', 'b'], members: ['c']} or {owners: ['a']}
# NOTE: Microsoft API does not distinguish between a group not existing, a
# user not existing, and an owner not existing in the group. If the group
# doesn't exist, this will return the full lists of members and owners
# passed in.
def remove_group_users_ignore_missing(group_id, members: [], owners: [])
check_group_users_args(members, owners)
reqs =
group_remove_user_requests(group_id, members, 'members') +
group_remove_user_requests(group_id, owners, 'owners')
quota = [reqs.count, reqs.count]
ignored_request_ids = run_batch(
'group_remove_users',
reqs,
quota: quota,
special_cases: BATCH_REMOVE_GROUP_USERS_SPECIAL_CASES
).keys
split_request_ids_to_hash(ignored_request_ids)
end
BATCH_ADD_USERS_TO_GROUP_SPECIAL_CASES = [
SpecialCase.new(
400, /One or more added object references already exist/i,
result: :ignored
),
SpecialCase.new(
403, /would exceed the maximum quota count.*for forward-link.*owners/i,
result: Errors::OwnersQuotaExceeded
),
SpecialCase.new(
403, /would exceed the maximum quota count.*for forward-link.*members/i,
result: Errors::MembersQuotaExceeded
),
].freeze
# Returns {owners: ['a', 'b', 'c'], members: ['d', 'e', 'f']} if there are owners
# or members not added. If all were added successfully, returns nil.
def add_users_to_group_via_batch(group_id, members, owners)
reqs =
group_add_user_requests(group_id, members, 'members') +
group_add_user_requests(group_id, owners, 'owners')
ignored_request_ids = run_batch(
'group_add_users',
reqs,
quota: [reqs.count, reqs.count],
special_cases: BATCH_ADD_USERS_TO_GROUP_SPECIAL_CASES
).keys
split_request_ids_to_hash(ignored_request_ids)
end
ADD_USERS_TO_GROUP_SPECIAL_CASES = [
SpecialCase.new(
400, /One or more added object references already exist/i,
result: :fallback_to_batch
),
# If a group has 81 owners, and we try to add 20 owners, but some or all
# of 20 owners are already in the group, Microsoft returns the "maximum
# quota count" error instead of the above "object references already
# exist" error -- even if adding only the non-duplicate users wouldn't
# push the total number over the maximum (100). In that case, fallback to
# batch requests, which do not have this problem.
SpecialCase.new(
403, /would exceed the maximum quota count.*for forward-link.*owners/i,
result: :fallback_to_batch
),
SpecialCase.new(
403, /would exceed the maximum quota count.*for forward-link.*members/i,
result: :fallback_to_batch
),
].freeze
# Returns nil if all added, or a hash with a list of :members and/or :owners that already
# existed in the group (e.g. {owners: ['a', 'b'], members: ['c']} or {owners: ['a']}
def add_users_to_group_ignore_duplicates(group_id, members: [], owners: [])
check_group_users_args(members, owners)
body = {
'members@odata.bind' => members.map { |m| DIRECTORY_OBJECT_PREFIX + m },
'owners@odata.bind' => owners.map { |o| DIRECTORY_OBJECT_PREFIX + o }
}.reject { |_k, users| users.empty? }
# Irregular write cost of adding members, about users_added/3, according to Microsoft.
write_quota = ((members.length + owners.length) / 3.0).ceil
response = request(
:patch, "groups/#{group_id}",
body: body,
quota: [1, write_quota],
special_cases: ADD_USERS_TO_GROUP_SPECIAL_CASES
)
if response == :fallback_to_batch
add_users_to_group_via_batch(group_id, members, owners)
end
end
# Maps requests ids, e.g. ["members_a", "members_b", "owners_a"]
# to a hash like {members: %w[a b], owners: %w[a]}
def split_request_ids_to_hash(req_ids)
return nil if req_ids.blank?
req_ids
.group_by { |id| id.split("_").first.to_sym }
.transform_values { |ids| ids.map { |id| id.split("_").last } }
end
# === Teams ===
TEAM_EXISTS_SPECIAL_CASES = [
SpecialCase.new(404, result: :not_found)
].freeze
def team_exists?(team_id)
request(:get, "teams/#{team_id}", special_cases: TEAM_EXISTS_SPECIAL_CASES) != :not_found
end
CREATE_EDUCATION_CLASS_TEAM_SPECIAL_CASES = [
SpecialCase.new(
400, /have one or more owners in order to create a Team/i,
result: MicrosoftSync::Errors::GroupHasNoOwners
),
SpecialCase.new(
409, /group is already provisioned/i,
result: MicrosoftSync::Errors::TeamAlreadyExists
)
].freeze
def create_education_class_team(group_id)
body = {
"template@odata.bind" =>
"https://graph.microsoft.com/v1.0/teamsTemplates('educationClass')",
"group@odata.bind" =>
"https://graph.microsoft.com/v1.0/groups(#{quote_value(group_id)})"
}
# Use special_cases exceptions so they use statsd "expected" counters
request(:post, 'teams', body: body, special_cases: CREATE_EDUCATION_CLASS_TEAM_SPECIAL_CASES)
end
# === Users ===
def list_users(options = {}, &blk)
get_paginated_list('users', quota: [2, 0], **options, &blk)
end
# ==== Helpers for removing and adding in batch ===
def check_group_users_args(members, owners)
raise ArgumentError, 'Missing members/owners' if members.empty? && owners.empty?
if (n_total_additions = members.length + owners.length) > GROUP_USERS_BATCH_SIZE
raise ArgumentError, "Only #{GROUP_USERS_BATCH_SIZE} users can be batched at " \
"once. Got #{n_total_additions}."
end
end
def group_add_user_requests(group_id, user_aad_ids, members_or_owners)
user_aad_ids.map do |aad_id|
{
id: "#{members_or_owners}_#{aad_id}",
url: "/groups/#{group_id}/#{members_or_owners}/$ref",
method: 'POST',
body: { "@odata.id": DIRECTORY_OBJECT_PREFIX + aad_id },
headers: { 'Content-Type' => 'application/json' }
}
end
end
def group_remove_user_requests(group_id, user_aad_ids, members_or_owners)
user_aad_ids.map do |aad_id|
{
id: "#{members_or_owners}_#{aad_id}",
url: "/groups/#{group_id}/#{members_or_owners}/#{aad_id}/$ref",
method: 'DELETE'
}
end
end
end
end