FEATURE: Add read-only scope to API keys (#14856)

This commit adds a global read-only scope that can be used to create
new API keys.
This commit is contained in:
Bianca Nenciu 2021-11-10 17:48:00 +02:00 committed by GitHub
parent 6a749b95c9
commit 3791fbd919
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 132 additions and 73 deletions

View File

@ -10,7 +10,8 @@ import { ajax } from "discourse/lib/ajax";
export default Controller.extend({ export default Controller.extend({
userModes: null, userModes: null,
useGlobalKey: false, scopeModes: null,
globalScopes: null,
scopes: null, scopes: null,
init() { init() {
@ -20,6 +21,13 @@ export default Controller.extend({
{ id: "all", name: I18n.t("admin.api.all_users") }, { id: "all", name: I18n.t("admin.api.all_users") },
{ id: "single", name: I18n.t("admin.api.single_user") }, { id: "single", name: I18n.t("admin.api.single_user") },
]); ]);
this.set("scopeModes", [
{ id: "granular", name: I18n.t("admin.api.scopes.granular") },
{ id: "read_only", name: I18n.t("admin.api.scopes.read_only") },
{ id: "global", name: I18n.t("admin.api.scopes.global") },
]);
this._loadScopes(); this._loadScopes();
}, },
@ -49,14 +57,23 @@ export default Controller.extend({
this.set("userMode", userMode); this.set("userMode", userMode);
}, },
@action
changeScopeMode(scopeMode) {
this.set("scopeMode", scopeMode);
},
@action @action
save() { save() {
if (!this.useGlobalKey) { if (this.scopeMode === "granular") {
const selectedScopes = Object.values(this.scopes) const selectedScopes = Object.values(this.scopes)
.flat() .flat()
.filterBy("selected"); .filterBy("selected");
this.model.set("scopes", selectedScopes); this.model.set("scopes", selectedScopes);
} else if (this.scopeMode === "read_only") {
this.model.set("scopes", [this.globalScopes.findBy("key", "read")]);
} else if (this.scopeMode === "all") {
this.model.set("scopes", null);
} }
return this.model.save().catch(popupAjaxError); return this.model.save().catch(popupAjaxError);
@ -78,6 +95,10 @@ export default Controller.extend({
_loadScopes() { _loadScopes() {
return ajax("/admin/api/keys/scopes.json") return ajax("/admin/api/keys/scopes.json")
.then((data) => { .then((data) => {
// remove global scopes because there is a different dropdown
this.set("globalScopes", data.scopes.global);
delete data.scopes.global;
this.set("scopes", data.scopes); this.set("scopes", data.scopes);
}) })
.catch(popupAjaxError); .catch(popupAjaxError);

View File

@ -36,12 +36,18 @@
{{/admin-form-row}} {{/admin-form-row}}
{{/if}} {{/if}}
{{#admin-form-row label="admin.api.use_global_key"}} {{#admin-form-row label="admin.api.scope_mode"}}
{{input type="checkbox" checked=useGlobalKey}} {{combo-box content=scopeModes value=scopeMode onChange=(action "changeScopeMode")}}
{{#if (eq scopeMode "read_only")}}
<p>{{i18n "admin.api.scopes.descriptions.global.read"}}</p>
{{else if (eq scopeMode "global")}}
<p>{{i18n "admin.api.scopes.global_description"}}</p>
{{/if}}
{{/admin-form-row}} {{/admin-form-row}}
{{#unless useGlobalKey}} {{#if (eq scopeMode "granular")}}
<div class="scopes-title">{{i18n "admin.api.scopes.title"}}</div> <h2 class="scopes-title">{{i18n "admin.api.scopes.title"}}</h2>
<p>{{i18n "admin.api.scopes.description"}}</p> <p>{{i18n "admin.api.scopes.description"}}</p>
<table class="scopes-table grid"> <table class="scopes-table grid">
<thead> <thead>
@ -82,7 +88,7 @@
{{/each-in}} {{/each-in}}
</tbody> </tbody>
</table> </table>
{{/unless}} {{/if}}
{{d-button icon="check" label="admin.api.save" action=(action "save") class="btn-primary" disabled=saveDisabled}} {{d-button icon="check" label="admin.api.save" action=(action "save") class="btn-primary" disabled=saveDisabled}}
{{/if}} {{/if}}

View File

@ -83,7 +83,7 @@
{{/admin-form-row}} {{/admin-form-row}}
{{#if model.api_key_scopes.length}} {{#if model.api_key_scopes.length}}
<div class="scopes-title">{{i18n "admin.api.scopes.title"}}</div> <h2 class="scopes-title">{{i18n "admin.api.scopes.title"}}</h2>
<table class="scopes-table grid"> <table class="scopes-table grid">
<thead> <thead>

View File

@ -179,9 +179,6 @@ table.api-keys {
width: 50%; width: 50%;
} }
.scopes-title { .scopes-title {
font-size: $font-up-2;
font-weight: bold;
text-decoration: underline;
margin-top: 20px; margin-top: 20px;
} }
} }

View File

@ -17,6 +17,9 @@ class ApiKeyScope < ActiveRecord::Base
return @default_mappings unless @default_mappings.nil? return @default_mappings unless @default_mappings.nil?
mappings = { mappings = {
global: {
read: { methods: %i[get] }
},
topics: { topics: {
write: { actions: %w[posts#create], params: %i[topic_id] }, write: { actions: %w[posts#create], params: %i[topic_id] },
read: { read: {
@ -48,12 +51,7 @@ class ApiKeyScope < ActiveRecord::Base
} }
} }
mappings.each_value do |resource_actions| parse_resources!(mappings)
resource_actions.each_value do |action_data|
action_data[:urls] = find_urls(action_data[:actions])
end
end
@default_mappings = mappings @default_mappings = mappings
end end
@ -62,20 +60,26 @@ class ApiKeyScope < ActiveRecord::Base
return default_mappings if plugin_mappings.empty? return default_mappings if plugin_mappings.empty?
default_mappings.deep_dup.tap do |mappings| default_mappings.deep_dup.tap do |mappings|
plugin_mappings.each do |plugin_mapping|
parse_resources!(plugin_mapping)
mappings.deep_merge!(plugin_mapping)
end
end
end
plugin_mappings.each do |resource| def parse_resources!(mappings)
resource.each_value do |resource_actions| mappings.each_value do |resource_actions|
resource_actions.each_value do |action_data| resource_actions.each_value do |action_data|
action_data[:urls] = find_urls(action_data[:actions]) action_data[:urls] = find_urls(actions: action_data[:actions], methods: action_data[:methods])
end
end
mappings.deep_merge!(resource)
end end
end end
end end
def find_urls(actions) def find_urls(actions:, methods:)
action_urls = []
method_urls = []
if actions.present?
Rails.application.routes.routes.reduce([]) do |memo, route| Rails.application.routes.routes.reduce([]) do |memo, route|
defaults = route.defaults defaults = route.defaults
action = "#{defaults[:controller].to_s}##{defaults[:action]}" action = "#{defaults[:controller].to_s}##{defaults[:action]}"
@ -90,6 +94,15 @@ class ApiKeyScope < ActiveRecord::Base
end end
end end
end end
if methods.present?
methods.each do |method|
method_urls << "* (#{method})"
end
end
action_urls + method_urls
end
end end
def permits?(env) def permits?(env)

View File

@ -4144,6 +4144,7 @@ en:
no_description: (no description) no_description: (no description)
all_api_keys: All API Keys all_api_keys: All API Keys
user_mode: User Level user_mode: User Level
scope_mode: Scope
impersonate_all_users: Impersonate any user impersonate_all_users: Impersonate any user
single_user: "Single User" single_user: "Single User"
user_placeholder: Enter username user_placeholder: Enter username
@ -4154,12 +4155,15 @@ en:
delete: Permanently Delete delete: Permanently Delete
not_shown_again: This key will not be displayed again. Make sure you take a copy before continuing. not_shown_again: This key will not be displayed again. Make sure you take a copy before continuing.
continue: Continue continue: Continue
use_global_key: Global Key (allows all actions)
scopes: scopes:
description: | description: |
When using scopes, you can restrict an API key to a specific set of endpoints. When using scopes, you can restrict an API key to a specific set of endpoints.
You can also define which parameters will be allowed. Use commas to separate multiple values. You can also define which parameters will be allowed. Use commas to separate multiple values.
title: Scopes title: Scopes
granular: Granular
read_only: Read-only
global: Global
global_description: API key has no restriction and all endpoints are accessible.
resource: Resource resource: Resource
action: Action action: Action
allowed_parameters: Allowed Parameters allowed_parameters: Allowed Parameters
@ -4167,6 +4171,8 @@ en:
any_parameter: (any parameter) any_parameter: (any parameter)
allowed_urls: Allowed URLs allowed_urls: Allowed URLs
descriptions: descriptions:
global:
read: Restrict API key to read-only endpoints.
topics: topics:
read: Read a topic or a specific post in it. RSS is also supported. read: Read a topic or a specific post in it. RSS is also supported.
write: Create a new topic or post to an existing one. write: Create a new topic or post to an existing one.

View File

@ -107,12 +107,13 @@ describe ApiKey do
let(:env) { request.env } let(:env) { request.env }
let(:key) { ApiKey.new(api_key_scopes: [scope]) }
context 'with regular scopes' do
let(:scope) do let(:scope) do
ApiKeyScope.new(resource: 'topics', action: 'read', allowed_parameters: { topic_id: '3' }) ApiKeyScope.new(resource: 'topics', action: 'read', allowed_parameters: { topic_id: '3' })
end end
let(:key) { ApiKey.new(api_key_scopes: [scope]) }
it 'allows the request if there are no allowed IPs' do it 'allows the request if there are no allowed IPs' do
key.allowed_ips = nil key.allowed_ips = nil
key.api_key_scopes = [] key.api_key_scopes = []
@ -150,4 +151,19 @@ describe ApiKey do
expect(key.request_allowed?(env)).to eq(false) expect(key.request_allowed?(env)).to eq(false)
end end
end end
context 'with global:read scope' do
let(:scope) do
ApiKeyScope.new(resource: 'global', action: 'read')
end
it 'allows only GET requests for global:read' do
request.request_method = 'GET'
expect(key.request_allowed?(env)).to eq(true)
request.request_method = 'POST'
expect(key.request_allowed?(env)).to eq(false)
end
end
end
end end

View File

@ -237,7 +237,7 @@ describe Admin::ApiController do
scopes = response.parsed_body['scopes'] scopes = response.parsed_body['scopes']
expect(scopes.keys).to contain_exactly('topics', 'users', 'email', 'posts') expect(scopes.keys).to contain_exactly('topics', 'users', 'email', 'posts', 'global')
end end
end end
end end