add lookup class for scope resource names
fixes PLAT-3311 test plan: * run the rake task "doc:api" * request the scopes from api/v1/accounts/:account_id/scopes - you should get back a json object that includes the localized name * request the scopes from api/v1/accounts/:account_id/scopes passing the query param "group_by=resources_name" - you should get back a json object with the scopes grouped by localized resource_name Change-Id: I2cab1822baef7cdda6471096153d60d4f7fe1e2b Reviewed-on: https://gerrit.instructure.com/150233 Tested-by: Jenkins Reviewed-by: Marc Alan Phillips <mphillips@instructure.com> QA-Review: August Thornton <august@instructure.com> Product-Review: Jesse Poulos <jpoulos@instructure.com>
This commit is contained in:
parent
8d9f8cb9ba
commit
ec4f61746f
|
@ -70,3 +70,6 @@ docker-compose.local.*
|
|||
# pact artifacts
|
||||
/reports/
|
||||
/pacts/
|
||||
|
||||
# generated scope names
|
||||
/lib/api_scope_mapper.rb
|
||||
|
|
|
@ -29,6 +29,21 @@
|
|||
# "example": "courses",
|
||||
# "type": "string"
|
||||
# },
|
||||
# "resource_name": {
|
||||
# "description": "The localized resource name",
|
||||
# "example": "Courses",
|
||||
# "type": "string"
|
||||
# },
|
||||
# "controller": {
|
||||
# "description": "The controller the scope is associated to",
|
||||
# "example": "courses",
|
||||
# "type": "string"
|
||||
# },
|
||||
# "action": {
|
||||
# "description": "The controller action the scope is associated to",
|
||||
# "example": "index",
|
||||
# "type": "string"
|
||||
# },
|
||||
# "verb": {
|
||||
# "description": "The HTTP verb for the scope",
|
||||
# "example": "GET",
|
||||
|
@ -50,13 +65,22 @@ class ScopesApiController < ApplicationController
|
|||
# @API List scopes
|
||||
# A list of scopes that can be applied to developer keys and access tokens.
|
||||
#
|
||||
# @argument group_by [String, "resource"]
|
||||
# @argument group_by [String, "resource_name"]
|
||||
# The attribute to group the scopes by. By default no grouping is done.
|
||||
#
|
||||
# @returns [Scope]
|
||||
def index
|
||||
if authorized_action(@context, @current_user, :manage_role_overrides)
|
||||
scopes = params[:group_by] == "resource" ? TokenScopes::GROUPED_DETAILED_SCOPES : TokenScopes::DETAILED_SCOPES
|
||||
named_scopes = TokenScopes::DETAILED_SCOPES.each_with_object([]) do |frozen_scope, arr|
|
||||
scope = frozen_scope.dup
|
||||
api_scope_mapper_class = ApiScopeMapperLoader.load
|
||||
scope[:resource] ||= api_scope_mapper_class.lookup_resource(scope[:controller], scope[:action])
|
||||
scope[:resource_name] = api_scope_mapper_class.name_for_resource(scope[:resource])
|
||||
arr << scope if scope[:resource_name]
|
||||
scope
|
||||
end
|
||||
named_scopes = Canvas::ICU.collate_by(named_scopes) {|s| s[:resource_name]}
|
||||
scopes = params[:group_by] == "resource_name" ? named_scopes.group_by {|route| route[:resource_name]} : named_scopes
|
||||
render json: scopes
|
||||
end
|
||||
end
|
||||
|
|
|
@ -80,7 +80,7 @@ export default class DeveloperKeyScopesGroup extends React.Component {
|
|||
return (
|
||||
<Flex justifyItems="space-between">
|
||||
<FlexItem grow shrink>
|
||||
<Text>{I18n.t('%{scopeName} scopes', {scopeName: this.props.name})}</Text>
|
||||
<Text>{this.props.name}</Text>
|
||||
</FlexItem>
|
||||
<FlexItem padding="none none none medium" grow>{this.selectedMethods()}</FlexItem>
|
||||
</Flex>
|
||||
|
|
|
@ -144,7 +144,7 @@ export default class DeveloperKeyScopesList extends React.Component {
|
|||
<FlexItem grow shrink>
|
||||
{this.state.availableScopes.map(scopeGroup => {
|
||||
return Object.keys(scopeGroup).reduce((result, key) => {
|
||||
if (this.noFilter() || key.indexOf(this.props.filter.toLowerCase()) > -1) {
|
||||
if (this.noFilter() || key.toLowerCase().indexOf(this.props.filter.toLowerCase()) > -1) {
|
||||
result.push(
|
||||
<LazyLoad
|
||||
offset={1000}
|
||||
|
|
|
@ -208,7 +208,7 @@ actions.listDeveloperKeyScopesSet = selectedScopes => ({type: actions.LIST_DEVEL
|
|||
|
||||
actions.listDeveloperKeyScopes = accountId => dispatch => {
|
||||
dispatch(actions.listDeveloperKeyScopesStart())
|
||||
const url = `/api/v1/accounts/${accountId}/scopes?group_by=resource`
|
||||
const url = `/api/v1/accounts/${accountId}/scopes?group_by=resource_name`
|
||||
|
||||
axios
|
||||
.get(url)
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
#
|
||||
# Copyright (C) 2018 - 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 ApiScopeMappingWriter
|
||||
|
||||
attr_reader :resource_lookup
|
||||
|
||||
def initialize(resources)
|
||||
@resources = resources
|
||||
dir = File.dirname(__FILE__)
|
||||
@template = File.read(File.join(dir, '/scope_mapper_template.erb'))
|
||||
@output_file = Rails.root.join("lib", "api_scope_mapper.rb")
|
||||
@resource_lookup = {}
|
||||
end
|
||||
|
||||
def generate_scope_mapper
|
||||
mapping = generate_scopes_mapping(@resources)
|
||||
out = ERB.new(@template, nil, '-').result(binding)
|
||||
File.open(@output_file, 'w') {|file| file.write(out)}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_scopes_mapping(resources)
|
||||
resources.each_with_object({}) do |(name, controllers), hash|
|
||||
process_controllers(controllers, name, hash)
|
||||
end
|
||||
end
|
||||
|
||||
def process_controllers(controllers, name, resource_hash)
|
||||
controllers.each_with_object(resource_hash) do |controller, hash|
|
||||
scope_resource = controller.name.to_s.underscore.gsub('_controller', '')
|
||||
hash[scope_resource] = process_children(controller.children, name)
|
||||
end
|
||||
end
|
||||
|
||||
def process_children(children, name)
|
||||
children.each_with_object({}) do |child, hash|
|
||||
next unless api_method?(child)
|
||||
resource = name.parameterize.underscore.to_sym
|
||||
hash[child.name] = resource
|
||||
add_resource_lookup(resource, name)
|
||||
end
|
||||
end
|
||||
|
||||
def add_resource_lookup(resource, name)
|
||||
@resource_lookup[resource] = name
|
||||
end
|
||||
|
||||
def api_method?(child)
|
||||
child.tags.any? {|t| t.tag_name == "API"} && child.tags.none? {|t| t.tag_name.casecmp?("internal")}
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,52 @@
|
|||
#
|
||||
# Copyright (C) 2018 - 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/>.
|
||||
#
|
||||
|
||||
###########################################################################################
|
||||
### This file is auto generated, any changes made directly will be lost. ###
|
||||
### To regenerate this file run `bundle exec rake doc:api ###
|
||||
### ###
|
||||
### The template for this file is located here: ###
|
||||
### doc/api/fulldoc/html/api_scopes/scope_mapper_template.erb ###
|
||||
###########################################################################################
|
||||
|
||||
class ApiScopeMapper
|
||||
|
||||
SCOPE_MAP = {
|
||||
<% mapping.each_with_index do |(controller, children), i| -%>
|
||||
<%="#{controller}: {" %>
|
||||
<% children.each_with_index do |(action, resource), j| -%>
|
||||
<%= "#{action}: :#{resource}" %><%= "," if j < (children.size - 1) %>
|
||||
<% end -%>
|
||||
<%= "}.freeze" %><%= "," if i < (mapping.size - 1) %>
|
||||
<% end -%>
|
||||
}.freeze
|
||||
|
||||
RESOURCE_NAMES = {
|
||||
oauth2: -> {I18n.t('OAuth 2')},
|
||||
<%=resource_lookup.map { |k, v| "#{k}: -> {I18n.t('#{v}')}"}.join(",\n " ) %>
|
||||
}.freeze
|
||||
|
||||
def self.lookup_resource(controller, action)
|
||||
SCOPE_MAP.dig(controller, action)
|
||||
end
|
||||
|
||||
def self.name_for_resource(resource)
|
||||
RESOURCE_NAMES[resource]&.call
|
||||
end
|
||||
|
||||
end
|
|
@ -16,8 +16,10 @@
|
|||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
$:.unshift(File.join(File.dirname(__FILE__), 'swagger'))
|
||||
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'swagger'))
|
||||
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'api_scopes'))
|
||||
require 'controller_list_view'
|
||||
require 'api_scope_mapping_writer'
|
||||
|
||||
include Helpers::ModuleHelper
|
||||
include Helpers::FilterHelper
|
||||
|
@ -156,6 +158,8 @@ def init
|
|||
group_by { |o| o.tags('API').first.text }.
|
||||
sort_by { |o| o.first }
|
||||
generate_swagger_json
|
||||
scope_writer = ApiScopeMappingWriter.new(options[:resources])
|
||||
scope_writer.generate_scope_mapper
|
||||
|
||||
options[:page_title] = "Canvas LMS REST API Documentation"
|
||||
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
#
|
||||
# Copyright (C) 2018 - 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 ApiScopeMapperLoader
|
||||
|
||||
# The ApiScopeMapper is a generated file that we don't commit.
|
||||
# This method ensures that if the file doesn't exist specs and canvas won't blow up.
|
||||
def self.load
|
||||
unless File.exist?(Rails.root.join('lib', 'api_scope_mapper.rb')) || defined? ApiScopeMapper
|
||||
Object.const_set("ApiScopeMapper", api_scope_mapper_fallback)
|
||||
end
|
||||
ApiScopeMapper
|
||||
end
|
||||
|
||||
def self.api_scope_mapper_fallback
|
||||
klass = Class.new(Object)
|
||||
klass.class_eval do
|
||||
def self.lookup_resource(controller, _)
|
||||
controller
|
||||
end
|
||||
|
||||
def self.name_for_resource(resource)
|
||||
resource
|
||||
end
|
||||
end
|
||||
klass
|
||||
end
|
||||
|
||||
end
|
|
@ -18,7 +18,7 @@
|
|||
class TokenScopes
|
||||
OAUTH2_SCOPE_NAMESPACE = '/auth/'.freeze
|
||||
USER_INFO_SCOPE = {
|
||||
resource: "oauth",
|
||||
resource: :oauth2,
|
||||
verb: "GET",
|
||||
scope: "#{OAUTH2_SCOPE_NAMESPACE}userinfo"
|
||||
}.freeze
|
||||
|
@ -27,10 +27,11 @@ class TokenScopes
|
|||
routes = Rails.application.routes.routes.select { |route| /^\/api\/(v1|sis)/ =~ route.path.spec.to_s }.map do |route|
|
||||
path = route.path.spec.to_s.gsub(/\(\.:format\)$/, '')
|
||||
{
|
||||
resource: route.defaults[:controller],
|
||||
controller: route.defaults[:controller]&.to_sym,
|
||||
action: route.defaults[:action]&.to_sym,
|
||||
verb: route.verb,
|
||||
path: path,
|
||||
scope: "url:#{route.verb}|#{path}".freeze
|
||||
scope: "url:#{route.verb}|#{path}".freeze,
|
||||
}
|
||||
end
|
||||
routes.uniq {|route| route[:scope]}
|
||||
|
@ -41,5 +42,4 @@ class TokenScopes
|
|||
SCOPES = API_ROUTES.map { |route| route[:scope] }.freeze
|
||||
ALL_SCOPES = [USER_INFO_SCOPE[:scope], *SCOPES].freeze
|
||||
DETAILED_SCOPES = [USER_INFO_SCOPE, *API_ROUTES].freeze
|
||||
GROUPED_DETAILED_SCOPES = DETAILED_SCOPES.group_by {|route| route[:resource]}.freeze
|
||||
end
|
||||
|
|
|
@ -19,6 +19,10 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
|
||||
|
||||
describe ScopesApiController, type: :request do
|
||||
|
||||
# We want to force the usage of the fallback scope mapper here, not the generated version
|
||||
Object.const_set("ApiScopeMapper", ApiScopeMapperLoader.api_scope_mapper_fallback)
|
||||
|
||||
describe "index" do
|
||||
before do
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?).and_return(false)
|
||||
|
@ -44,14 +48,24 @@ describe ScopesApiController, type: :request do
|
|||
end
|
||||
|
||||
it "returns expected scopes" do
|
||||
json = api_call(:get, api_url, scope_params)
|
||||
expect(json).to match_array TokenScopes::DETAILED_SCOPES.as_json
|
||||
json = api_call(:get, "/api/v1/accounts/#{@account.id}/scopes", scope_params)
|
||||
expect(json).to include({
|
||||
"resource"=>"oauth2",
|
||||
"verb"=>"GET",
|
||||
"scope"=>"/auth/userinfo",
|
||||
"resource_name"=>"oauth2"
|
||||
})
|
||||
end
|
||||
|
||||
it "groups scopes when group_by is passed in" do
|
||||
scope_params[:group_by] = "resource"
|
||||
json = api_call(:get, api_url, scope_params)
|
||||
expect(json).to match_array TokenScopes::GROUPED_DETAILED_SCOPES.as_json
|
||||
scope_params[:group_by] = "resource_name"
|
||||
json = api_call(:get, "/api/v1/accounts/#{@account.id}/scopes", scope_params)
|
||||
expect(json["oauth2"]).to eq [{
|
||||
"resource"=>"oauth2",
|
||||
"verb"=>"GET",
|
||||
"scope"=>"/auth/userinfo",
|
||||
"resource_name"=>"oauth2"
|
||||
}]
|
||||
end
|
||||
|
||||
it "returns 403 when feature flag is disabled" do
|
||||
|
@ -71,7 +85,12 @@ describe ScopesApiController, type: :request do
|
|||
scope_params.merge(account_id: Account.site_admin.id)
|
||||
)
|
||||
|
||||
expect(json).to match_array TokenScopes::DETAILED_SCOPES.as_json
|
||||
expect(json).to include({
|
||||
"resource"=>"oauth2",
|
||||
"verb"=>"GET",
|
||||
"scope"=>"/auth/userinfo",
|
||||
"resource_name"=>"oauth2"
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -86,4 +105,4 @@ describe ScopesApiController, type: :request do
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -95,7 +95,7 @@ test('getRemainingInheritedDeveloperKeys requests keys from the specified URL wi
|
|||
test('listDeveloperKeyScopes makes a request to the scopes endpoint', () => {
|
||||
const getStub = sinon.stub(axios, 'get').returns(thenStub())
|
||||
actions.listDeveloperKeyScopes(1)(store.dispatch)
|
||||
ok(getStub.calledWith('/api/v1/accounts/1/scopes?group_by=resource'))
|
||||
ok(getStub.calledWith('/api/v1/accounts/1/scopes?group_by=resource_name'))
|
||||
axios.get.restore()
|
||||
})
|
||||
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
#
|
||||
# Copyright (C) 2011 - 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/>.
|
||||
#
|
||||
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../sharding_spec_helper')
|
||||
|
||||
describe ApiScopeMapperLoader do
|
||||
|
||||
let(:resource) {"users"}
|
||||
|
||||
describe ".load" do
|
||||
|
||||
it "loads the ApiScopeMapper file if present" do
|
||||
fallback_class = ApiScopeMapperLoader.api_scope_mapper_fallback
|
||||
Object.const_set("ApiScopeMapper", fallback_class)
|
||||
allow(File).to receive(:exist?).and_return(true)
|
||||
expect(ApiScopeMapperLoader.load).to eq(fallback_class)
|
||||
end
|
||||
|
||||
it "loads the api_scope_mapper_fallback if the file is not present" do
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
api_scope_mapper = ApiScopeMapperLoader.load
|
||||
expect(api_scope_mapper.name_for_resource(resource)).to eq resource
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
describe ".api_scope_mapper_fallback" do
|
||||
|
||||
it "creates a ApiScopeMapper Class with a lookup_resource method" do
|
||||
api_scope_mapper_fallback = ApiScopeMapperLoader.api_scope_mapper_fallback
|
||||
expect(api_scope_mapper_fallback.lookup_resource(resource, "not_used")).to eq(resource)
|
||||
end
|
||||
|
||||
it "creates a ApiScopeMapper Class with a name_for_resource method" do
|
||||
api_scope_mapper_fallback = ApiScopeMapperLoader.api_scope_mapper_fallback
|
||||
expect(api_scope_mapper_fallback.name_for_resource(resource)).to eq(resource)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
|
@ -34,21 +34,4 @@ describe TokenScopes do
|
|||
|
||||
end
|
||||
|
||||
describe "GROUPED_SCOPES" do
|
||||
it "groups the scopes by controller" do
|
||||
expect(TokenScopes::GROUPED_DETAILED_SCOPES["demos"]).to include({
|
||||
resource: "demos",
|
||||
verb: "POST",
|
||||
path: "/api/v1/demos",
|
||||
scope: "url:POST|/api/v1/demos"
|
||||
})
|
||||
|
||||
end
|
||||
|
||||
it "includes the user_info scope" do
|
||||
expect(TokenScopes::GROUPED_DETAILED_SCOPES["oauth"]).to include TokenScopes::USER_INFO_SCOPE
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -18,6 +18,9 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../common')
|
||||
|
||||
describe 'Developer Keys' do
|
||||
# We want to force the usage of the fallback scope mapper here, not the generated version
|
||||
Object.const_set("ApiScopeMapper", ApiScopeMapperLoader.api_scope_mapper_fallback)
|
||||
|
||||
include_context 'in-process server selenium tests'
|
||||
|
||||
describe 'with developer key management UI rewrite feature flag' do
|
||||
|
@ -327,8 +330,13 @@ describe 'Developer Keys' do
|
|||
def expand_scope_group_by_filter(scope)
|
||||
get "/accounts/#{Account.default.id}/developer_keys"
|
||||
find_button("Developer Key").click
|
||||
filter_scopes_by_name(scope)
|
||||
fj(".toggle-scope-group span:contains('#{scope}')").click
|
||||
end
|
||||
|
||||
def filter_scopes_by_name(scope)
|
||||
f("input[placeholder='Search endpoints']").clear
|
||||
f("input[placeholder='Search endpoints']").send_keys scope
|
||||
fj(".toggle-scope-group span:contains('#{scope} scopes')").click
|
||||
end
|
||||
|
||||
it "allows filtering by scope group name" do
|
||||
|
@ -385,8 +393,9 @@ describe 'Developer Keys' do
|
|||
click_scope_group_checkbox
|
||||
find_button("Save Key").click
|
||||
click_edit_icon
|
||||
filter_scopes_by_name 'assignment_groups_api'
|
||||
click_scope_group_checkbox
|
||||
f("input[placeholder='Search endpoints']").send_keys 'account_domain_lookups'
|
||||
filter_scopes_by_name 'account_domain_lookups'
|
||||
click_scope_group_checkbox
|
||||
dk = DeveloperKey.last
|
||||
find_button("Save Key").click
|
||||
|
|
Loading…
Reference in New Issue