diff --git a/config/routes.rb b/config/routes.rb index c410d349eb493dc38227a3bcd65cc51ec9b2902d..76c1405ef4d9769d00dd46820bffeef091e76d43 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -325,6 +325,7 @@ draw :user draw :project draw :unmatched_project + draw :virtual_registry draw :well_known # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/210024 diff --git a/config/routes/virtual_registry.rb b/config/routes/virtual_registry.rb new file mode 100644 index 0000000000000000000000000000000000000000..83a45bec2117adb453dd8f4b4ae894230f6020af --- /dev/null +++ b/config/routes/virtual_registry.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Virtual Registries for Containers +scope format: false do + # Manifest endpoints - handle BOTH tag and digest + get 'v2/virtual_registries/containers/:id/*image/manifests/*tag_or_digest', + to: 'virtual_registries/containers#manifest', + as: :virtual_registries_containers_manifest, + constraints: { + image: Gitlab::PathRegex.container_image_regex, + tag_or_digest: /[\w][\w.-]{0,127}|sha256:[a-f0-9]{64}/ # Matches both tags AND digests + } + + # Blob endpoints + get 'v2/virtual_registries/containers/:id/*image/blobs/:sha', + to: 'virtual_registries/containers#blob', + as: :virtual_registries_containers_blob, + constraints: { + image: Gitlab::PathRegex.container_image_regex, + sha: Gitlab::PathRegex.container_image_blob_sha_regex + } +end diff --git a/ee/app/controllers/virtual_registries/containers_controller.rb b/ee/app/controllers/virtual_registries/containers_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..b9b46c1243e6a0db8981cee5c5cd049e614380ef --- /dev/null +++ b/ee/app/controllers/virtual_registries/containers_controller.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +module VirtualRegistries + class ContainersController < ::ApplicationController + include SendFileUpload + include ::PackagesHelper # for event tracking + include Gitlab::Utils::StrongMemoize + + EMPTY_AUTH_RESULT = Gitlab::Auth::Result.new(nil, nil, nil, nil).freeze + + # Response headers matching Maven VR pattern + EXTRA_RESPONSE_HEADERS = { + 'Docker-Distribution-Api-Version' => 'registry/2.0', + 'Content-Security-Policy' => "sandbox; default-src 'none'; require-trusted-types-for 'script'", + 'X-Content-Type-Options' => 'nosniff' + }.freeze + + ALLOWED_RESPONSE_HEADERS = %w[ + Content-Length + Content-Type + Docker-Content-Digest + Docker-Distribution-Api-Version + Etag + ].freeze + + MAX_FILE_SIZE = 5.gigabytes + + delegate :actor, to: :@authentication_result, allow_nil: true + alias_method :authenticated_user, :actor + + # We disable `authenticate_user!` since we perform auth using JWT token + skip_before_action :authenticate_user!, raise: false + prepend_before_action :authenticate_user_from_jwt_token! + before_action :skip_session + + before_action :ensure_registry + before_action :ensure_feature_enabled! + before_action :authorize_read_virtual_registry! + + feature_category :virtual_registry + urgency :low + + PERMITTED_PARAMS = [:id, :image, :tag, :tag_or_digest, :file, :sha].freeze + + def manifest + service_response = ::VirtualRegistries::Container::HandleFileRequestService.new( + registry: registry, + current_user: authenticated_user, + params: { path: manifest_path } + ).execute + + if service_response.error? + send_error_response_from!(service_response: service_response) + else + send_successful_response_from(service_response: service_response) + end + end + + def blob + service_response = ::VirtualRegistries::Container::HandleFileRequestService.new( + registry: registry, + current_user: authenticated_user, + params: { path: blob_path } + ).execute + + if service_response.error? + send_error_response_from!(service_response: service_response) + else + send_successful_response_from(service_response: service_response) + end + end + + private + + attr_reader :personal_access_token + + # JWT Authentication (duplicated from Groups::DependencyProxy::ApplicationController) + def authenticate_user_from_jwt_token! + authenticate_with_http_token do |token, _| + @authentication_result = EMPTY_AUTH_RESULT + + user_or_token = ::DependencyProxy::AuthTokenService.user_or_token_from_jwt(token) + + case user_or_token + when User + set_auth_result(user_or_token, :user) + sign_in(user_or_token) if can_sign_in?(user_or_token) + when PersonalAccessToken + set_auth_result(user_or_token.user, :personal_access_token) + @personal_access_token = user_or_token + when DeployToken + set_auth_result(user_or_token, :deploy_token) + end + end + + request_bearer_token! unless authenticated_user + end + + def request_bearer_token! + response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header + render plain: '', status: :unauthorized + end + + def can_sign_in?(user_or_token) + return false if user_or_token.project_bot? || user_or_token.service_account? + + true + end + + def set_auth_result(actor, type) + @authentication_result = Gitlab::Auth::Result.new(actor, nil, type, []) + end + + def skip_session + request.session_options[:skip] = true + end + + # Registry and authorization + def registry + ::VirtualRegistries::Container::Registry.find(permitted_params[:id]) + end + strong_memoize_attr :registry + + def ensure_registry + render_404 unless registry + end + + def ensure_feature_enabled! + render_404 unless Feature.enabled?(:container_virtual_registries, registry.group) + end + + def authorize_read_virtual_registry! + access_denied! unless can?(authenticated_user, :read_virtual_registry, registry) + end + + def authorize_write_virtual_registry! + access_denied! unless can?(authenticated_user, :write_virtual_registry, registry) + end + + # Response handling (matching Maven VR pattern) + def send_successful_response_from(service_response:) + action = service_response.payload[:action] + action_params = service_response.payload[:action_params] + + case action + when :download_file + send_cached_file(action_params) + else + head :not_found + end + end + + def send_error_response_from!(service_response:) + case service_response.reason + when :unauthorized + access_denied! + when :no_upstreams, :file_not_found_on_upstreams + render_404(service_response.message) + when :upstream_not_available + render_api_error!(service_response.message, :service_unavailable) + else + bad_request!('Bad Request') + end + end + + # Send cached file with Docker-specific headers + def send_cached_file(action_params) + file = action_params[:file] + content_type = action_params[:content_type] + upstream_etag = action_params[:upstream_etag] + + # Set Docker-specific headers + extra_headers = EXTRA_RESPONSE_HEADERS.dup + extra_headers['Content-Type'] = content_type if content_type.present? + + # For manifests, add the Docker-Content-Digest header + extra_headers['Docker-Content-Digest'] = upstream_etag if manifest_request? && upstream_etag.present? + extra_headers.each { |key, value| response.headers[key] = value } + + track_pull_event(manifest_request? ? :manifest : :blob, from_cache: true) + + send_upload( + file, + proxy: true, + redirect_params: { query: { 'response-content-type' => content_type } }, + send_params: { type: content_type }, + ssrf_params: { + restrict_forwarded_response_headers: { + enabled: true, + allow_list: ALLOWED_RESPONSE_HEADERS + } + } + ) + end + + def authorized_upload_response(upstream) + ::VirtualRegistries::Cache::EntryUploader.workhorse_authorize( + has_length: true, + maximum_size: MAX_FILE_SIZE, + use_final_store_path: true, + final_store_path_config: { + override_path: upstream.object_storage_key + } + ) + end + + # Path helpers + def manifest_path + tag_or_digest = permitted_params[:tag_or_digest] || permitted_params[:tag] + "#{image}/manifests/#{tag_or_digest}" + end + + def blob_path + "#{image}/blobs/#{permitted_params[:sha]}" + end + + def manifest_request? + permitted_params[:tag].present? + end + + def track_pull_event(object_type, from_cache:) + event_name = "pull_#{object_type}" + event_name = "#{event_name}_from_cache" if from_cache + + track_package_event(event_name, :virtual_registry, namespace: registry.group, user: tracking_user) + end + + def image + permitted_params[:image] + end + + def tag + permitted_params[:tag_or_digest] || permitted_params[:tag] + end + + def permitted_params + params.permit(PERMITTED_PARAMS) + end + + def jwt_token + authenticate_with_http_token { |token, _| token } + end + strong_memoize_attr :jwt_token + + def tracking_user + authenticated_user if authenticated_user.is_a?(User) + end + + # Helper methods for error responses + def bad_request!(message) + render json: { message: message }, status: :bad_request + end + + def render_404(message = nil) + render json: { message: message || 'Not Found' }, status: :not_found + end + + def render_api_error!(message, status) + render json: { message: message }, status: status + end + end +end diff --git a/ee/app/services/virtual_registries/container/handle_file_request_service.rb b/ee/app/services/virtual_registries/container/handle_file_request_service.rb index c150aac89899260bca95589abdea3f9c2b68b587..cea740b8549e5002ba2a9e4fb6c538d1211cb4fc 100644 --- a/ee/app/services/virtual_registries/container/handle_file_request_service.rb +++ b/ee/app/services/virtual_registries/container/handle_file_request_service.rb @@ -48,11 +48,24 @@ def cache_response_still_valid? end def cache_entry - VirtualRegistries::Container::Cache::Entry + # First try exact path match + entry = VirtualRegistries::Container::Cache::Entry .default .for_group(registry.group) .for_upstream(registry.upstreams) .find_by_relative_path(path) + + return entry if entry + + # If requesting manifest by digest, search by upstream_etag + return unless path =~ %r{manifests/(sha256:[a-f0-9]{64})$} + + digest = Regexp.last_match(1) + VirtualRegistries::Container::Cache::Entry + .default + .for_group(registry.group) + .for_upstream(registry.upstreams) + .find_by_upstream_etag(digest) end strong_memoize_attr :cache_entry @@ -110,7 +123,8 @@ def download_cache_entry file: cache_entry.file, file_sha1: cache_entry.file_sha1, file_md5: cache_entry.file_md5, - content_type: cache_entry.content_type + content_type: cache_entry.content_type, + upstream_etag: cache_entry.upstream_etag } } ) diff --git a/ee/spec/controllers/virtual_registries/containers_controller_spec.rb b/ee/spec/controllers/virtual_registries/containers_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f107032797695d94c87754a0c1ae5b85481e0417 --- /dev/null +++ b/ee/spec/controllers/virtual_registries/containers_controller_spec.rb @@ -0,0 +1,465 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe VirtualRegistries::ContainersController, feature_category: :virtual_registry do + include HttpBasicAuthHelpers + include DependencyProxyHelpers + + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:group) { create(:group, :private) } + let_it_be_with_reload(:registry) { create(:virtual_registries_container_registry, :with_upstreams, group: group) } + + let(:jwt) { build_jwt(user) } + let(:token_header) { "Bearer #{jwt.encoded}" } + let(:snowplow_gitlab_standard_context) { { namespace: group, user: user } } + + shared_examples 'without a token' do + before do + request.headers['HTTP_AUTHORIZATION'] = nil + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + + it 'sets WWW-Authenticate header' do + subject + expect(response.headers['WWW-Authenticate']).to eq(::DependencyProxy::Registry.authenticate_header) + end + end + + shared_examples 'without permission' do + context 'with invalid user' do + before do + bad_user = instance_double(User, id: 999) + token_header = "Bearer #{build_jwt(bad_user).encoded}" + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + + context 'with valid user that does not have access' do + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'with invalid group access token' do + let_it_be(:user) { create(:user, :project_bot) } + let_it_be(:token) { create(:personal_access_token, user: user, scopes: [Gitlab::Auth::READ_API_SCOPE]) } + let_it_be(:jwt) { build_jwt(token) } + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'with deploy token from a different group' do + let_it_be(:user) { create(:deploy_token, :group, :dependency_proxy_scopes) } + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'with revoked deploy token' do + let_it_be(:user) { create(:deploy_token, :revoked, :group, :dependency_proxy_scopes, groups: [group]) } + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + + context 'with expired deploy token' do + let_it_be(:user) { create(:deploy_token, :expired, :group, :dependency_proxy_scopes, groups: [group]) } + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + + context 'when registry is not found' do + before do + allow(VirtualRegistries::Container::Registry).to receive(:find).and_return(nil) + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'when user is not found' do + before do + allow(User).to receive(:find).and_return(nil) + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + end + + shared_examples 'feature flag disabled' do + before do + stub_feature_flags(container_virtual_registries: false) + end + + it 'returns 404' do + subject + expect(response).to have_gitlab_http_status(:not_found) + end + end + + shared_examples 'successful file download' do |file_type:| + it 'returns success with Docker headers', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Docker-Distribution-Api-Version']).to eq('registry/2.0') + expect(response.headers['Content-Security-Policy']).to eq( + "sandbox; default-src 'none'; require-trusted-types-for 'script'") + expect(response.headers['X-Content-Type-Options']).to eq('nosniff') + end + + it 'tracks pull event' do + expect(controller).to receive(:track_package_event).with( + "pull_#{file_type}_from_cache", + :virtual_registry, + namespace: group, + user: user + ) + + subject + end + end + + before do + stub_feature_flags(container_virtual_registries: true) + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + describe 'GET #manifest' do + let_it_be(:image) { 'alpine' } + let_it_be(:tag) { 'latest' } + let_it_be(:cache_entry) do + create(:virtual_registries_container_cache_entry, + upstream: registry.upstreams.first, + group: group, + relative_path: "#{image}/manifests/#{tag}", + content_type: 'application/vnd.docker.distribution.manifest.v2+json', + upstream_etag: 'sha256:abc123') + end + + let(:service_response) do + ServiceResponse.success( + payload: { + action: :download_file, + action_params: { + file: cache_entry.file, + content_type: cache_entry.content_type, + upstream_etag: cache_entry.upstream_etag + } + } + ) + end + + subject(:get_manifest) { get :manifest, params: { id: registry.id, image: image, tag: tag } } + + before do + allow_next_instance_of(VirtualRegistries::Container::HandleFileRequestService) do |service| + allow(service).to receive(:execute).and_return(service_response) + end + end + + context 'with valid authentication' do + before_all do + group.add_guest(user) + end + + it_behaves_like 'successful file download', file_type: :manifest + + it 'includes Docker-Content-Digest header' do + get_manifest + + expect(response.headers['Docker-Content-Digest']).to eq('sha256:abc123') + end + + it 'calls HandleFileRequestService with correct params' do + expect_next_instance_of(VirtualRegistries::Container::HandleFileRequestService) do |service| + expect(service).to receive(:execute).and_return(service_response) + end + + get_manifest + end + + context 'when requesting by digest' do + let(:tag) { 'sha256:abc123' } + + it_behaves_like 'successful file download', file_type: :manifest + end + + context 'with a valid group access token' do + let_it_be(:user) { create(:user, :project_bot) } + let_it_be(:token) { create(:personal_access_token, user: user) } + let_it_be(:jwt) { build_jwt(token) } + + before_all do + token.update_column(:scopes, Gitlab::Auth::REGISTRY_SCOPES) + group.add_guest(user) + end + + it_behaves_like 'successful file download', file_type: :manifest + end + + context 'with a deploy token' do + let_it_be(:user) { create(:deploy_token, :dependency_proxy_scopes, :group, groups: [group]) } + + it_behaves_like 'successful file download', file_type: :manifest + end + end + + context 'with error handling' do + before_all do + group.add_guest(user) + end + + context 'when service returns unauthorized' do + let(:service_response) { ServiceResponse.error(message: 'Unauthorized', reason: :unauthorized) } + + it 'returns forbidden' do + get_manifest + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when service returns no_upstreams' do + let(:service_response) { ServiceResponse.error(message: 'No upstreams', reason: :no_upstreams) } + + it 'returns not found' do + get_manifest + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when service returns file_not_found_on_upstreams' do + let(:service_response) { ServiceResponse.error(message: 'Not found', reason: :file_not_found_on_upstreams) } + + it 'returns not found' do + get_manifest + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when service returns upstream_not_available' do + let(:service_response) do + ServiceResponse.error(message: 'Service unavailable', reason: :upstream_not_available) + end + + it 'returns service unavailable' do + get_manifest + + expect(response).to have_gitlab_http_status(:service_unavailable) + end + end + + context 'when service returns unknown error' do + let(:service_response) { ServiceResponse.error(message: 'Unknown error', reason: :unknown) } + + it 'returns bad request' do + get_manifest + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + + it_behaves_like 'without a token' + it_behaves_like 'without permission' + it_behaves_like 'feature flag disabled' + end + + describe 'GET #blob' do + let_it_be(:image) { 'alpine' } + let_it_be(:sha) { 'sha256:abc123def456' } + let_it_be(:cache_entry) do + create(:virtual_registries_container_cache_entry, + upstream: registry.upstreams.first, + group: group, + relative_path: "#{image}/blobs/#{sha}", + content_type: 'application/octet-stream') + end + + let(:service_response) do + ServiceResponse.success( + payload: { + action: :download_file, + action_params: { + file: cache_entry.file, + content_type: cache_entry.content_type, + upstream_etag: cache_entry.upstream_etag + } + } + ) + end + + subject(:get_blob) { get :blob, params: { id: registry.id, image: image, sha: sha } } + + before do + allow_next_instance_of(VirtualRegistries::Container::HandleFileRequestService) do |service| + allow(service).to receive(:execute).and_return(service_response) + end + end + + context 'with valid authentication' do + before_all do + group.add_guest(user) + end + + it_behaves_like 'successful file download', file_type: :blob + + it 'does not include Docker-Content-Digest header for blobs' do + get_blob + + expect(response.headers['Docker-Content-Digest']).to be_nil + end + + it 'calls HandleFileRequestService with correct params' do + expect_next_instance_of(VirtualRegistries::Container::HandleFileRequestService) do |service| + expect(service).to receive(:execute).and_return(service_response) + end + + get_blob + end + + context 'with a valid group access token' do + let_it_be(:user) { create(:user, :project_bot) } + let_it_be(:token) { create(:personal_access_token, user: user) } + let_it_be(:jwt) { build_jwt(token) } + + before_all do + token.update_column(:scopes, Gitlab::Auth::REGISTRY_SCOPES) + group.add_guest(user) + end + + it_behaves_like 'successful file download', file_type: :blob + end + + context 'with a deploy token' do + let_it_be(:user) { create(:deploy_token, :dependency_proxy_scopes, :group, groups: [group]) } + + it_behaves_like 'successful file download', file_type: :blob + end + end + + context 'with error handling' do + before_all do + group.add_guest(user) + end + + context 'when service returns unauthorized' do + let(:service_response) { ServiceResponse.error(message: 'Unauthorized', reason: :unauthorized) } + + it 'returns forbidden' do + get_blob + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when service returns no_upstreams' do + let(:service_response) { ServiceResponse.error(message: 'No upstreams', reason: :no_upstreams) } + + it 'returns not found' do + get_blob + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when service returns file_not_found_on_upstreams' do + let(:service_response) { ServiceResponse.error(message: 'Not found', reason: :file_not_found_on_upstreams) } + + it 'returns not found' do + get_blob + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when service returns upstream_not_available' do + let(:service_response) do + ServiceResponse.error(message: 'Service unavailable', reason: :upstream_not_available) + end + + it 'returns service unavailable' do + get_blob + + expect(response).to have_gitlab_http_status(:service_unavailable) + end + end + + context 'when service returns unknown error' do + let(:service_response) { ServiceResponse.error(message: 'Unknown error', reason: :unknown) } + + it 'returns bad request' do + get_blob + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + + it_behaves_like 'without a token' + it_behaves_like 'without permission' + it_behaves_like 'feature flag disabled' + end + + describe 'authentication' do + subject(:get_manifest) { get :manifest, params: { id: registry.id, image: 'alpine', tag: 'latest' } } + + context 'with project bot user' do + let_it_be(:user) { create(:user, :project_bot) } + + before_all do + group.add_guest(user) + end + + it 'does not sign in the user' do + expect(controller).not_to receive(:sign_in) + + get_manifest + end + end + + context 'with service account user' do + let_it_be(:user) { create(:user, :service_account) } + + before_all do + group.add_guest(user) + end + + it 'does not sign in the user' do + expect(controller).not_to receive(:sign_in) + + get_manifest + end + end + + context 'with regular user' do + before_all do + group.add_guest(user) + end + + it 'signs in the user' do + expect(controller).to receive(:sign_in).with(user) + + get_manifest + end + end + + context 'with session handling' do + before_all do + group.add_guest(user) + end + + it 'skips session' do + get_manifest + + expect(request.session_options[:skip]).to be true + end + end + end +end