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:
David Taylor
2020-09-29 10:57:48 +01:00
committed by GitHub
parent 91ac70a32d
commit 1ba9b34b03
11 changed files with 98 additions and 20 deletions

View File

@ -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

View File

@ -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

View 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)
#

View File

@ -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")

View 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

View File

@ -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

View File

@ -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
) )

View File

@ -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

View File

@ -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)

View File

@ -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 = ""

View File

@ -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