FEATURE: Add scopes to API keys (#9844)

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

diff --git a/app/assets/javascripts/admin/adapters/api-key.js b/app/assets/javascripts/admin/adapters/api-key.js
index a473f66..9777518 100644
--- a/app/assets/javascripts/admin/adapters/api-key.js
+++ b/app/assets/javascripts/admin/adapters/api-key.js
@@ -1,6 +1,8 @@
 import RESTAdapter from "discourse/adapters/rest";
 
 export default RESTAdapter.extend({
+  jsonMode: true,
+
   basePath() {
     return "/admin/api/";
   },
diff --git a/app/assets/javascripts/admin/controllers/admin-api-keys-new.js b/app/assets/javascripts/admin/controllers/admin-api-keys-new.js
index eaddcfc..bd9c446 100644
--- a/app/assets/javascripts/admin/controllers/admin-api-keys-new.js
+++ b/app/assets/javascripts/admin/controllers/admin-api-keys-new.js
@@ -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);
     },
 
diff --git a/app/assets/javascripts/admin/models/api-key.js b/app/assets/javascripts/admin/models/api-key.js
index 06d861f..e5f9724 100644
--- a/app/assets/javascripts/admin/models/api-key.js
+++ b/app/assets/javascripts/admin/models/api-key.js
@@ -41,7 +41,7 @@ const ApiKey = RestModel.extend({
   },
 
   createProperties() {
-    return this.getProperties("description", "username");
+    return this.getProperties("description", "username", "scopes");
   },
 
   @discourseComputed()
diff --git a/app/assets/javascripts/admin/routes/admin-api-keys-new.js b/app/assets/javascripts/admin/routes/admin-api-keys-new.js
index 3969615..63b248c 100644
--- a/app/assets/javascripts/admin/routes/admin-api-keys-new.js
+++ b/app/assets/javascripts/admin/routes/admin-api-keys-new.js
@@ -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
+      });
+    });
   }
 });
diff --git a/app/assets/javascripts/admin/templates/api-keys-new.hbs b/app/assets/javascripts/admin/templates/api-keys-new.hbs
index d48da80..2809541 100644
--- a/app/assets/javascripts/admin/templates/api-keys-new.hbs
+++ b/app/assets/javascripts/admin/templates/api-keys-new.hbs
@@ -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>
diff --git a/app/assets/javascripts/admin/templates/api-keys-show.hbs b/app/assets/javascripts/admin/templates/api-keys-show.hbs
index d1d35da..2ffd5c4 100644
--- a/app/assets/javascripts/admin/templates/api-keys-show.hbs
+++ b/app/assets/javascripts/admin/templates/api-keys-show.hbs
@@ -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>
diff --git a/app/assets/stylesheets/common/admin/api.scss b/app/assets/stylesheets/common/admin/api.scss
index d257460..4e77cb4 100644
--- a/app/assets/stylesheets/common/admin/api.scss
+++ b/app/assets/stylesheets/common/admin/api.scss
@@ -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
diff --git a/app/controllers/admin/api_controller.rb b/app/controllers/admin/api_controller.rb
index 42ec403..d2f8339 100644
--- a/app/controllers/admin/api_controller.rb
+++ b/app/controllers/admin/api_controller.rb
@@ -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
+

[... diff too long, it was truncated ...]

GitHub sha: f13ec11c

This commit appears in #9844 which was merged by romanrizzi.