Merge branch '33580-fix-api-scoping' into 'master'

Fix API Scoping

Closes #33580 and #33022

See merge request !12300
This commit is contained in:
Douwe Maan 2017-07-05 13:25:38 +00:00
commit 98768953f3
15 changed files with 291 additions and 41 deletions

View File

@ -5,10 +5,11 @@ class AccessTokenValidationService
REVOKED = :revoked
INSUFFICIENT_SCOPE = :insufficient_scope
attr_reader :token
attr_reader :token, :request
def initialize(token)
def initialize(token, request: nil)
@token = token
@request = request
end
def validate(scopes: [])
@ -27,12 +28,23 @@ class AccessTokenValidationService
end
# True if the token's scope contains any of the passed scopes.
def include_any_scope?(scopes)
if scopes.blank?
def include_any_scope?(required_scopes)
if required_scopes.blank?
true
else
# Check whether the token is allowed access to any of the required scopes.
Set.new(scopes).intersection(Set.new(token.scopes)).present?
# We're comparing each required_scope against all token scopes, which would
# take quadratic time. This consideration is irrelevant here because of the
# small number of records involved.
# https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12300/#note_33689006
token_scopes = token.scopes.map(&:to_sym)
required_scopes.any? do |scope|
if scope.respond_to?(:sufficient?)
scope.sufficient?(token_scopes, request)
else
API::Scope.new(scope).sufficient?(token_scopes, request)
end
end
end
end
end

View File

@ -0,0 +1,4 @@
---
title: Fix API Scoping
merge_request: 12300
author:

View File

@ -2,6 +2,8 @@ module API
class API < Grape::API
include APIGuard
allow_access_with_scope :api
version %w(v3 v4), using: :path
version 'v3', using: :path do
@ -44,7 +46,6 @@ module API
mount ::API::V3::Variables
end
before { allow_access_with_scope :api }
before { header['X-Frame-Options'] = 'SAMEORIGIN' }
before { Gitlab::I18n.locale = current_user&.preferred_language }

View File

@ -23,6 +23,23 @@ module API
install_error_responders(base)
end
class_methods do
# Set the authorization scope(s) allowed for an API endpoint.
#
# A call to this method maps the given scope(s) to the current API
# endpoint class. If this method is called multiple times on the same class,
# the scopes are all aggregated.
def allow_access_with_scope(scopes, options = {})
Array(scopes).each do |scope|
allowed_scopes << Scope.new(scope, options)
end
end
def allowed_scopes
@scopes ||= []
end
end
# Helper Methods for Grape Endpoint
module HelperMethods
# Invokes the doorkeeper guard.
@ -47,7 +64,7 @@ module API
access_token = find_access_token
return nil unless access_token
case AccessTokenValidationService.new(access_token).validate(scopes: scopes)
case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes)
when AccessTokenValidationService::INSUFFICIENT_SCOPE
raise InsufficientScopeError.new(scopes)
@ -74,18 +91,6 @@ module API
@current_user
end
# Set the authorization scope(s) allowed for the current request.
#
# Note: A call to this method adds to any previous scopes in place. This is done because
# `Grape` callbacks run from the outside-in: the top-level callback (API::API) runs first, then
# the next-level callback (API::API::Users, for example) runs. All these scopes are valid for the
# given endpoint (GET `/api/users` is accessible by the `api` and `read_user` scopes), and so they
# need to be stored.
def allow_access_with_scope(*scopes)
@scopes ||= []
@scopes.concat(scopes.map(&:to_s))
end
private
def find_user_by_authentication_token(token_string)
@ -96,7 +101,7 @@ module API
access_token = PersonalAccessToken.active.find_by_token(token_string)
return unless access_token
if AccessTokenValidationService.new(access_token).include_any_scope?(scopes)
if AccessTokenValidationService.new(access_token, request: request).include_any_scope?(scopes)
User.find(access_token.user_id)
end
end

View File

@ -342,8 +342,8 @@ module API
def initial_current_user
return @initial_current_user if defined?(@initial_current_user)
Gitlab::Auth::UniqueIpsLimiter.limit_user! do
@initial_current_user ||= find_user_by_private_token(scopes: @scopes)
@initial_current_user ||= doorkeeper_guard(scopes: @scopes)
@initial_current_user ||= find_user_by_private_token(scopes: scopes_registered_for_endpoint)
@initial_current_user ||= doorkeeper_guard(scopes: scopes_registered_for_endpoint)
@initial_current_user ||= find_user_from_warden
unless @initial_current_user && Gitlab::UserAccess.new(@initial_current_user).allowed?
@ -407,5 +407,22 @@ module API
exception.status == 500
end
# An array of scopes that were registered (using `allow_access_with_scope`)
# for the current endpoint class. It also returns scopes registered on
# `API::API`, since these are meant to apply to all API routes.
def scopes_registered_for_endpoint
@scopes_registered_for_endpoint ||=
begin
endpoint_classes = [options[:for].presence, ::API::API].compact
endpoint_classes.reduce([]) do |memo, endpoint|
if endpoint.respond_to?(:allowed_scopes)
memo.concat(endpoint.allowed_scopes)
else
memo
end
end
end
end
end
end

