mirror of
https://github.com/discourse/discourse.git
synced 2025-05-22 22:43:33 +08:00
DEV: Move UserApiKey scopes to dedicated table (#10704)
This has no functional impact yet, but it is the first step in adding more granular scopes to UserApiKeys
This commit is contained in:
@ -71,7 +71,7 @@ class UserApiKeysController < ApplicationController
|
|||||||
client_id: params[:client_id],
|
client_id: params[:client_id],
|
||||||
user_id: current_user.id,
|
user_id: current_user.id,
|
||||||
push_url: params[:push_url],
|
push_url: params[:push_url],
|
||||||
scopes: scopes
|
scopes: scopes.map { |name| UserApiKeyScope.new(name: name) }
|
||||||
)
|
)
|
||||||
|
|
||||||
# we keep the payload short so it encrypts easily with public key
|
# we keep the payload short so it encrypts easily with public key
|
||||||
@ -147,9 +147,6 @@ class UserApiKeysController < ApplicationController
|
|||||||
if current_key = request.env['HTTP_USER_API_KEY']
|
if current_key = request.env['HTTP_USER_API_KEY']
|
||||||
request_key = UserApiKey.with_key(current_key).first
|
request_key = UserApiKey.with_key(current_key).first
|
||||||
revoke_key ||= request_key
|
revoke_key ||= request_key
|
||||||
if request_key && request_key.id != revoke_key.id && !request_key.scopes.include?("write")
|
|
||||||
raise Discourse::InvalidAccess
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
raise Discourse::NotFound unless revoke_key
|
raise Discourse::NotFound unless revoke_key
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class UserApiKey < ActiveRecord::Base
|
class UserApiKey < ActiveRecord::Base
|
||||||
|
self.ignored_columns = [
|
||||||
|
"scopes" # TODO(2020-12-18): remove
|
||||||
|
]
|
||||||
|
|
||||||
SCOPES = {
|
SCOPES = {
|
||||||
read: [:get],
|
read: [:get],
|
||||||
@ -19,6 +22,7 @@ class UserApiKey < ActiveRecord::Base
|
|||||||
}
|
}
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
has_many :scopes, class_name: "UserApiKeyScope", dependent: :destroy
|
||||||
|
|
||||||
scope :active, -> { where(revoked_at: nil) }
|
scope :active, -> { where(revoked_at: nil) }
|
||||||
scope :with_key, ->(key) { where(key_hash: ApiKey.hash_key(key)) }
|
scope :with_key, ->(key) { where(key_hash: ApiKey.hash_key(key)) }
|
||||||
@ -41,6 +45,7 @@ class UserApiKey < ActiveRecord::Base
|
|||||||
@key.present?
|
@key.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Scopes allowed to be requested by external services
|
||||||
def self.allowed_scopes
|
def self.allowed_scopes
|
||||||
Set.new(SiteSetting.allow_user_api_key_scopes.split("|"))
|
Set.new(SiteSetting.allow_user_api_key_scopes.split("|"))
|
||||||
end
|
end
|
||||||
@ -78,13 +83,15 @@ class UserApiKey < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def has_push?
|
def has_push?
|
||||||
(scopes.include?("push") || scopes.include?("notifications")) && push_url.present? && SiteSetting.allowed_user_api_push_urls.include?(push_url)
|
scopes.any? { |s| s.name == "push" || s.name == "notifications" } &&
|
||||||
|
push_url.present? &&
|
||||||
|
SiteSetting.allowed_user_api_push_urls.include?(push_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
def allow?(env)
|
def allow?(env)
|
||||||
scopes.any? do |name|
|
scopes.any? do |s|
|
||||||
UserApiKey.allow_scope?(name, env)
|
UserApiKey.allow_scope?(s.name, env)
|
||||||
end
|
end || is_revoke_self_request?(env)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.invalid_auth_redirect?(auth_redirect)
|
def self.invalid_auth_redirect?(auth_redirect)
|
||||||
@ -92,6 +99,12 @@ class UserApiKey < ActiveRecord::Base
|
|||||||
.split('|')
|
.split('|')
|
||||||
.none? { |u| WildcardUrlChecker.check_url(u, auth_redirect) }
|
.none? { |u| WildcardUrlChecker.check_url(u, auth_redirect) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def is_revoke_self_request?(env)
|
||||||
|
UserApiKey.allow_permission?([:post, 'user_api_keys#revoke'], env) && (env[:id].nil? || env[:id].to_i == id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
19
app/models/user_api_key_scope.rb
Normal file
19
app/models/user_api_key_scope.rb
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class UserApiKeyScope < ActiveRecord::Base
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: user_api_key_scopes
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# user_api_key_id :integer not null
|
||||||
|
# name :string not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_user_api_key_scopes_on_user_api_key_id (user_api_key_id)
|
||||||
|
#
|
@ -470,7 +470,8 @@ class PostAlerter
|
|||||||
|
|
||||||
if SiteSetting.allow_user_api_key_scopes.split("|").include?("push") && SiteSetting.allowed_user_api_push_urls.present?
|
if SiteSetting.allow_user_api_key_scopes.split("|").include?("push") && SiteSetting.allowed_user_api_push_urls.present?
|
||||||
clients = user.user_api_keys
|
clients = user.user_api_keys
|
||||||
.where("('push' = ANY(scopes) OR 'notifications' = ANY(scopes))")
|
.joins(:scopes)
|
||||||
|
.where("user_api_key_scopes.name IN ('push', 'notifications')")
|
||||||
.where("push_url IS NOT NULL AND push_url <> ''")
|
.where("push_url IS NOT NULL AND push_url <> ''")
|
||||||
.where("position(push_url IN ?) > 0", SiteSetting.allowed_user_api_push_urls)
|
.where("position(push_url IN ?) > 0", SiteSetting.allowed_user_api_push_urls)
|
||||||
.where("revoked_at IS NULL")
|
.where("revoked_at IS NULL")
|
||||||
|
46
db/migrate/20200918095554_add_user_api_key_scopes.rb
Normal file
46
db/migrate/20200918095554_add_user_api_key_scopes.rb
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddUserApiKeyScopes < ActiveRecord::Migration[6.0]
|
||||||
|
def change
|
||||||
|
create_table :user_api_key_scopes do |t|
|
||||||
|
t.integer :user_api_key_id, null: false
|
||||||
|
t.string :name, null: false
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :user_api_key_scopes, :user_api_key_id
|
||||||
|
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
execute <<~SQL
|
||||||
|
INSERT INTO user_api_key_scopes
|
||||||
|
(
|
||||||
|
user_api_key_id,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
user_api_keys.id,
|
||||||
|
unnest(user_api_keys.scopes),
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM user_api_keys
|
||||||
|
SQL
|
||||||
|
|
||||||
|
Migration::SafeMigrate.disable!
|
||||||
|
change_column_null :user_api_keys, :scopes, true
|
||||||
|
change_column_default :user_api_keys, :scopes, nil
|
||||||
|
Migration::SafeMigrate.enable!
|
||||||
|
|
||||||
|
Migration::ColumnDropper.mark_readonly(:user_api_keys, :scopes)
|
||||||
|
end
|
||||||
|
|
||||||
|
dir.down do
|
||||||
|
change_column_null :user_api_keys, :scopes, false
|
||||||
|
change_column_default :user_api_keys, :scopes, []
|
||||||
|
Migration::ColumnDropper.drop_readonly(:user_api_keys, :scopes)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -307,7 +307,7 @@ class Auth::DefaultCurrentUserProvider
|
|||||||
protected
|
protected
|
||||||
|
|
||||||
def lookup_user_api_user_and_update_key(user_api_key, client_id)
|
def lookup_user_api_user_and_update_key(user_api_key, client_id)
|
||||||
if api_key = UserApiKey.active.with_key(user_api_key).includes(:user).first
|
if api_key = UserApiKey.active.with_key(user_api_key).includes(:user, :scopes).first
|
||||||
unless api_key.allow?(@env)
|
unless api_key.allow?(@env)
|
||||||
raise Discourse::InvalidAccess
|
raise Discourse::InvalidAccess
|
||||||
end
|
end
|
||||||
|
@ -547,7 +547,7 @@ describe Auth::DefaultCurrentUserProvider do
|
|||||||
UserApiKey.create!(
|
UserApiKey.create!(
|
||||||
application_name: 'my app',
|
application_name: 'my app',
|
||||||
client_id: '1234',
|
client_id: '1234',
|
||||||
scopes: ['read'],
|
scopes: ['read'].map { |name| UserApiKeyScope.new(name: name) },
|
||||||
user_id: user.id
|
user_id: user.id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@ -556,7 +556,7 @@ describe Auth::DefaultCurrentUserProvider do
|
|||||||
dupe = UserApiKey.create!(
|
dupe = UserApiKey.create!(
|
||||||
application_name: 'my app',
|
application_name: 'my app',
|
||||||
client_id: '12345',
|
client_id: '12345',
|
||||||
scopes: ['read'],
|
scopes: ['read'].map { |name| UserApiKeyScope.new(name: name) },
|
||||||
user_id: user.id
|
user_id: user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Fabricator(:user_api_key_scope)
|
||||||
|
|
||||||
Fabricator(:readonly_user_api_key, from: :user_api_key) do
|
Fabricator(:readonly_user_api_key, from: :user_api_key) do
|
||||||
user
|
user
|
||||||
scopes ['read']
|
scopes { [Fabricate.build(:user_api_key_scope, name: 'read')] }
|
||||||
client_id { SecureRandom.hex }
|
client_id { SecureRandom.hex }
|
||||||
application_name 'some app'
|
application_name 'some app'
|
||||||
end
|
end
|
||||||
|
@ -5,7 +5,7 @@ require 'rails_helper'
|
|||||||
describe UserApiKey do
|
describe UserApiKey do
|
||||||
context "#allow?" do
|
context "#allow?" do
|
||||||
it "can look up permissions correctly" do
|
it "can look up permissions correctly" do
|
||||||
key = UserApiKey.new(scopes: ['message_bus', 'notifications'])
|
key = UserApiKey.new(scopes: ['message_bus', 'notifications'].map { |name| UserApiKeyScope.new(name: name) })
|
||||||
|
|
||||||
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(false)
|
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(false)
|
||||||
expect(key.allow?("PATH_INFO" => "/message-bus/1234/poll", "REQUEST_METHOD" => "POST")).to eq(true)
|
expect(key.allow?("PATH_INFO" => "/message-bus/1234/poll", "REQUEST_METHOD" => "POST")).to eq(true)
|
||||||
@ -20,7 +20,7 @@ describe UserApiKey do
|
|||||||
|
|
||||||
it "can allow all correct scopes to write" do
|
it "can allow all correct scopes to write" do
|
||||||
|
|
||||||
key = UserApiKey.new(scopes: ["write"])
|
key = UserApiKey.new(scopes: ["write"].map { |name| UserApiKeyScope.new(name: name) })
|
||||||
|
|
||||||
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(true)
|
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(true)
|
||||||
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "PUT")).to eq(true)
|
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "PUT")).to eq(true)
|
||||||
@ -31,7 +31,7 @@ describe UserApiKey do
|
|||||||
|
|
||||||
it "can allow blanket read" do
|
it "can allow blanket read" do
|
||||||
|
|
||||||
key = UserApiKey.new(scopes: ["read"])
|
key = UserApiKey.new(scopes: ["read"].map { |name| UserApiKeyScope.new(name: name) })
|
||||||
|
|
||||||
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(true)
|
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(true)
|
||||||
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "PUT")).to eq(false)
|
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "PUT")).to eq(false)
|
||||||
|
@ -174,7 +174,7 @@ describe UserApiKeysController do
|
|||||||
expect(parsed["api"]).to eq(4)
|
expect(parsed["api"]).to eq(4)
|
||||||
|
|
||||||
key = user.user_api_keys.first
|
key = user.user_api_keys.first
|
||||||
expect(key.scopes).to include("push")
|
expect(key.scopes.map(&:name)).to include("push")
|
||||||
expect(key.push_url).to eq("https://push.it/here")
|
expect(key.push_url).to eq("https://push.it/here")
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -208,7 +208,7 @@ describe UserApiKeysController do
|
|||||||
api_key = UserApiKey.with_key(parsed["key"]).first
|
api_key = UserApiKey.with_key(parsed["key"]).first
|
||||||
|
|
||||||
expect(api_key.user_id).to eq(user.id)
|
expect(api_key.user_id).to eq(user.id)
|
||||||
expect(api_key.scopes.sort).to eq(["push", "message_bus", "notifications", "session_info", "one_time_password"].sort)
|
expect(api_key.scopes.map(&:name).sort).to eq(["push", "message_bus", "notifications", "session_info", "one_time_password"].sort)
|
||||||
expect(api_key.push_url).to eq("https://push.it/here")
|
expect(api_key.push_url).to eq("https://push.it/here")
|
||||||
|
|
||||||
uri.query = ""
|
uri.query = ""
|
||||||
|
@ -724,7 +724,7 @@ describe PostAlerter do
|
|||||||
UserApiKey.create!(user_id: evil_trout.id,
|
UserApiKey.create!(user_id: evil_trout.id,
|
||||||
client_id: "xxx#{i}",
|
client_id: "xxx#{i}",
|
||||||
application_name: "iPhone#{i}",
|
application_name: "iPhone#{i}",
|
||||||
scopes: ['notifications'],
|
scopes: ['notifications'].map { |name| UserApiKeyScope.new(name: name) },
|
||||||
push_url: "https://site2.com/push")
|
push_url: "https://site2.com/push")
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -739,7 +739,7 @@ describe PostAlerter do
|
|||||||
UserApiKey.create!(user_id: evil_trout.id,
|
UserApiKey.create!(user_id: evil_trout.id,
|
||||||
client_id: "xxx#{i}",
|
client_id: "xxx#{i}",
|
||||||
application_name: "iPhone#{i}",
|
application_name: "iPhone#{i}",
|
||||||
scopes: ['notifications'],
|
scopes: ['notifications'].map { |name| UserApiKeyScope.new(name: name) },
|
||||||
push_url: "https://site2.com/push")
|
push_url: "https://site2.com/push")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user