From a84db03ee45bccd08e9d1a7072aa0c092a2fdae3 Mon Sep 17 00:00:00 2001 From: Alex Buijs Date: Tue, 2 Sep 2025 16:13:42 +0200 Subject: [PATCH 1/2] Add granular token authorization service Add service for authorizing a boundary and permissions against a personal access token's granular scopes. --- .../authorize_granular_scopes_service.rb | 99 +++++++++++++++ lib/gitlab/auth/auth_finders.rb | 1 + spec/lib/gitlab/auth/auth_finders_spec.rb | 1 + .../authorize_granular_scopes_service_spec.rb | 113 ++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 app/services/authz/tokens/authorize_granular_scopes_service.rb create mode 100644 spec/services/authz/tokens/authorize_granular_scopes_service_spec.rb diff --git a/app/services/authz/tokens/authorize_granular_scopes_service.rb b/app/services/authz/tokens/authorize_granular_scopes_service.rb new file mode 100644 index 00000000000000..a2aee0ace3f1f6 --- /dev/null +++ b/app/services/authz/tokens/authorize_granular_scopes_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Authz + module Tokens + class AuthorizeGranularScopesService + include Gitlab::Utils::StrongMemoize + + InvalidInputError = Class.new(StandardError) + + def initialize(boundary:, permissions:, token: nil) + @boundary = boundary + @permissions = Array(permissions).map(&:to_sym) + @token = token || current_token + + validate_inputs! + end + + def execute + return success unless should_check_authorization? + return missing_inputs_error unless missing_inputs.empty? + + authorized? ? success : access_denied_error + end + + private + + attr_reader :boundary, :permissions, :token + + def validate_inputs! + validate_boundary! + validate_permissions! + end + + def validate_boundary! + return if boundary.nil? + return if boundary.is_a?(::Authz::Boundary::Base) + + raise InvalidInputError, "Boundary must be an instance of Authz::Boundary::Base, got #{boundary.class.name}" + end + + def validate_permissions! + return if permissions.empty? + + invalid_permissions = permissions - Authz::Permission.all.keys + return if invalid_permissions.empty? + + raise InvalidInputError, "Invalid permissions: #{invalid_permissions.join(', ')}" + end + + def should_check_authorization? + token_supports_granular_permissions? && + (token.granular? || granular_token_required?) + end + + def authorized? + missing_permissions.empty? + end + + def current_token + ::Current.token_info&.[](:token) + end + + def token_supports_granular_permissions? + token.respond_to?(:granular?) && token.respond_to?(:can?) + end + + def granular_token_required? + false # to be implemented as a namespace setting + end + + def missing_inputs + { token:, boundary:, permissions: }.select { |_, value| value.blank? }.keys + end + strong_memoize_attr :missing_inputs + + def missing_permissions + permissions.reject { |permission| token.can?(permission, boundary) } + end + strong_memoize_attr :missing_permissions + + def missing_inputs_error + error "Unable to determine #{missing_inputs.to_sentence} for authorization" + end + + def success + ::ServiceResponse.success + end + + def access_denied_error + error "Access denied: Your #{token.class.name.titleize} lacks the required permissions: " \ + "[#{missing_permissions.join(', ')}] for \"#{boundary.path}\"." + end + + def error(message) + ::ServiceResponse.error(message:) + end + end + end +end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index d02fc30270b689..927c237c852ff2 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -261,6 +261,7 @@ def extract_personal_access_token def save_current_token_in_env token_info = { + token: access_token, token_id: access_token.id, token_type: access_token.class.to_s, token_scopes: access_token.scopes.map(&:to_sym) diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index b135a1c3d4ae06..0172ab46eaa485 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -1174,6 +1174,7 @@ def auth_header_with(token) subject expect(::Current.token_info).not_to be_nil + expect(::Current.token_info[:token]).to eq(personal_access_token) end context 'when the token is not valid' do diff --git a/spec/services/authz/tokens/authorize_granular_scopes_service_spec.rb b/spec/services/authz/tokens/authorize_granular_scopes_service_spec.rb new file mode 100644 index 00000000000000..3f8ab776db653a --- /dev/null +++ b/spec/services/authz/tokens/authorize_granular_scopes_service_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Authz::Tokens::AuthorizeGranularScopesService, feature_category: :permissions do + let_it_be(:boundary) { Authz::Boundary.for(nil) } + let_it_be(:granular_pat) { create(:granular_pat, namespace: boundary.namespace, permissions: :create_issue) } + let_it_be(:token) { granular_pat } + let_it_be(:permissions) { :create_issue } + + subject(:service) { described_class.new(boundary:, permissions:, token:) } + + before do + allow(Authz::Permission).to receive(:all).and_return(create_issue: nil, create_epic: nil, create_project: nil) + end + + shared_examples 'successful response' do + it 'returns ServiceResponse.success' do + result = service.execute + + expect(result).to be_a(ServiceResponse) + expect(result.success?).to be(true) + end + end + + shared_examples 'error response' do |message| + it 'returns ServiceResponse.error' do + result = service.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(message) + end + end + + describe '#initialize' do + context 'when the passed boundary is not an Authz::Boundary' do + let(:boundary) { build(:project) } + + it 'raises an InvalidInputError error' do + expect { service }.to raise_error(Authz::Tokens::AuthorizeGranularScopesService::InvalidInputError, + 'Boundary must be an instance of Authz::Boundary::Base, got Project') + end + end + + context 'when the passed permissions are not valid' do + let(:permissions) { [:a, :b, :create_issue] } + + it 'raises an InvalidInputError error' do + expect { service }.to raise_error(Authz::Tokens::AuthorizeGranularScopesService::InvalidInputError, + 'Invalid permissions: a, b') + end + end + end + + describe '#execute' do + it_behaves_like 'successful response' + + context 'when the token is missing' do + let(:token) { nil } + + it_behaves_like 'successful response' + + context 'when the token is available in the current context' do + before do + ::Current.token_info = { token: granular_pat } + end + + it_behaves_like 'successful response' + end + end + + context 'when the boundary is missing' do + let(:boundary) { nil } + + it_behaves_like 'error response', 'Unable to determine boundary for authorization' + end + + context 'when permissions are missing' do + let(:permissions) { nil } + + it_behaves_like 'error response', 'Unable to determine permissions for authorization' + end + + context 'when the token does not support fine-grained permissions' do + let(:token) { build(:oauth_access_token) } + + it_behaves_like 'successful response' + end + + context 'when the token is supported, but is not granular' do + let(:token) { build(:personal_access_token) } + + it_behaves_like 'successful response' + + context 'when the namespace requires granular tokens' do + before do + allow(service).to receive(:granular_token_required?).and_return(true) + end + + it_behaves_like 'error response', 'Access denied: Your Personal Access Token lacks the required permissions: ' \ + '[create_issue] for "instance".' + end + end + + context 'when the token does not have the required permissions' do + let_it_be(:permissions) { [:create_issue, :create_epic, :create_project] } + + it_behaves_like 'error response', 'Access denied: Your Personal Access Token lacks the required permissions: ' \ + '[create_epic, create_project] for "instance".' + end + end +end -- GitLab From aabcdae00816c1a3fd12872f7313f592cb0da5fb Mon Sep 17 00:00:00 2001 From: Alex Buijs Date: Thu, 11 Sep 2025 16:17:25 +0200 Subject: [PATCH 2/2] Add demo endpoints --- .../access_token_validation_service.rb | 2 +- .../personal_access_tokens/create_service.rb | 3 +- config/authz/permissions/issue/update.yml | 5 ++++ lib/api/api_guard.rb | 29 ++++++++++++++++++- lib/api/issues.rb | 2 ++ lib/gitlab/auth.rb | 4 ++- lib/gitlab/auth/auth_finders.rb | 1 + spec/lib/gitlab/auth_spec.rb | 27 ++++++++++------- 8 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 config/authz/permissions/issue/update.yml diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb index eb2e66a9285384..8bc9a505492dde 100644 --- a/app/services/access_token_validation_service.rb +++ b/app/services/access_token_validation_service.rb @@ -22,7 +22,7 @@ def validate(scopes: []) elsif token.revoked? REVOKED - elsif !self.include_any_scope?(scopes) + elsif !token.try(:granular?) && !self.include_any_scope?(scopes) INSUFFICIENT_SCOPE elsif token.respond_to?(:impersonation) && diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb index c31caaf864e4b1..abf6e25ba03afb 100644 --- a/app/services/personal_access_tokens/create_service.rb +++ b/app/services/personal_access_tokens/create_service.rb @@ -41,7 +41,8 @@ def personal_access_token_params scopes: params[:scopes], expires_at: pat_expiration, organization_id: organization_id, - description: params[:description] + description: params[:description], + granular: params[:granular] || params[:scopes] == ['granular'] } end diff --git a/config/authz/permissions/issue/update.yml b/config/authz/permissions/issue/update.yml new file mode 100644 index 00000000000000..f6f9ab464de065 --- /dev/null +++ b/config/authz/permissions/issue/update.yml @@ -0,0 +1,5 @@ +name: update_issue +description: Grants the ability to update issues +scopes: + - project +feature_category: team_planning diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index d586e00cc6c1e8..2278bbe922a222 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -21,6 +21,7 @@ module APIGuard use AdminModeMiddleware use ResponseCoercerMiddleware use TrackAPIRequestFromRunnerMiddleware + use GranularTokenAuthorizationMiddleware helpers HelperMethods @@ -184,7 +185,8 @@ def install_error_responders(base) Gitlab::Auth::ImpersonationDisabled, Gitlab::Auth::InsufficientScopeError, Gitlab::Auth::RestrictedLanguageServerClientError, - Gitlab::Auth::DpopValidationError] + Gitlab::Auth::DpopValidationError, + Gitlab::Auth::GranularPermissionsError] base.__send__(:rescue_from, *error_classes, oauth2_bearer_token_error_handler) # rubocop:disable GitlabSecurity/PublicSend end @@ -233,6 +235,11 @@ def oauth2_bearer_token_error_handler Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new( :restricted_language_server_client_error, e) + + when Gitlab::Auth::GranularPermissionsError + Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new( + :granular_permissions_error, + e) end status, headers, body = response.finish @@ -352,6 +359,26 @@ def cross_project_request? !current_user.ci_job_token_scope.self_referential?(current_project) end end + + class GranularTokenAuthorizationMiddleware < Grape::Middleware::Base + def after + response = ::Authz::Tokens::AuthorizeGranularScopesService.new(boundary:, permissions:).execute + return if response.success? + + raise Gitlab::Auth::GranularPermissionsError, response.message + end + + private + + def boundary + project = context.instance_variable_get(:@project) + ::Authz::Boundary.for(project) + end + + def permissions + context.route.settings&.dig(:authorization, :permissions) + end + end end end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index cb14b8b30d4e9b..424e7ebf995866 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -279,6 +279,7 @@ class Issues < ::API::Base use :issue_params end route_setting :mcp, tool_name: :create_issue, params: Helpers::IssuesHelpers.create_issue_mcp_params + route_setting :authorization, permissions: [:create_issue] post ':id/issues' do Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/21140') @@ -325,6 +326,7 @@ class Issues < ::API::Base at_least_one_of(*Helpers::IssuesHelpers.update_params_at_least_one_of) end + route_setting :authorization, permissions: [:update_issue] # rubocop: disable CodeReuse/ActiveRecord put ':id/issues/:issue_iid' do Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20775') diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index cb7c055dec1cfe..27d62d41476890 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -17,13 +17,15 @@ module Auth CREATE_RUNNER_SCOPE = :create_runner MANAGE_RUNNER_SCOPE = :manage_runner MCP_SCOPE = :mcp + GRANULAR_SCOPE = :granular API_SCOPES = [ API_SCOPE, READ_API_SCOPE, READ_USER_SCOPE, CREATE_RUNNER_SCOPE, MANAGE_RUNNER_SCOPE, K8S_PROXY_SCOPE, SELF_ROTATE_SCOPE, - MCP_SCOPE + MCP_SCOPE, + GRANULAR_SCOPE ].freeze # Scopes for Duo diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 927c237c852ff2..1a2b4a9cc18361 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -9,6 +9,7 @@ module Auth RevokedError = Class.new(AuthenticationError) ImpersonationDisabled = Class.new(AuthenticationError) UnauthorizedError = Class.new(AuthenticationError) + GranularPermissionsError = Class.new(AuthenticationError) class DpopValidationError < AuthenticationError def initialize(msg) diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 66f53b21a0efa1..24ffffb0883bbf 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -15,7 +15,7 @@ describe 'constants' do it 'API_SCOPES contains all scopes for API access' do - expect(subject::API_SCOPES).to match_array %i[api read_user read_api create_runner manage_runner k8s_proxy self_rotate mcp] + expect(subject::API_SCOPES).to match_array %i[api read_user read_api create_runner manage_runner k8s_proxy self_rotate mcp granular] end it 'ADMIN_SCOPES contains all scopes for ADMIN access' do @@ -50,8 +50,9 @@ end it 'contains all non-default scopes' do - # MCP_SCOPE is available, but not in the UI. - expect(subject.all_available_scopes - [subject::MCP_SCOPE]).to match_array(subject::UI_SCOPES_ORDERED_BY_PERMISSION) + # MCP_SCOPE and GRANULAR_SCOPEs are available, but not in the UI. + expect(subject.all_available_scopes - [subject::MCP_SCOPE, subject::GRANULAR_SCOPE]) + .to match_array(subject::UI_SCOPES_ORDERED_BY_PERMISSION) end it 'contains for non-admin user all non-default scopes without ADMIN access and without observability scopes' do @@ -60,6 +61,7 @@ expect(subject.available_scopes_for(user)).to match_array %i[ api read_user read_api read_repository write_repository read_registry write_registry create_runner manage_runner k8s_proxy ai_features self_rotate read_virtual_registry write_virtual_registry mcp + granular ] end @@ -69,6 +71,7 @@ expect(subject.available_scopes_for(user)).to match_array %i[ api read_user read_api read_repository read_service_ping write_repository read_registry write_registry sudo admin_mode create_runner manage_runner k8s_proxy ai_features self_rotate read_virtual_registry write_virtual_registry mcp + granular ] end @@ -76,7 +79,7 @@ expect(subject.available_scopes_for(project)).to match_array %i[ api read_api read_repository write_repository read_registry write_registry read_observability write_observability create_runner manage_runner k8s_proxy ai_features - self_rotate mcp + self_rotate mcp granular ] end @@ -86,7 +89,7 @@ expect(subject.available_scopes_for(group)).to match_array %i[ api read_api read_repository write_repository read_registry write_registry read_observability write_observability create_runner manage_runner k8s_proxy ai_features - self_rotate read_virtual_registry write_virtual_registry mcp + self_rotate read_virtual_registry write_virtual_registry mcp granular ] end @@ -120,6 +123,7 @@ write_repository read_virtual_registry write_virtual_registry + granular ] end @@ -136,7 +140,7 @@ expect(subject.available_scopes_for(group)).to match_array %i[ api read_api read_repository write_repository read_registry write_registry create_runner manage_runner - k8s_proxy ai_features self_rotate read_virtual_registry write_virtual_registry mcp + k8s_proxy ai_features self_rotate read_virtual_registry write_virtual_registry mcp granular ] end @@ -148,7 +152,7 @@ expect(subject.available_scopes_for(project)).to match_array %i[ api read_api read_repository write_repository read_registry write_registry create_runner manage_runner - k8s_proxy ai_features self_rotate mcp + k8s_proxy ai_features self_rotate mcp granular ] end end @@ -169,7 +173,7 @@ expect(subject.available_scopes_for(group)).to match_array %i[ api read_api read_repository write_repository read_registry write_registry read_observability write_observability create_runner manage_runner k8s_proxy ai_features - self_rotate read_virtual_registry write_virtual_registry mcp + self_rotate read_virtual_registry write_virtual_registry mcp granular ] end @@ -179,6 +183,7 @@ expect(subject.available_scopes_for(user)).to match_array %i[ api read_user read_api read_repository write_repository read_registry write_registry read_service_ping sudo admin_mode create_runner manage_runner k8s_proxy ai_features self_rotate read_virtual_registry write_virtual_registry mcp + granular ] end @@ -186,7 +191,7 @@ expect(subject.available_scopes_for(project)).to match_array %i[ api read_api read_repository write_repository read_registry write_registry read_observability write_observability create_runner manage_runner k8s_proxy ai_features - self_rotate mcp + self_rotate mcp granular ] end @@ -199,7 +204,7 @@ expect(subject.available_scopes_for(other_group)).to match_array %i[ api read_api read_repository write_repository read_registry write_registry create_runner manage_runner k8s_proxy ai_features - self_rotate read_virtual_registry write_virtual_registry mcp + self_rotate read_virtual_registry write_virtual_registry mcp granular ] end @@ -212,7 +217,7 @@ expect(subject.available_scopes_for(other_project)).to match_array %i[ api read_api read_repository write_repository read_registry write_registry - create_runner manage_runner k8s_proxy ai_features self_rotate mcp + create_runner manage_runner k8s_proxy ai_features self_rotate mcp granular ] end end -- GitLab