Commit 0956ef50 authored by David Fernandez's avatar David Fernandez

Merge branch '280586-dependency-proxy-deploy-tokens' into 'master'

Deploy token access for the dependency proxy

See merge request gitlab-org/gitlab!64363
parents 0d56b371 b723758c
# frozen_string_literal: true
module DependencyProxy
module Auth
extend ActiveSupport::Concern
included do
# We disable `authenticate_user!` since the `DependencyProxy::Auth` performs auth using JWT token
skip_before_action :authenticate_user!, raise: false
prepend_before_action :authenticate_user_from_jwt_token!
end
def authenticate_user_from_jwt_token!
return unless dependency_proxy_for_private_groups?
authenticate_with_http_token do |token, _|
user = user_from_token(token)
sign_in(user) if user
end
request_bearer_token! unless current_user
end
private
def dependency_proxy_for_private_groups?
Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true)
end
def request_bearer_token!
# unfortunately, we cannot use https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html#method-i-authentication_request
response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header
render plain: '', status: :unauthorized
end
def user_from_token(token)
token_payload = DependencyProxy::AuthTokenService.decoded_token_payload(token)
User.find(token_payload['user_id'])
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature
nil
end
end
end
......@@ -12,15 +12,15 @@ module DependencyProxy
private
def verify_dependency_proxy_enabled!
render_404 unless group.dependency_proxy_feature_available?
render_404 unless group&.dependency_proxy_feature_available?
end
def authorize_read_dependency_proxy!
access_denied! unless can?(current_user, :read_dependency_proxy, group)
access_denied! unless can?(auth_user, :read_dependency_proxy, group)
end
def authorize_admin_dependency_proxy!
access_denied! unless can?(current_user, :admin_dependency_proxy, group)
access_denied! unless can?(auth_user, :admin_dependency_proxy, group)
end
end
end
......@@ -2,7 +2,7 @@
module Groups
class DependencyProxiesController < Groups::ApplicationController
include DependencyProxy::GroupAccess
include ::DependencyProxy::GroupAccess
before_action :authorize_admin_dependency_proxy!, only: :update
before_action :dependency_proxy
......
# frozen_string_literal: true
module Groups
module DependencyProxy
class ApplicationController < ::ApplicationController
EMPTY_AUTH_RESULT = Gitlab::Auth::Result.new(nil, nil, nil, nil).freeze
delegate :actor, to: :@authentication_result, allow_nil: true
# This allows auth_user to be set in the base ApplicationController
alias_method :authenticated_user, :actor
# We disable `authenticate_user!` since the `DependencyProxy::ApplicationController` performs auth using JWT token
skip_before_action :authenticate_user!, raise: false
prepend_before_action :authenticate_user_from_jwt_token!
def authenticate_user_from_jwt_token!
return unless dependency_proxy_for_private_groups?
if Feature.enabled?(:dependency_proxy_deploy_tokens)
authenticate_with_http_token do |token, _|
@authentication_result = EMPTY_AUTH_RESULT
found_user = user_from_token(token)
sign_in(found_user) if found_user.is_a?(User)
end
request_bearer_token! unless authenticated_user
else
authenticate_with_http_token do |token, _|
user = user_from_token(token)
sign_in(user) if user
end
request_bearer_token! unless current_user
end
end
private
def dependency_proxy_for_private_groups?
Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true)
end
def request_bearer_token!
# unfortunately, we cannot use https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html#method-i-authentication_request
response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header
render plain: '', status: :unauthorized
end
def user_from_token(token)
token_payload = ::DependencyProxy::AuthTokenService.decoded_token_payload(token)
return User.find(token_payload['user_id']) unless Feature.enabled?(:dependency_proxy_deploy_tokens)
if token_payload['user_id']
token_user = User.find(token_payload['user_id'])
return unless token_user
@authentication_result = Gitlab::Auth::Result.new(token_user, nil, :user, [])
return token_user
elsif token_payload['deploy_token']
deploy_token = DeployToken.active.find_by_token(token_payload['deploy_token'])
return unless deploy_token
@authentication_result = Gitlab::Auth::Result.new(deploy_token, nil, :deploy_token, [])
return deploy_token
end
nil
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature
nil
end
end
end
end
# frozen_string_literal: true
class Groups::DependencyProxyAuthController < ApplicationController
include DependencyProxy::Auth
class Groups::DependencyProxyAuthController < ::Groups::DependencyProxy::ApplicationController
feature_category :dependency_proxy
def authenticate
......
# frozen_string_literal: true
class Groups::DependencyProxyForContainersController < Groups::ApplicationController
include DependencyProxy::Auth
class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy::ApplicationController
include Gitlab::Utils::StrongMemoize
include DependencyProxy::GroupAccess
include SendFileUpload
include ::PackagesHelper # for event tracking
before_action :ensure_group
before_action :ensure_token_granted!
before_action :ensure_feature_enabled!
......@@ -24,7 +25,7 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro
content_type = result[:manifest].content_type
event_name = tracking_event_name(object_type: :manifest, from_cache: result[:from_cache])
track_package_event(event_name, :dependency_proxy, namespace: group, user: current_user)
track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user)
send_upload(
result[:manifest].file,
proxy: true,
......@@ -42,7 +43,7 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro
if result[:status] == :success
event_name = tracking_event_name(object_type: :blob, from_cache: result[:from_cache])
track_package_event(event_name, :dependency_proxy, namespace: group, user: current_user)
track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user)
send_upload(result[:blob].file)
else
head result[:http_status]
......@@ -51,6 +52,12 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro
private
def group
strong_memoize(:group) do
Group.find_by_full_path(params[:group_id], follow_redirects: request.get?)
end
end
def image
params[:image]
end
......@@ -71,6 +78,10 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro
group.dependency_proxy_setting || group.create_dependency_proxy_setting
end
def ensure_group
render_404 unless group
end
def ensure_feature_enabled!
render_404 unless dependency_proxy.enabled
end
......
......@@ -10,6 +10,7 @@ class DeployToken < ApplicationRecord
AVAILABLE_SCOPES = %i(read_repository read_registry write_registry
read_package_registry write_package_registry).freeze
GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token'
REQUIRED_DEPENDENCY_PROXY_SCOPES = %i[read_registry write_registry].freeze
default_value_for(:expires_at) { Forever.date }
......@@ -46,6 +47,12 @@ class DeployToken < ApplicationRecord
active.find_by(name: GITLAB_DEPLOY_TOKEN_NAME)
end
def valid_for_dependency_proxy?
group_type? &&
active? &&
REQUIRED_DEPENDENCY_PROXY_SCOPES.all? { |scope| scope.in?(scopes) }
end
def revoke!
update!(revoked: true)
end
......@@ -73,6 +80,14 @@ class DeployToken < ApplicationRecord
holder.has_access_to?(requested_project)
end
def has_access_to_group?(requested_group)
return false unless active?
return false unless group_type?
return false unless holder
holder.has_access_to_group?(requested_group)
end
# This is temporal. Currently we limit DeployToken
# to a single project or group, later we're going to
# extend that to be for multiple projects and namespaces.
......
......@@ -11,9 +11,14 @@ class GroupDeployToken < ApplicationRecord
def has_access_to?(requested_project)
requested_project_group = requested_project&.group
return false unless requested_project_group
return true if requested_project_group.id == group_id
requested_project_group
has_access_to_group?(requested_project_group)
end
def has_access_to_group?(requested_group)
return true if requested_group.id == group_id
requested_group
.ancestors
.where(id: group_id)
.exists?
......
......@@ -50,6 +50,14 @@ class GroupPolicy < BasePolicy
@subject.dependency_proxy_feature_available?
end
condition(:dependency_proxy_access_allowed) do
if Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true)
access_level >= GroupMember::REPORTER || valid_dependency_proxy_deploy_token
else
can?(:read_group)
end
end
desc "Deploy token with read_package_registry scope"
condition(:read_package_registry_deploy_token) do
@user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.read_package_registry
......@@ -212,7 +220,7 @@ class GroupPolicy < BasePolicy
enable :read_group
end
rule { can?(:read_group) & dependency_proxy_available }
rule { dependency_proxy_access_allowed & dependency_proxy_available }
.enable :read_dependency_proxy
rule { developer & dependency_proxy_available }
......@@ -260,6 +268,10 @@ class GroupPolicy < BasePolicy
def resource_access_token_creation_allowed?
resource_access_token_feature_available? && group.root_ancestor.namespace_settings.resource_access_token_creation_allowed?
end
def valid_dependency_proxy_deploy_token
@user.is_a?(DeployToken) && @user&.valid_for_dependency_proxy? && @user&.has_access_to_group?(@subject)
end
end
GroupPolicy.prepend_mod_with('GroupPolicy')
......@@ -8,10 +8,7 @@ module Auth
def execute(authentication_abilities:)
return error('dependency proxy not enabled', 404) unless ::Gitlab.config.dependency_proxy.enabled
# Because app/controllers/concerns/dependency_proxy/auth.rb consumes this
# JWT only as `User.find`, we currently only allow User (not DeployToken, etc)
return error('access forbidden', 403) unless current_user
return error('access forbidden', 403) unless valid_user_actor?
{ token: authorized_token.encoded }
end
......@@ -36,11 +33,24 @@ module Auth
private
def valid_user_actor?
current_user || valid_deploy_token?
end
def valid_deploy_token?
deploy_token && deploy_token.valid_for_dependency_proxy?
end
def authorized_token
JSONWebToken::HMACToken.new(self.class.secret).tap do |token|
token['user_id'] = current_user.id
token['user_id'] = current_user.id if current_user
token['deploy_token'] = deploy_token.token if deploy_token
token.expire_time = self.class.token_expire_at
end
end
def deploy_token
params[:deploy_token]
end
end
end
---
name: dependency_proxy_deploy_tokens
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64363
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334565
milestone: '14.2'
type: development
group: group::package
default_enabled: false
......@@ -89,6 +89,7 @@ You can authenticate using:
- Your GitLab username and password.
- A [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `read_registry` and `write_registry`.
- A [group deploy token](../../../user/project/deploy_tokens/index.md#group-deploy-token) with the scope set to `read_registry` and `write_registry`.
#### Authenticate within CI/CD
......@@ -123,7 +124,7 @@ Proxy manually without including the port:
docker pull gitlab.example.com:443/my-group/dependency_proxy/containers/alpine:latest
```
You can also use [custom CI/CD variables](../../../ci/variables/index.md#custom-cicd-variables) to store and access your personal access token or other valid credentials.
You can also use [custom CI/CD variables](../../../ci/variables/index.md#custom-cicd-variables) to store and access your personal access token or deploy token.
### Store a Docker image in Dependency Proxy cache
......
......@@ -30,16 +30,31 @@ RSpec.describe Groups::DependencyProxyAuthController do
end
context 'with valid JWT' do
let_it_be(:user) { create(:user) }
context 'user' do
let_it_be(:user) { create(:user) }
let(:jwt) { build_jwt(user) }
let(:token_header) { "Bearer #{jwt.encoded}" }
let(:jwt) { build_jwt(user) }
let(:token_header) { "Bearer #{jwt.encoded}" }
before do
request.headers['HTTP_AUTHORIZATION'] = token_header
before do
request.headers['HTTP_AUTHORIZATION'] = token_header
end
it { is_expected.to have_gitlab_http_status(:success) }
end
it { is_expected.to have_gitlab_http_status(:success) }
context 'deploy token' do
let_it_be(:user) { create(:deploy_token) }
let(:jwt) { build_jwt(user) }
let(:token_header) { "Bearer #{jwt.encoded}" }
before do
request.headers['HTTP_AUTHORIZATION'] = token_header
end
it { is_expected.to have_gitlab_http_status(:success) }
end
end
context 'with invalid JWT' do
......@@ -51,7 +66,7 @@ RSpec.describe Groups::DependencyProxyAuthController do
request.headers['HTTP_AUTHORIZATION'] = token_header
end
it { is_expected.to have_gitlab_http_status(:not_found) }
it { is_expected.to have_gitlab_http_status(:unauthorized) }
end
context 'token with no user id' do
......@@ -61,7 +76,7 @@ RSpec.describe Groups::DependencyProxyAuthController do
request.headers['HTTP_AUTHORIZATION'] = token_header
end
it { is_expected.to have_gitlab_http_status(:not_found) }
it { is_expected.to have_gitlab_http_status(:unauthorized) }
end
context 'expired token' do
......@@ -76,6 +91,32 @@ RSpec.describe Groups::DependencyProxyAuthController do
it { is_expected.to have_gitlab_http_status(:unauthorized) }
end
context 'expired deploy token' do
let_it_be(:user) { create(:deploy_token, :expired) }
let(:jwt) { build_jwt(user) }
let(:token_header) { "Bearer #{jwt.encoded}" }
before do
request.headers['HTTP_AUTHORIZATION'] = token_header
end
it { is_expected.to have_gitlab_http_status(:unauthorized) }
end
context 'revoked deploy token' do
let_it_be(:user) { create(:deploy_token, :revoked) }
let(:jwt) { build_jwt(user) }
let(:token_header) { "Bearer #{jwt.encoded}" }
before do
request.headers['HTTP_AUTHORIZATION'] = token_header
end
it { is_expected.to have_gitlab_http_status(:unauthorized) }
end
end
end
end
......@@ -35,9 +35,13 @@ FactoryBot.define do
end
trait :all_scopes do
write_registry { true}
write_registry { true }
read_package_registry { true }
write_package_registry { true }
end
trait :dependency_proxy_scopes do
write_registry { true }
end
end
end
......@@ -22,6 +22,32 @@ RSpec.describe DeployToken do
it { is_expected.to validate_presence_of(:deploy_token_type) }
end
shared_examples 'invalid group deploy token' do
context 'revoked' do
before do
deploy_token.update_column(:revoked, true)
end
it { is_expected.to eq(false) }
end
context 'expired' do
before do
deploy_token.update!(expires_at: Date.today - 1.month)
end
it { is_expected.to eq(false) }
end
context 'project type' do
before do
deploy_token.update_column(:deploy_token_type, 2)
end
it { is_expected.to eq(false) }
end
end
describe 'deploy_token_type validations' do
context 'when a deploy token is associated to a group' do
it 'does not allow setting a project to it' do
......@@ -70,6 +96,50 @@ RSpec.describe DeployToken do
end
end
describe '#valid_for_dependency_proxy?' do
let_it_be_with_reload(:deploy_token) { create(:deploy_token, :group, :dependency_proxy_scopes) }
subject { deploy_token.valid_for_dependency_proxy? }
it { is_expected.to eq(true) }
it_behaves_like 'invalid group deploy token'
context 'insufficient scopes' do
before do
deploy_token.update_column(:write_registry, false)
end
it { is_expected.to eq(false) }
end
end
describe '#has_access_to_group?' do
let_it_be(:group) { create(:group) }
let_it_be_with_reload(:deploy_token) { create(:deploy_token, :group) }
let_it_be(:group_deploy_token) { create(:group_deploy_token, group: group, deploy_token: deploy_token) }
let(:test_group) { group }
subject { deploy_token.has_access_to_group?(test_group) }
it { is_expected.to eq(true) }
it_behaves_like 'invalid group deploy token'
context 'for a sub group' do
let(:test_group) { create(:group, parent: group) }
it { is_expected.to eq(true) }
end
context 'for a different group' do
let(:test_group) { create(:group) }
it { is_expected.to eq(false) }
end
end
describe '#scopes' do
context 'with all the scopes' do
let_it_be(:deploy_token) { create(:deploy_token, :all_scopes) }
......
......@@ -3,15 +3,40 @@
require 'spec_helper'
RSpec.describe GroupDeployToken, type: :model do
let(:group) { create(:group) }
let(:deploy_token) { create(:deploy_token) }
let_it_be(:group) { create(:group) }
let_it_be(:deploy_token) { create(:deploy_token) }
let_it_be(:group_deploy_token) { create(:group_deploy_token, group: group, deploy_token: deploy_token) }
subject(:group_deploy_token) { create(:group_deploy_token, group: group, deploy_token: deploy_token) }
describe 'relationships' do
it { is_expected.to belong_to :group }
it { is_expected.to belong_to :deploy_token }
end
it { is_expected.to belong_to :group }
it { is_expected.to belong_to :deploy_token }
describe 'validation' do
it { is_expected.to validate_presence_of :deploy_token }
it { is_expected.to validate_presence_of :group }
it { is_expected.to validate_uniqueness_of(:deploy_token_id).scoped_to(:group_id) }
end
it { is_expected.to validate_presence_of :deploy_token }
it { is_expected.to validate_presence_of :group }
it { is_expected.to validate_uniqueness_of(:deploy_token_id).scoped_to(:group_id) }
describe '#has_access_to_group?' do
subject { group_deploy_token.has_access_to_group?(test_group) }
context 'for itself' do
let(:test_group) { group }
it { is_expected.to eq(true) }
end
context 'for a subgroup' do
let(:test_group) { create(:group, parent: group) }
it { is_expected.to eq(true) }
end
context 'for other group' do
let(:test_group) { create(:group) }
it { is_expected.to eq(false) }
end
end
end
......@@ -224,8 +224,10 @@ RSpec.describe JwtController do
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :private, group: group) }
let_it_be(:group_deploy_token) { create(:deploy_token, :group, groups: [group]) }
let_it_be(:project_deploy_token) { create(:deploy_token, :project, projects: [project]) }
let_it_be(:group_deploy_token) { create(:deploy_token, :group, :dependency_proxy_scopes) }
let_it_be(:gdeploy_token) { create(:group_deploy_token, deploy_token: group_deploy_token, group: group) }
let_it_be(:project_deploy_token) { create(:deploy_token, :project, :dependency_proxy_scopes) }
let_it_be(:pdeploy_token) { create(:project_deploy_token, deploy_token: project_deploy_token, project: project) }
let_it_be(:service_name) { 'dependency_proxy' }
let(:headers) { { authorization: credentials(credential_user, credential_password) } }
......@@ -264,7 +266,7 @@ RSpec.describe JwtController do
let(:credential_user) { group_deploy_token.username }
let(:credential_password) { group_deploy_token.token }
it_behaves_like 'returning response status', :forbidden
it_behaves_like 'with valid credentials'
end
context 'with project deploy token' do
......@@ -274,6 +276,28 @@ RSpec.describe JwtController do
it_behaves_like 'returning response status', :forbidden
end
context 'with revoked group deploy token' do
let(:credential_user) { group_deploy_token.username }
let(:credential_password) { project_deploy_token.token }
before do
group_deploy_token.update_column(:revoked, true)
end
it_behaves_like 'returning response status', :unauthorized
end
context 'with group deploy token with insufficient scopes' do
let(:credential_user) { group_deploy_token.username }
let(:credential_password) { project_deploy_token.token }
before do
group_deploy_token.update_column(:write_registry, false)
end
it_behaves_like 'returning response status', :unauthorized
end
context 'with invalid credentials' do
let(:credential_user) { 'foo' }
let(:credential_password) { 'bar' }
......
......@@ -21,6 +21,12 @@ RSpec.describe Auth::DependencyProxyAuthenticationService do
end
end
shared_examples 'returning a token' do
it 'returns a token' do
expect(subject[:token]).not_to be_nil
end
end
context 'dependency proxy is not enabled' do
before do
stub_config(dependency_proxy: { enabled: false })
......@@ -35,10 +41,14 @@ RSpec.describe Auth::DependencyProxyAuthenticationService do
it_behaves_like 'returning', status: 403, message: 'access forbidden'
end
context 'with a deploy token as user' do
let_it_be(:user) { create(:deploy_token, :group, :dependency_proxy_scopes) }
it_behaves_like 'returning a token'
end
context 'with a user' do
it 'returns a token' do
expect(subject[:token]).not_to be_nil
end
it_behaves_like 'returning a token'
end
end
end
......@@ -14,6 +14,19 @@ RSpec.describe DependencyProxy::AuthTokenService do
result = subject
expect(result['user_id']).to eq(user.id)
expect(result['deploy_token']).to be_nil
end
context 'with a deploy token' do
let_it_be(:deploy_token) { create(:deploy_token) }
let_it_be(:token) { build_jwt(deploy_token) }
it 'returns the deploy token' do
result = subject
expect(result['deploy_token']).to eq(deploy_token.token)
expect(result['user_id']).to be_nil
end
end
it 'raises an error if the token is expired' do
......
......@@ -34,7 +34,8 @@ module DependencyProxyHelpers
def build_jwt(user = nil, expire_time: nil)
JSONWebToken::HMACToken.new(::Auth::DependencyProxyAuthenticationService.secret).tap do |jwt|
jwt['user_id'] = user.id if user
jwt['user_id'] = user.id if user.is_a?(User)
jwt['deploy_token'] = user.token if user.is_a?(DeployToken)
jwt.expire_time = expire_time || jwt.issued_at + 1.minute
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment