DEV: Require at least one scope for API key granular mode (#31253)

Currently, if creating an API key in "granular" mode, and not selecting any scopes, a globally scoped API key is created. This can be surprising and is not ideal. Having a key with no scopes isn't useful in the first place, so this PR adds client- and server side validations to check that at least one scope is selected if using "granular" mode.
This commit is contained in:
Ted Johansson
2025-02-10 13:22:08 +08:00
committed by GitHub
parent 7be88bbe8a
commit 3d11e3ca10
7 changed files with 55 additions and 2 deletions

View File

@ -10,6 +10,7 @@ import DButton from "discourse/components/d-button";
import Form from "discourse/components/form"; import Form from "discourse/components/form";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
import ApiKeyUrlsModal from "admin/components/modal/api-key-urls"; import ApiKeyUrlsModal from "admin/components/modal/api-key-urls";
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser"; import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";
@ -79,7 +80,10 @@ export default class AdminConfigAreasApiKeysNew extends Component {
@action @action
async save(data) { async save(data) {
const payload = { description: data.description }; const payload = {
description: data.description,
scope_mode: data.scope_mode,
};
if (data.user_mode === "single") { if (data.user_mode === "single") {
payload.username = data.user; payload.username = data.user;
@ -123,6 +127,21 @@ export default class AdminConfigAreasApiKeysNew extends Component {
return enabledScopes.flat(); return enabledScopes.flat();
} }
@bind
atLeastOneGranularScope(data, { addError, removeError }) {
removeError("scopes");
if (
data.scope_mode === "granular" &&
this.#selectedScopes(data.scopes).length === 0
) {
addError("scopes", {
title: i18n("admin.api.scopes.title"),
message: i18n("admin.api.scopes.one_or_more"),
});
}
}
@action @action
async showURLs(urls) { async showURLs(urls) {
await this.modal.show(ApiKeyUrlsModal, { await this.modal.show(ApiKeyUrlsModal, {
@ -165,6 +184,7 @@ export default class AdminConfigAreasApiKeysNew extends Component {
<Form <Form
@onSubmit={{this.save}} @onSubmit={{this.save}}
@data={{this.formData}} @data={{this.formData}}
@validate={{this.atLeastOneGranularScope}}
as |form transientData| as |form transientData|
> >
<form.Field <form.Field

View File

@ -55,7 +55,12 @@ export default class ApiKey extends RestModel {
} }
createProperties() { createProperties() {
return this.getProperties("description", "username", "scopes"); return this.getProperties(
"description",
"username",
"scopes",
"scope_mode"
);
} }
@discourseComputed() @discourseComputed()

View File

@ -75,6 +75,7 @@ class Admin::ApiController < Admin::AdminController
ApiKey.transaction do ApiKey.transaction do
api_key.created_by = current_user api_key.created_by = current_user
api_key.api_key_scopes = build_scopes api_key.api_key_scopes = build_scopes
api_key.scope_mode = params.dig(:key, :scope_mode)
if username = params.require(:key).permit(:username)[:username].presence if username = params.require(:key).permit(:username)[:username].presence
api_key.user = User.find_by_username(username) api_key.user = User.find_by_username(username)
raise Discourse::NotFound unless api_key.user raise Discourse::NotFound unless api_key.user

View File

@ -4,6 +4,8 @@ class ApiKey < ActiveRecord::Base
class KeyAccessError < StandardError class KeyAccessError < StandardError
end end
attr_accessor :scope_mode
has_many :api_key_scopes has_many :api_key_scopes
belongs_to :user belongs_to :user
belongs_to :created_by, class_name: "User" belongs_to :created_by, class_name: "User"
@ -18,6 +20,7 @@ class ApiKey < ActiveRecord::Base
end end
validates :description, length: { maximum: 255 } validates :description, length: { maximum: 255 }
validate :at_least_one_granular_scope
after_initialize :generate_key after_initialize :generate_key
@ -114,6 +117,17 @@ class ApiKey < ActiveRecord::Base
# using update_column to avoid the AR transaction # using update_column to avoid the AR transaction
update_column(:last_used_at, now) update_column(:last_used_at, now)
end end
private
def at_least_one_granular_scope
if scope_mode == "granular" && api_key_scopes.empty?
errors.add(
:api_key_scopes,
I18n.t("activerecord.errors.models.api_key.base.at_least_one_granular_scope"),
)
end
end
end end
# == Schema Information # == Schema Information

View File

@ -5478,6 +5478,7 @@ en:
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
one_or_more: At least one scope must be selected.
granular: Granular granular: Granular
read_only: Read-only read_only: Read-only
global: Global global: Global

View File

@ -847,6 +847,9 @@ en:
attributes: attributes:
linkable_type: linkable_type:
invalid: "is not valid" invalid: "is not valid"
api_key:
base:
at_least_one_granular_scope: "at least one must be selected"
uncategorized_category_name: "Uncategorized" uncategorized_category_name: "Uncategorized"

View File

@ -8,6 +8,15 @@ RSpec.describe ApiKey do
it { is_expected.to belong_to :created_by } it { is_expected.to belong_to :created_by }
it { is_expected.to validate_length_of(:description).is_at_most(255) } it { is_expected.to validate_length_of(:description).is_at_most(255) }
it "validates at least one scope for granular mode" do
api_key = ApiKey.new
api_key.scope_mode = "granular"
api_key.validate
expect(api_key.errors).to contain_exactly("Api key scopes at least one must be selected")
end
it "generates a key when saving" do it "generates a key when saving" do
api_key = ApiKey.new api_key = ApiKey.new
api_key.save! api_key.save!