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:
parent
766cb24989
commit
f13ec11c64
|
@ -1,6 +1,8 @@
|
|||
import RESTAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default RESTAdapter.extend({
|
||||
jsonMode: true,
|
||||
|
||||
basePath() {
|
||||
return "/admin/api/";
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ const ApiKey = RestModel.extend({
|
|||
},
|
||||
|
||||
createProperties() {
|
||||
return this.getProperties("description", "username");
|
||||
return this.getProperties("description", "username", "scopes");
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
#
|
|
@ -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
|
|
@ -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?
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue