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:
Nathan Mills 2018-05-14 15:32:44 -06:00
parent 8d9f8cb9ba
commit ec4f61746f
15 changed files with 297 additions and 37 deletions

3
.gitignore vendored
View File

@ -70,3 +70,6 @@ docker-compose.local.*
# pact artifacts
/reports/
/pacts/
# generated scope names
/lib/api_scope_mapper.rb

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
})

View File

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

View File

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

View File

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