mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
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:
@@ -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
|
||||
|
||||
91
app/models/api_key_scope.rb
Normal file
91
app/models/api_key_scope.rb
Normal 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)
|
||||
#
|
||||
13
app/serializers/api_key_scope_serializer.rb
Normal file
13
app/serializers/api_key_scope_serializer.rb
Normal 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
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user