23
lib/api/scope.rb Normal file
View File

@ -0,0 +1,23 @@
# Encapsulate a scope used for authorization, such as `api`, or `read_user`
module API
class Scope
attr_reader :name, :if
def initialize(name, options = {})
@name = name.to_sym
@if = options[:if]
end
# Are the `scopes` passed in sufficient to adequately authorize the passed
# request for the scope represented by the current instance of this class?
def sufficient?(scopes, request)
scopes.include?(self.name) && verify_if_condition(request)
end
private
def verify_if_condition(request)
self.if.nil? || self.if.call(request)
end
end
end

View File

@ -1,10 +1,9 @@
module API
class Users < Grape::API
include PaginationParams
include APIGuard
before do
allow_access_with_scope :read_user if request.get?
end
allow_access_with_scope :read_user, if: -> (request) { request.get? }
resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do
before do

View File

@ -2,9 +2,11 @@ module API
module V3
class Users < Grape::API
include PaginationParams
include APIGuard
allow_access_with_scope :read_user, if: -> (request) { request.get? }
before do
allow_access_with_scope :read_user if request.get?
authenticate!
end

View File

@ -130,13 +130,13 @@ module Gitlab
token = PersonalAccessTokensFinder.new(state: 'active').find_by(token: password)
if token && valid_scoped_token?(token, AVAILABLE_SCOPES.map(&:to_s))
if token && valid_scoped_token?(token, AVAILABLE_SCOPES)
Gitlab::Auth::Result.new(token.user, nil, :personal_token, abilities_for_scope(token.scopes))
end
end
def valid_oauth_token?(token)
token && token.accessible? && valid_scoped_token?(token, ["api"])
token && token.accessible? && valid_scoped_token?(token, [:api])
end
def valid_scoped_token?(token, scopes)

View File

@ -14,6 +14,10 @@ describe API::Helpers do
let(:request) { Rack::Request.new(env) }
let(:header) { }
before do
allow_any_instance_of(self.class).to receive(:options).and_return({})
end
def set_env(user_or_token, identifier)
clear_env
clear_param
@ -167,7 +171,6 @@ describe API::Helpers do
it "returns nil for a token without the appropriate scope" do
personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user'])
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token
allow_access_with_scope('write_user')
expect(current_user).to be_nil
end

View File

