FEATURE: Add scopes to API keys (#9844)

* Added scopes UI

* Create scopes when creating a new API key

* Show scopes on the API key show route

* Apply scopes on API requests

* Extend scopes from plugins

* Add missing scopes. A mapping can be associated with multiple controller actions

* Only send scopes if the use global key option is disabled. Use the discourse plugin registry to add new scopes

* Add not null validations and index for api_key_id

* Annotate model

* DEV: Move default mappings to ApiKeyScope

* Remove unused attribute and improve UI for existing keys

* Support multiple parameters separated by a comma
This commit is contained in:
Roman Rizzi 2020-07-16 15:51:24 -03:00 committed by GitHub
parent 766cb24989
commit f13ec11c64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 423 additions and 6 deletions

View File

@ -1,6 +1,8 @@
import RESTAdapter from "discourse/adapters/rest";
export default RESTAdapter.extend({
jsonMode: true,
basePath() {
return "/admin/api/";
},

View File

@ -9,6 +9,8 @@ export default Controller.extend({
{ id: "all", name: I18n.t("admin.api.all_users") },
{ id: "single", name: I18n.t("admin.api.single_user") }
],
useGlobalKey: false,
scopes: null,
@discourseComputed("userMode")
showUserSelector(mode) {
@ -31,6 +33,16 @@ export default Controller.extend({
},
save() {
if (!this.useGlobalKey) {
const selectedScopes = Object.values(this.scopes)
.flat()
.filter(action => {
return action.selected;
});
this.model.set("scopes", selectedScopes);
}
this.model.save().catch(popupAjaxError);
},

View File

@ -41,7 +41,7 @@ const ApiKey = RestModel.extend({
},
createProperties() {
return this.getProperties("description", "username");
return this.getProperties("description", "username", "scopes");
},
@discourseComputed()

View File

@ -1,7 +1,17 @@
import Route from "@ember/routing/route";
import { ajax } from "discourse/lib/ajax";
export default Route.extend({
model() {
return this.store.createRecord("api-key");
},
setupController(controller, model) {
ajax("/admin/api/keys/scopes.json").then(data => {
controller.setProperties({
scopes: data.scopes,
model
});
});
}
});

View File

@ -31,8 +31,40 @@
}}
{{/admin-form-row}}
{{/if}}
{{#admin-form-row}}
{{d-button icon="check" label="admin.api.save" action=(action "save") class="btn-primary" disabled=saveDisabled}}
{{#admin-form-row label="admin.api.use_global_key"}}
{{input type="checkbox" checked=useGlobalKey}}
{{/admin-form-row}}
{{#unless useGlobalKey}}
{{#each-in scopes as |resource actions|}}
<table class="scopes-table">
<thead>
<tr>
<td><b>{{resource}}</b></td>
<td></td>
<td>{{i18n "admin.api.scopes.optional_allowed_parameters"}}</td>
</tr>
</thead>
<tbody>
{{#each actions as |act|}}
<tr>
<td>{{input type="checkbox" checked=act.selected}}</td>
<td><b>{{act.name}}</b></td>
<td>
{{#each act.params as |p|}}
<div>
{{input maxlength="255" value=(get act p) placeholder=p}}
</div>
{{/each}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{/each-in}}
{{/unless}}
{{d-button icon="check" label="admin.api.save" action=(action "save") class="btn-primary" disabled=saveDisabled}}
{{/if}}
</div>

View File

@ -79,4 +79,39 @@
{{/if}}
</div>
{{/admin-form-row}}
{{#if model.api_key_scopes.length}}
{{#admin-form-row label="admin.api.scopes.title"}}
{{/admin-form-row}}
<table class="scopes-table">
<thead>
<tr>
<td>{{i18n "admin.api.scopes.resource"}}</td>
<td>{{i18n "admin.api.scopes.action"}}</td>
<td>{{i18n "admin.api.scopes.allowed_parameters"}}</td>
</tr>
</thead>
<tbody>
{{#each model.api_key_scopes as |scope|}}
<tr>
<td>{{scope.resource}}</td>
<td>{{scope.action}}</td>
<td>
{{#each scope.parameters as |p|}}
<div>
<b>{{p}}:</b>
{{#if (get scope.allowed_parameters p)}}
{{get scope.allowed_parameters p}}
{{else}}
{{i18n "admin.api.scopes.any_parameter"}}
{{/if}}
</div>
{{/each}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</div>

View File

@ -95,7 +95,6 @@ table.api-keys {
.api-key {
padding: 10px;
margin-bottom: 10px;
border-bottom: 1px solid $primary-low;
.form-element,
.form-element-desc {
float: left;
@ -127,6 +126,9 @@ table.api-keys {
width: 50%;
}
}
.scopes-table {
margin: 20px 0 20px 0;
}
}
// Webhook

View File

@ -18,10 +18,20 @@ class Admin::ApiController < Admin::AdminController
end
def show
api_key = ApiKey.find_by!(id: params[:id])
api_key = ApiKey.includes(:api_key_scopes).find_by!(id: params[:id])
render_serialized(api_key, ApiKeySerializer, root: 'key')
end
def scopes
scopes = ApiKeyScope.scope_mappings.reduce({}) do |memo, (resource, actions)|
memo.tap do |m|
m[resource] = actions.map { |k, v| { id: "#{resource}:#{k}", name: k, params: v[:params] } }
end
end
render json: { scopes: scopes }
end
def update
api_key = ApiKey.find_by!(id: params[:id])
ApiKey.transaction do
@ -44,6 +54,7 @@ class Admin::ApiController < Admin::AdminController
api_key = ApiKey.new(update_params)
ApiKey.transaction do
api_key.created_by = current_user
api_key.api_key_scopes = build_scopes
if username = params.require(:key).permit(:username)[:username].presence
api_key.user = User.find_by_username(username)
raise Discourse::NotFound unless api_key.user
@ -78,6 +89,31 @@ class Admin::ApiController < Admin::AdminController
private
def build_scopes
params.require(:key)[:scopes].to_a.map do |scope_params|
resource, action = scope_params[:id].split(':')
mapping = ApiKeyScope.scope_mappings.dig(resource.to_sym, action.to_sym)
raise Discourse::InvalidParameters if mapping.nil? # invalid mapping
ApiKeyScope.new(
resource: resource,
action: action,
allowed_parameters: build_params(scope_params, mapping[:params])
)
end
end
def build_params(scope_params, params)
return if params.nil?
scope_params.slice(*params).tap do |allowed_params|
allowed_params.each do |k, v|
v.blank? ? allowed_params.delete(k) : allowed_params[k] = v.split(',')
end
end
end
def update_params
editable_fields = [:description]
permitted_params = params.permit(key: [*editable_fields])[:key]

View File

@ -3,6 +3,7 @@
class ApiKey < ActiveRecord::Base
class KeyAccessError < StandardError; end
has_many :api_key_scopes
belongs_to :user
belongs_to :created_by, class_name: 'User'
@ -60,6 +61,12 @@ class ApiKey < ActiveRecord::Base
def self.hash_key(key)
Digest::SHA256.hexdigest key
end
def request_allowed?(request, route_param)
return false if allowed_ips.present? && allowed_ips.none? { |ip| ip.include?(request.ip) }
api_key_scopes.blank? || api_key_scopes.any? { |s| s.permits?(route_param) }
end
end
# == Schema Information

View File

@ -0,0 +1,91 @@
# frozen_string_literal: true
class ApiKeyScope < ActiveRecord::Base
validates_presence_of :resource
validates_presence_of :action
class << self
def list_actions
actions = []
TopTopic.periods.each do |p|
actions.concat(["list#category_top_#{p}", "list#top_#{p}", "list#top_#{p}_feed"])
end
%i[latest unread new top].each { |f| actions.concat(["list#category_#{f}", "list##{f}"]) }
actions
end
def default_mappings
{
topics: {
write: { actions: %w[posts#create topics#feed], params: %i[topic_id] },
read: { actions: %w[topics#show], params: %i[topic_id], aliases: { topic_id: :id } },
read_lists: { actions: list_actions, params: %i[category_id], aliases: { category_id: :category_slug_path_with_id } }
}
}
end
def scope_mappings
plugin_mappings = DiscoursePluginRegistry.api_key_scope_mappings
default_mappings.tap do |mappings|
plugin_mappings.each do |mapping|
mappings.deep_merge!(mapping)
end
end
end
end
def permits?(route_param)
path_params = "#{route_param['controller']}##{route_param['action']}"
mapping[:actions].include?(path_params) && (allowed_parameters.blank? || params_allowed?(route_param))
end
private
def params_allowed?(route_param)
mapping[:params].all? do |param|
param_alias = mapping.dig(:aliases, param)
allowed_values = [allowed_parameters[param.to_s]].flatten
value = route_param[param.to_s]
alias_value = route_param[param_alias.to_s]
return false if value.present? && alias_value.present?
value = value || alias_value
value = extract_category_id(value) if param_alias == :category_slug_path_with_id
allowed_values.blank? || allowed_values.include?(value)
end
end
def mapping
@mapping ||= self.class.scope_mappings.dig(resource.to_sym, action.to_sym)
end
def extract_category_id(category_slug_with_id)
parts = category_slug_with_id.split('/')
!parts.empty? && parts.last =~ /\A\d+\Z/ ? parts.pop : nil
end
end
# == Schema Information
#
# Table name: api_key_scopes
#
# id :bigint not null, primary key
# api_key_id :integer not null
# resource :string not null
# action :string not null
# allowed_parameters :json
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_api_key_scopes_on_api_key_id (api_key_id)
#

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class ApiKeyScopeSerializer < ApplicationSerializer
attributes :resource,
:action,
:parameters,
:allowed_parameters
def parameters
ApiKeyScope.scope_mappings.dig(object.resource.to_sym, object.action.to_sym, :params).to_a
end
end

View File

@ -12,6 +12,7 @@ class ApiKeySerializer < ApplicationSerializer
:revoked_at
has_one :user, serializer: BasicUserSerializer, embed: :objects
has_many :api_key_scopes, serializer: ApiKeyScopeSerializer, embed: :objects
def include_user_id?
!object.user_id.nil?

View File

@ -3610,6 +3610,15 @@ en:
delete: Permenantly Delete
not_shown_again: This key will not be displayed again. Make sure you take a copy before continuing.
continue: Continue
use_global_key: Global Key (allows all actions)
scopes:
title: Scopes
resource: Resource
action: Action
allowed_parameters: Allowed Parameters
optional_allowed_parameters: Allowed Parameters (optional)
any_parameter: (any parameter)
web_hooks:
title: "Webhooks"

View File

@ -269,6 +269,10 @@ Discourse::Application.routes.draw do
resources :api, only: [:index], constraints: AdminConstraint.new do
collection do
resources :keys, controller: 'api', only: [:index, :show, :update, :create, :destroy] do
collection do
get 'scopes' => 'api#scopes'
end
member do
post "revoke" => "api#revoke_key"
post "undo-revoke" => "api#undo_revoke_key"

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddApiKeyScopes < ActiveRecord::Migration[6.0]
def change
create_table :api_key_scopes do |t|
t.integer :api_key_id, null: false
t.string :resource, null: false
t.string :action, null: false
t.json :allowed_parameters
t.timestamps
end
add_index :api_key_scopes, :api_key_id
end
end

View File

@ -330,7 +330,8 @@ class Auth::DefaultCurrentUserProvider
if api_key = ApiKey.active.with_key(api_key_value).includes(:user).first
api_username = header_api_key? ? @env[HEADER_API_USERNAME] : request[API_USERNAME]
if api_key.allowed_ips.present? && !api_key.allowed_ips.any? { |ip| ip.include?(request.ip) }
params = @env['action_dispatch.request.parameters']
unless api_key.request_allowed?(request, params)
Rails.logger.warn("[Unauthorized API Access] username: #{api_username}, IP address: #{request.ip}")
return nil
end

View File

@ -78,6 +78,8 @@ class DiscoursePluginRegistry
define_filtered_register :topic_thumbnail_sizes
define_filtered_register :api_key_scope_mappings
def self.register_auth_provider(auth_provider)
self.auth_providers << auth_provider
end

View File

@ -738,6 +738,10 @@ class Plugin::Instance
end
end
def add_api_key_scope(resource, action)
DiscoursePluginRegistry.register_api_key_scope_mapping({ resource => action }, self)
end
protected
def self.js_path

View File

@ -79,4 +79,74 @@ describe ApiKey do
expect(used_recently.revoked_at).to eq(nil)
end
describe 'API Key scope mappings' do
it 'maps api_key permissions' do
api_key_mappings = ApiKeyScope.scope_mappings[:topics]
assert_responds_to(api_key_mappings.dig(:write, :actions))
assert_responds_to(api_key_mappings.dig(:read, :actions))
assert_responds_to(api_key_mappings.dig(:read_lists, :actions))
end
def assert_responds_to(mappings)
mappings.each do |m|
controller, method = m.split('#')
controller_name = "#{controller.capitalize}Controller"
expect(controller_name.constantize.method_defined?(method)).to eq(true)
end
end
end
describe "#request_allowed?" do
let(:request_mock) { OpenStruct.new(ip: '133.45.67.99') }
let(:route_path) { { 'controller' => 'topics', 'action' => 'show', 'topic_id' => '3' } }
let(:scope) do
ApiKeyScope.new(resource: 'topics', action: 'read', allowed_parameters: { topic_id: '3' })
end
let(:key) { ApiKey.new(api_key_scopes: [scope]) }
it 'allows the request if there are no allowed IPs' do
key.allowed_ips = nil
key.api_key_scopes = []
expect(key.request_allowed?(request_mock, route_path)).to eq(true)
end
it 'rejects the request if the IP is not allowed' do
key.allowed_ips = %w[115.65.76.87]
expect(key.request_allowed?(request_mock, route_path)).to eq(false)
end
it 'allow the request if there are not allowed params' do
scope.allowed_parameters = nil
expect(key.request_allowed?(request_mock, route_path)).to eq(true)
end
it 'rejects the request when params are different' do
route_path['topic_id'] = '4'
expect(key.request_allowed?(request_mock, route_path)).to eq(false)
end
it 'accepts the request if one of the parameters match' do
route_path['topic_id'] = '4'
scope.allowed_parameters = { topic_id: %w[3 4] }
expect(key.request_allowed?(request_mock, route_path)).to eq(true)
end
it 'allow the request when the scope has an alias' do
route_path = { 'controller' => 'topics', 'action' => 'show', 'id' => '3' }
expect(key.request_allowed?(request_mock, route_path)).to eq(true)
end
it 'rejects the request when the main parameter and the alias are both used' do
route_path = { 'controller' => 'topics', 'action' => 'show', 'topic_id' => '3', 'id' => '4' }
expect(key.request_allowed?(request_mock, route_path)).to eq(false)
end
end
end

View File

@ -131,6 +131,67 @@ describe Admin::ApiController do
expect(UserHistory.last.action).to eq(UserHistory.actions[:api_key_create])
expect(UserHistory.last.subject).to eq(key.truncated_key)
end
describe 'Scopes' do
it 'creates an scope with allowed parameters' do
post "/admin/api/keys.json", params: {
key: {
description: "master key description",
scopes: [{ id: 'topics:write', topic_id: '55' }]
}
}
expect(response.status).to eq(200)
data = response.parsed_body
scope = ApiKeyScope.find_by(api_key_id: data.dig('key', 'id'))
expect(scope.resource).to eq('topics')
expect(scope.action).to eq('write')
expect(scope.allowed_parameters['topic_id']).to contain_exactly('55')
end
it 'allows multiple parameters separated by a comma' do
post "/admin/api/keys.json", params: {
key: {
description: "master key description",
scopes: [{ id: 'topics:write', topic_id: '55,33' }]
}
}
expect(response.status).to eq(200)
data = response.parsed_body
scope = ApiKeyScope.find_by(api_key_id: data.dig('key', 'id'))
expect(scope.allowed_parameters['topic_id']).to contain_exactly('55', '33')
end
end
it 'ignores invalid parameters' do
post "/admin/api/keys.json", params: {
key: {
description: "master key description",
scopes: [{ id: 'topics:write', fake_id: '55' }]
}
}
expect(response.status).to eq(200)
data = response.parsed_body
scope = ApiKeyScope.find_by(api_key_id: data.dig('key', 'id'))
expect(scope.allowed_parameters['fake_id']).to be_nil
end
it 'fails when the scope is invalid' do
post "/admin/api/keys.json", params: {
key: {
description: "master key description",
scopes: [{ id: 'something:else' }]
}
}
expect(response.status).to eq(400)
end
end
describe "#revoke and #undo_revoke" do
@ -154,6 +215,16 @@ describe Admin::ApiController do
expect(UserHistory.last.details).to eq(I18n.t("staff_action_logs.api_key.restored"))
end
end
describe '#scopes' do
it 'includes scopes' do
get '/admin/api/keys/scopes.json'
scopes = response.parsed_body['scopes']
expect(scopes.keys).to contain_exactly('topics')
end
end
end
context "as a moderator" do