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