canvas-lms/lib/services/address_book.rb

294 lines
11 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2016 - 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 Services
class AddressBook
# regarding these methods' parameters or options, generally:
#
# * `sender` is a User or a user ID
# * `context` is an asset string ('course_123', or optionally subscoped
# such as 'course_123_teachers') or a Course, CourseSection, or Group
# * `users` is a list of Users or user IDs
# * `search` is a string
# * `exclude` is a list of Users or of user IDs
# * `weak_checks` is a truthy/falsey value
#
# which of the users does the sender know, and what contexts do they and
# the sender have in common?
def self.common_contexts(sender, users, ignore_result = false)
recipients(sender:, user_ids: users, ignore_result:).common_contexts
end
# which of the users have roles in the context and what are those roles?
def self.roles_in_context(context, users, ignore_result = false)
context = context.course if context.is_a?(CourseSection)
recipients(context:, user_ids: users, ignore_result:).common_contexts
end
# which users:
#
# - does the sender know in the context and what are their roles in that
# context? (sender present)
#
# --OR--
#
# - have roles in the context and what are those roles? (sender absent;
# admin view)
#
def self.known_in_context(sender, context, user_ids = nil, ignore_result = false)
params = { sender:, context:, ignore_result: }
params[:user_ids] = user_ids if user_ids
response = recipients(params)
[response.user_ids, response.common_contexts]
end
# how many users does the sender know in each of the contexts?
def self.count_in_contexts(sender, contexts, ignore_result = false)
counts = count_recipients(sender:, contexts:, ignore_result:)
# map back from normalized to argument
contexts.each do |ctx|
serialized = serialize_context(ctx)
if serialized != ctx
counts[ctx] = counts.delete(serialized)
end
end
counts
end
# of the users who are not in `exclude_ids` and whose name matches the
# `search` term, if any, which:
#
# - does the sender know, and what are their common contexts with the
# sender? (no context provided, sender must be)
#
# - does the sender know in the context and what are their roles in that
# context? (context provided with sender)
#
# --OR--
#
# - have roles in the context and what are those roles? (context provided
# without sender; admin view)
#
def self.search_users(sender, options, service_options, ignore_result = false)
params = options.slice(:search, :context, :exclude_ids, :weak_checks)
params[:ignore_result] = ignore_result
params[:sender] = sender
# interpret pagination as specified in service_options
params[:per_page] = service_options[:per_page] if service_options[:per_page]
params[:cursor] = service_options[:cursor] if service_options[:cursor]
# call out to service
response = recipients(params)
# interpret response
[response.user_ids, response.common_contexts, response.cursors]
end
def self.recipients(params)
Response.new(fetch("/recipients", query_params(params)))
end
def self.count_recipients(params)
return {} if params[:contexts].blank?
fetch("/recipients/counts", query_params(params))["counts"] || {}
end
def self.jwt # public only for testing, should not be used directly
Canvas::Security.create_jwt({ iat: Time.now.to_i }, nil, jwt_secret, :HS512)
rescue => e
Canvas::Errors.capture_exception(:address_book, e)
nil
end
class << self
private
def setting(key)
DynamicSettings.find("address-book", default_ttl: 5.minutes)[key, failsafe: nil]
end
def app_host
setting("app-host")
end
def jwt_secret
Canvas::Security.base64_decode(setting("secret"))
end
# generic retrieve, parse
def fetch(path, params = {})
url = app_host + path
url += "?" + params.to_query unless params.empty?
fallback = { "records" => [] }
timeout_service_name = if params[:ignore_result] == 1
"address_book_performance_tap"
else
"address_book"
end
Canvas.timeout_protection(timeout_service_name) do
response = CanvasHttp.get(url, { "Authorization" => "Bearer #{jwt}" })
if ![200, 202].include?(response.code.to_i)
err = CanvasHttp::InvalidResponseCodeError.new(response.code.to_i)
data = {
extra: { url:, response: response.body },
tags: { type: "address_book_fault" }
}
Canvas::Errors.capture(err, data, :warn)
return fallback
elsif params[:ignore_result] == 1
return fallback
else
return JSON.parse(response.body)
end
end || fallback
end
# serialize logical params into query string values
def query_params(params = {})
query_params = {}
query_params[:cursor] = params[:cursor] if params[:cursor]
query_params[:per_page] = params[:per_page] if params[:per_page]
query_params[:search] = params[:search] if params[:search]
if params[:sender]
sender = params[:sender]
sender = User.find(sender) unless sender.is_a?(User)
visible_accounts = sender.associated_accounts.select { |account| account.grants_right?(sender, :read_roster) }
restricted_courses = sender.all_courses.reject { |course| course.grants_right?(sender, :send_messages) }
query_params[:for_sender] = serialize_item(sender)
query_params[:visible_account_ids] = serialize_list(visible_accounts) unless visible_accounts.empty?
query_params[:restricted_course_ids] = serialize_list(restricted_courses) unless restricted_courses.empty?
end
query_params[:in_context] = serialize_context(params[:context]) if params[:context]
if params[:contexts]
contexts = params[:contexts].map { |ctx| serialize_context(ctx) }
query_params[:in_contexts] = contexts.join(",")
end
query_params[:user_ids] = serialize_list(params[:user_ids]) if params[:user_ids]
query_params[:exclude_ids] = serialize_list(params[:exclude_ids]) if params[:exclude_ids]
query_params[:weak_checks] = 1 if params[:weak_checks]
query_params[:ignore_result] = 1 if params[:ignore_result]
query_params
end
def serialize_item(item)
Shard.global_id_for(item)
end
def serialize_list(list) # can be either IDs or objects (e.g. User)
list.map { |item| serialize_item(item) }.join(",")
end
def serialize_context(context)
if context.respond_to?(:global_asset_string)
context.global_asset_string
else
context_type, context_id, scope = context.split("_", 3)
global_context_id = serialize_item(context_id)
asset_string = "#{context_type}_#{global_context_id}"
asset_string += "_#{scope}" if scope
asset_string
end
end
end
# /recipients returns data in the (JSON) shape:
#
# {
# records: [
# {
# 'user_id': '10000000000002',
# 'contexts': [
# { 'context_type': 'course', 'context_id': '10000000000001', 'roles': ['TeacherEnrollment'] }
# ],
# cursor: ...
# },
# {
# 'user_id': '10000000000005',
# 'contexts': [
# { 'context_type': 'course', 'context_id': '10000000000002', 'roles': ['StudentEnrollment'] },
# { 'context_type': 'group', 'context_id': '10000000000001', 'roles': ['Member'] }
# ],
# cursor: ...
# }
# ],
# ...
# }
#
# where `user_id` is a string representation of the recipient's global
# user ID, `contexts` is a list of contexts they have in common with
# the sender, and `cursor` is the cursor to pass to start at the next
# record. each context states the type, id (again as a string
# representation of the global ID), and roles the recipient has in that
# context (to the knowledge of the sender).
#
# this class facilitates separating those pieces
class Response
def initialize(response)
@response = response
end
# extract just the user IDs from the response, as an ordered list
def user_ids
@response["records"].map { |record| record["user_id"].to_i }
end
# reshape the records into a ruby hash with integers instead of strings
# for IDs (but still global), user_ids promoted to keys, and context
# types collated. e.g. for the above example, the transformed data would
# have the (ruby) shape:
#
# {
# 10000000000002 => {
# courses: { 10000000000001 => ['TeacherEnrollment'] },
# groups: {}
# },
# 10000000000005 => {
# courses: { 10000000000002 => ['StudentEnrollment'] },
# groups: { 10000000000001 => ['Member'] }
# }
# }
#
def common_contexts
common_contexts = {}
@response["records"].each do |recipient|
global_user_id = recipient["user_id"].to_i
contexts = recipient["contexts"]
common_contexts[global_user_id] ||= { courses: {}, groups: {} }
contexts.each do |context|
context_type = context["context_type"].pluralize.to_sym
next unless common_contexts[global_user_id].key?(context_type)
global_context_id = context["context_id"].to_i
common_contexts[global_user_id][context_type][global_context_id] = context["roles"]
end
end
common_contexts
end
# extract the next page cursor from the response
def cursors
@response["records"].pluck("cursor")
end
end
end
end