@ -390,6 +390,14 @@ describe API::Users do
expect(json_response['identities'].first['provider']).to eq('github')
end
end
context "scopes" do
let(:user) { admin }
let(:path) { '/users' }
let(:api_call) { method(:api) }
include_examples 'does not allow the "read_user" scope'
end
end
describe "GET /users/sign_up" do
@ -887,6 +895,13 @@ describe API::Users do
expect(response).to match_response_schema('public_api/v4/user/public')
expect(json_response['id']).to eq(user.id)
end
context "scopes" do
let(:path) { "/user" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
end
end
context 'with admin' do
@ -956,6 +971,13 @@ describe API::Users do
expect(json_response).to be_an Array
expect(json_response.first["title"]).to eq(key.title)
end
context "scopes" do
let(:path) { "/user/keys" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
end
end
end
@ -989,6 +1011,13 @@ describe API::Users do
expect(response).to have_http_status(404)
end
context "scopes" do
let(:path) { "/user/keys/#{key.id}" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
end
end
describe "POST /user/keys" do
@ -1078,6 +1107,13 @@ describe API::Users do
expect(json_response).to be_an Array
expect(json_response.first["email"]).to eq(email.email)
end
context "scopes" do
let(:path) { "/user/emails" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
end
end
end
@ -1110,6 +1146,13 @@ describe API::Users do
expect(response).to have_http_status(404)
end
context "scopes" do
let(:path) { "/user/emails/#{email.id}" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
end
end
describe "POST /user/emails" do

View File

@ -67,6 +67,19 @@ describe API::V3::Users do
expect(json_response.first['title']).to eq(key.title)
end
end
context "scopes" do
let(:user) { admin }
let(:path) { "/users/#{user.id}/keys" }
let(:api_call) { method(:v3_api) }
before do
user.keys << key
user.save
end
include_examples 'allows the "read_user" scope'
end
end
describe 'GET /user/:id/emails' do
@ -287,7 +300,7 @@ describe API::V3::Users do
end
it 'returns a 404 error if not found' do
get v3_api('/users/42/events', user)
get v3_api('/users/420/events', user)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 User Not Found')
@ -312,5 +325,13 @@ describe API::V3::Users do
expect(json_response['is_admin']).to be_nil
end
context "scopes" do
let(:user) { admin }
let(:path) { '/users' }
let(:api_call) { method(:v3_api) }
include_examples 'does not allow the "read_user" scope'
end
end
end

View File

@ -2,40 +2,71 @@ require 'spec_helper'
describe AccessTokenValidationService, services: true do
describe ".include_any_scope?" do
let(:request) { double("request") }
it "returns true if the required scope is present in the token's scopes" do
token = double("token", scopes: [:api, :read_user])
scopes = [:api]
expect(described_class.new(token).include_any_scope?([:api])).to be(true)
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "returns true if more than one of the required scopes is present in the token's scopes" do
token = double("token", scopes: [:api, :read_user, :other_scope])
scopes = [:api, :other_scope]
expect(described_class.new(token).include_any_scope?([:api, :other_scope])).to be(true)
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "returns true if the list of required scopes is an exact match for the token's scopes" do
token = double("token", scopes: [:api, :read_user, :other_scope])
scopes = [:api, :read_user, :other_scope]
expect(described_class.new(token).include_any_scope?([:api, :read_user, :other_scope])).to be(true)
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "returns true if the list of required scopes contains all of the token's scopes, in addition to others" do
token = double("token", scopes: [:api, :read_user])
scopes = [:api, :read_user, :other_scope]
expect(described_class.new(token).include_any_scope?([:api, :read_user, :other_scope])).to be(true)
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it 'returns true if the list of required scopes is blank' do
token = double("token", scopes: [])
scopes = []
expect(described_class.new(token).include_any_scope?([])).to be(true)
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "returns false if there are no scopes in common between the required scopes and the token scopes" do
token = double("token", scopes: [:api, :read_user])
scopes = [:other_scope]
expect(described_class.new(token).include_any_scope?([:other_scope])).to be(false)
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(false)
end
context "conditions" do
it "ignores any scopes whose `if` condition returns false" do
token = double("token", scopes: [:api, :read_user])
scopes = [API::Scope.new(:api, if: ->(_) { false })]
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(false)
end
it "does not ignore scopes whose `if` condition is not set" do
token = double("token", scopes: [:api, :read_user])
scopes = [API::Scope.new(:api, if: ->(_) { false }), :read_user]
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
it "does not ignore scopes whose `if` condition returns true" do
token = double("token", scopes: [:api, :read_user])
scopes = [API::Scope.new(:api, if: ->(_) { true }), API::Scope.new(:read_user, if: ->(_) { false })]
expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true)
end
end
end
end

View File

@ -0,0 +1,79 @@
shared_examples_for 'allows the "read_user" scope' do
context 'for personal access tokens' do
context 'when the requesting token has the "api" scope' do
let(:token) { create(:personal_access_token, scopes: ['api'], user: user) }
it 'returns a "200" response' do
get api_call.call(path, user, personal_access_token: token)
expect(response).to have_http_status(200)
end
end
context 'when the requesting token has the "read_user" scope' do
let(:token) { create(:personal_access_token, scopes: ['read_user'], user: user) }
it 'returns a "200" response' do
get api_call.call(path, user, personal_access_token: token)
expect(response).to have_http_status(200)
end
end
context 'when the requesting token does not have any required scope' do
let(:token) { create(:personal_access_token, scopes: ['read_registry'], user: user) }
it 'returns a "401" response' do
get api_call.call(path, user, personal_access_token: token)
expect(response).to have_http_status(401)
end
end
end
context 'for doorkeeper (OAuth) tokens' do
let!(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) }
context 'when the requesting token has the "api" scope' do
let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "api" }
it 'returns a "200" response' do
get api_call.call(path, user, oauth_access_token: token)
expect(response).to have_http_status(200)
end
end
context 'when the requesting token has the "read_user" scope' do
let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "read_user" }
it 'returns a "200" response' do
get api_call.call(path, user, oauth_access_token: token)
expect(response).to have_http_status(200)
end
end
context 'when the requesting token does not have any required scope' do
let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "invalid" }
it 'returns a "403" response' do
get api_call.call(path, user, oauth_access_token: token)
expect(response).to have_http_status(403)
end
end
end
end
shared_examples_for 'does not allow the "read_user" scope' do
context 'when the requesting token has the "read_user" scope' do
let(:token) { create(:personal_access_token, scopes: ['read_user'], user: user) }
it 'returns a "401" response' do
post api_call.call(path, user, personal_access_token: token), attributes_for(:user, projects_limit: 3)
expect(response).to have_http_status(401)
end
end
end

View File

@ -17,14 +17,18 @@ module ApiHelpers
# => "/api/v2/issues?foo=bar&private_token=..."
#
# Returns the relative path to the requested API resource
def api(path, user = nil, version: API::API.version)
def api(path, user = nil, version: API::API.version, personal_access_token: nil, oauth_access_token: nil)
"/api/#{version}#{path}" +
# Normalize query string
(path.index('?') ? '' : '?') +
if personal_access_token.present?
"&private_token=#{personal_access_token.token}"
elsif oauth_access_token.present?
"&access_token=#{oauth_access_token.token}"
# Append private_token if given a User object
if user.respond_to?(:private_token)
elsif user.respond_to?(:private_token)
"&private_token=#{user.private_token}"
else
''
@ -32,8 +36,14 @@ module ApiHelpers
end
# Temporary helper method for simplifying V3 exclusive API specs
def v3_api(path, user = nil)
api(path, user, version: 'v3')
def v3_api(path, user = nil, personal_access_token: nil, oauth_access_token: nil)
api(
path,
user,
version: 'v3',
personal_access_token: personal_access_token,
oauth_access_token: oauth_access_token
)
end
def ci_api(path, user = nil)