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

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.

diff --git a/app/assets/javascripts/admin/addon/controllers/admin-api-keys-new.js b/app/assets/javascripts/admin/addon/controllers/admin-api-keys-new.js
index 961c1a5..a95c784 100644
--- a/app/assets/javascripts/admin/addon/controllers/admin-api-keys-new.js
+++ b/app/assets/javascripts/admin/addon/controllers/admin-api-keys-new.js
@@ -10,7 +10,8 @@ import { ajax } from "discourse/lib/ajax";
 
 export default Controller.extend({
   userModes: null,
-  useGlobalKey: false,
+  scopeModes: null,
+  globalScopes: null,
   scopes: null,
 
   init() {
@@ -20,6 +21,13 @@ export default Controller.extend({
       { id: "all", name: I18n.t("admin.api.all_users") },
       { 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();
   },
 
@@ -50,13 +58,22 @@ export default Controller.extend({
   },
 
   @action
+  changeScopeMode(scopeMode) {
+    this.set("scopeMode", scopeMode);
+  },
+
+  @action
   save() {
-    if (!this.useGlobalKey) {
+    if (this.scopeMode === "granular") {
       const selectedScopes = Object.values(this.scopes)
         .flat()
         .filterBy("selected");
 
       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);
@@ -78,6 +95,10 @@ export default Controller.extend({
   _loadScopes() {
     return ajax("/admin/api/keys/scopes.json")
       .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);
       })
       .catch(popupAjaxError);
diff --git a/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs b/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs
index 2b9e40c..7f703c3 100644
--- a/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs
+++ b/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs
@@ -36,12 +36,18 @@
       {{/admin-form-row}}
     {{/if}}
 
-    {{#admin-form-row label="admin.api.use_global_key"}}
-      {{input type="checkbox" checked=useGlobalKey}}
+    {{#admin-form-row label="admin.api.scope_mode"}}
+      {{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}}
 
-    {{#unless useGlobalKey}}
-      <div class="scopes-title">{{i18n "admin.api.scopes.title"}}</div>
+    {{#if (eq scopeMode "granular")}}
+      <h2 class="scopes-title">{{i18n "admin.api.scopes.title"}}</h2>
       <p>{{i18n "admin.api.scopes.description"}}</p>
       <table class="scopes-table grid">
         <thead>
@@ -82,7 +88,7 @@
           {{/each-in}}
         </tbody>
       </table>
-    {{/unless}}
+    {{/if}}
 
     {{d-button icon="check" label="admin.api.save" action=(action "save") class="btn-primary" disabled=saveDisabled}}
   {{/if}}
diff --git a/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs b/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs
index 771ecbd..436e0cf 100644
--- a/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs
+++ b/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs
@@ -83,7 +83,7 @@
   {{/admin-form-row}}
 
   {{#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">
       <thead>
diff --git a/app/assets/stylesheets/common/admin/api.scss b/app/assets/stylesheets/common/admin/api.scss
index 91d25c8..f664986 100644
--- a/app/assets/stylesheets/common/admin/api.scss
+++ b/app/assets/stylesheets/common/admin/api.scss
@@ -179,9 +179,6 @@ table.api-keys {
       width: 50%;
     }
     .scopes-title {
-      font-size: $font-up-2;
-      font-weight: bold;
-      text-decoration: underline;
       margin-top: 20px;
     }
   }
diff --git a/app/models/api_key_scope.rb b/app/models/api_key_scope.rb
index f4cb44e..9016da3 100644
--- a/app/models/api_key_scope.rb
+++ b/app/models/api_key_scope.rb
@@ -17,6 +17,9 @@ class ApiKeyScope < ActiveRecord::Base
       return @default_mappings unless @default_mappings.nil?
 
       mappings = {
+        global: {
+          read: { methods: %i[get] }
+        },
         topics: {
           write: { actions: %w[posts#create], params: %i[topic_id] },
           read: {
@@ -48,12 +51,7 @@ class ApiKeyScope < ActiveRecord::Base
         }
       }
 
-      mappings.each_value do |resource_actions|
-        resource_actions.each_value do |action_data|
-          action_data[:urls] = find_urls(action_data[:actions])
-        end
-      end
-
+      parse_resources!(mappings)
       @default_mappings = mappings
     end
 
@@ -62,33 +60,48 @@ class ApiKeyScope < ActiveRecord::Base
       return default_mappings if plugin_mappings.empty?
 
       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|
-          resource.each_value do |resource_actions|
-            resource_actions.each_value do |action_data|
-              action_data[:urls] = find_urls(action_data[:actions])
-            end
-          end
-
-          mappings.deep_merge!(resource)
+    def parse_resources!(mappings)
+      mappings.each_value do |resource_actions|
+        resource_actions.each_value do |action_data|
+          action_data[:urls] = find_urls(actions: action_data[:actions], methods: action_data[:methods])
         end
       end
     end
 
-    def find_urls(actions)
-      Rails.application.routes.routes.reduce([]) do |memo, route|
-        defaults = route.defaults
-        action = "#{defaults[:controller].to_s}##{defaults[:action]}"
-        path = route.path.spec.to_s.gsub(/\(\.:format\)/, '')
-        api_supported_path = path.end_with?('.rss') || route.path.requirements[:format]&.match?('json')
-        excluded_paths = %w[/new-topic /new-message /exception]
-
-        memo.tap do |m|
-          if actions.include?(action) && api_supported_path && !excluded_paths.include?(path)
-            m << "#{path} (#{route.verb})"
+    def find_urls(actions:, methods:)
+      action_urls = []
+      method_urls = []
+
+      if actions.present?
+        Rails.application.routes.routes.reduce([]) do |memo, route|
+          defaults = route.defaults
+          action = "#{defaults[:controller].to_s}##{defaults[:action]}"
+          path = route.path.spec.to_s.gsub(/\(\.:format\)/, '')
+          api_supported_path = path.end_with?('.rss') || route.path.requirements[:format]&.match?('json')
+          excluded_paths = %w[/new-topic /new-message /exception]
+
+          memo.tap do |m|
+            if actions.include?(action) && api_supported_path && !excluded_paths.include?(path)
+              m << "#{path} (#{route.verb})"
+            end
           end
         end
       end
+
+      if methods.present?
+        methods.each do |method|
+          method_urls << "* (#{method})"
+        end
+      end
+
+      action_urls + method_urls
     end
   end
 
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index e88a15c..8a6b07b 100644

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

GitHub sha: 3791fbd9196c5a10d2723e3c46e7cf8f008caa4c

This commit appears in #14856 which was approved by pmusaraj. It was merged by nbianca.