diff options
| -rw-r--r-- | app/services/access_token_validation_service.rb | 24 | ||||
| -rw-r--r-- | changelogs/unreleased/33580-fix-api-scoping.yml | 4 | ||||
| -rw-r--r-- | lib/api/api.rb | 3 | ||||
| -rw-r--r-- | lib/api/api_guard.rb | 33 | ||||
| -rw-r--r-- | lib/api/helpers.rb | 21 | ||||
| -rw-r--r-- | lib/api/scope.rb | 23 | ||||
| -rw-r--r-- | lib/api/users.rb | 5 | ||||
| -rw-r--r-- | lib/api/v3/users.rb | 4 | ||||
| -rw-r--r-- | lib/gitlab/auth.rb | 4 | ||||
| -rw-r--r-- | spec/requests/api/helpers_spec.rb | 5 | ||||
| -rw-r--r-- | spec/requests/api/users_spec.rb | 22 | ||||
| -rw-r--r-- | spec/requests/api/v3/users_spec.rb | 23 | ||||
| -rw-r--r-- | spec/services/access_token_validation_service_spec.rb | 43 | ||||
| -rw-r--r-- | spec/support/api/scopes/read_user_shared_examples.rb | 79 | ||||
| -rw-r--r-- | spec/support/api_helpers.rb | 18 | 
15 files changed, 270 insertions, 41 deletions
diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb index b2a543daa00..9c00ea789ec 100644 --- a/app/services/access_token_validation_service.rb +++ b/app/services/access_token_validation_service.rb @@ -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 diff --git a/changelogs/unreleased/33580-fix-api-scoping.yml b/changelogs/unreleased/33580-fix-api-scoping.yml new file mode 100644 index 00000000000..f4ebb13c082 --- /dev/null +++ b/changelogs/unreleased/33580-fix-api-scoping.yml @@ -0,0 +1,4 @@ +--- +title: Fix API Scoping +merge_request: 12300 +author: diff --git a/lib/api/api.rb b/lib/api/api.rb index d767af36e8e..efcf0976a81 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -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 } diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index 9fcf04efa38..0d2d71e336a 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -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 diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 2c73a6fdc4e..a2a661b205c 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -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 diff --git a/lib/api/scope.rb b/lib/api/scope.rb new file mode 100644 index 00000000000..d5165b2e482 --- /dev/null +++ b/lib/api/scope.rb @@ -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 diff --git a/lib/api/users.rb b/lib/api/users.rb index 5b9d9a71be4..88bca235692 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -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 diff --git a/lib/api/v3/users.rb b/lib/api/v3/users.rb index 37020019e07..cf106f2552d 100644 --- a/lib/api/v3/users.rb +++ b/lib/api/v3/users.rb @@ -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 diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 3933c3b04dd..ccb5d886bab 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -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) diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index 191c60aba31..25ec44fa036 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -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 diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 8640c16203e..b8109ce401c 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -113,6 +113,13 @@ describe API::Users do          expect(json_response.first.keys).not_to include 'is_admin'        end + +      context "scopes" do +        let(:path) { "/users" } +        let(:api_call) { method(:api) } + +        include_examples 'allows the "read_user" scope' +      end      end      context "when admin" do @@ -209,6 +216,13 @@ describe API::Users do        expect(response).to have_http_status(404)      end + +    context "scopes" do +      let(:path) { "/users/#{user.id}" } +      let(:api_call) { method(:api) } + +      include_examples 'allows the "read_user" scope' +    end    end    describe "POST /users" do @@ -390,6 +404,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 diff --git a/spec/requests/api/v3/users_spec.rb b/spec/requests/api/v3/users_spec.rb index 6d7401f9764..de7499a4e43 100644 --- a/spec/requests/api/v3/users_spec.rb +++ b/spec/requests/api/v3/users_spec.rb @@ -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 diff --git a/spec/services/access_token_validation_service_spec.rb b/spec/services/access_token_validation_service_spec.rb index 87f093ee8ce..11225fad18a 100644 --- a/spec/services/access_token_validation_service_spec.rb +++ b/spec/services/access_token_validation_service_spec.rb @@ -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, 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).include_any_scope?([:other_scope])).to be(false) +        expect(described_class.new(token, request: request).include_any_scope?(scopes)).to be(true) +      end      end    end  end diff --git a/spec/support/api/scopes/read_user_shared_examples.rb b/spec/support/api/scopes/read_user_shared_examples.rb new file mode 100644 index 00000000000..3bd589d64b9 --- /dev/null +++ b/spec/support/api/scopes/read_user_shared_examples.rb @@ -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 diff --git a/spec/support/api_helpers.rb b/spec/support/api_helpers.rb index 35d1e1cfc7d..ac0aaa524b7 100644 --- a/spec/support/api_helpers.rb +++ b/spec/support/api_helpers.rb @@ -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)  | 
