diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml index 3b509368a6b..66e3e6c860b 100644 --- a/.rubocop_manual_todo.yml +++ b/.rubocop_manual_todo.yml @@ -2441,31 +2441,6 @@ Gitlab/FeatureAvailableUsage: - 'lib/api/helpers/related_resources_helpers.rb' - 'spec/models/concerns/featurable_spec.rb' -# WIP See https://gitlab.com/gitlab-org/gitlab/-/issues/327490 -Style/RegexpLiteralMixedPreserve: - Exclude: - - 'qa/qa/page/project/settings/advanced.rb' - - 'qa/spec/service/docker_run/gitlab_runner_spec.rb' - - 'rubocop/cop/gitlab/duplicate_spec_location.rb' - - 'spec/features/clusters/cluster_health_dashboard_spec.rb' - - 'spec/features/markdown/metrics_spec.rb' - - 'spec/features/search/user_searches_for_code_spec.rb' - - 'spec/features/snippets/embedded_snippet_spec.rb' - - 'spec/helpers/diff_helper_spec.rb' - - 'spec/helpers/releases_helper_spec.rb' - - 'spec/lib/gitlab/ci/reports/test_case_spec.rb' - - 'spec/lib/gitlab/consul/internal_spec.rb' - - 'spec/lib/gitlab/import_export/shared_spec.rb' - - 'spec/lib/gitlab/utils/usage_data_spec.rb' - - 'spec/presenters/ci/build_runner_presenter_spec.rb' - - 'spec/requests/api/projects_spec.rb' - - 'spec/services/jira/requests/projects/list_service_spec.rb' - - 'spec/support/capybara.rb' - - 'spec/support/helpers/grafana_api_helpers.rb' - - 'spec/support/helpers/query_recorder.rb' - - 'spec/support/helpers/require_migration.rb' - - 'spec/views/layouts/_head.html.haml_spec.rb' - # WIP see: https://gitlab.com/gitlab-org/gitlab/-/issues/335808 Database/MultipleDatabases: Exclude: diff --git a/Gemfile b/Gemfile index d4126b7614f..d89e8f6adec 100644 --- a/Gemfile +++ b/Gemfile @@ -223,7 +223,7 @@ gem 're2', '~> 1.2.0' gem 'version_sorter', '~> 2.2.4' # Export Ruby Regex to Javascript -gem 'js_regex', '~> 3.4' +gem 'js_regex', '~> 3.7' # User agent parsing gem 'device_detector' @@ -523,7 +523,7 @@ gem 'valid_email', '~> 0.1' # JSON gem 'json', '~> 2.3.0' -gem 'json_schemer', '~> 0.2.12' +gem 'json_schemer', '~> 0.2.18' gem 'oj', '~> 3.10.6' gem 'multi_json', '~> 1.14.1' gem 'yajl-ruby', '~> 1.4.1', require: 'yajl' diff --git a/Gemfile.lock b/Gemfile.lock index 9c78186595d..02298bca3c8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -179,7 +179,8 @@ GEM mime-types (>= 1.16) ssrf_filter (~> 1.0) cbor (0.5.9.6) - character_set (1.4.0) + character_set (1.4.1) + sorted_set (~> 1.0) charlock_holmes (0.7.7) chef-config (16.10.17) addressable @@ -307,8 +308,8 @@ GEM dry-inflector (~> 0.1, >= 0.1.2) dry-logic (~> 1.0, >= 1.0.2) e2mmap (0.1.0) - ecma-re-validator (0.2.1) - regexp_parser (~> 1.2) + ecma-re-validator (0.3.0) + regexp_parser (~> 2.0) ed25519 (1.2.4) elasticsearch (6.8.2) elasticsearch-api (= 6.8.2) @@ -604,7 +605,7 @@ GEM temple (>= 0.8.2) thor tilt - hana (1.3.6) + hana (1.3.7) hangouts-chat (0.0.5) hashdiff (1.0.1) hashie (4.1.0) @@ -652,19 +653,19 @@ GEM multipart-post oauth (~> 0.5, >= 0.5.0) jmespath (1.4.0) - js_regex (3.4.0) + js_regex (3.7.0) character_set (~> 1.4) - regexp_parser (~> 1.5) - regexp_property_values (~> 0.3) + regexp_parser (~> 2.1) + regexp_property_values (~> 1.0) json (2.3.0) json-jwt (1.13.0) activesupport (>= 4.2) aes_key_wrap bindata - json_schemer (0.2.12) - ecma-re-validator (~> 0.2) + json_schemer (0.2.18) + ecma-re-validator (~> 0.3) hana (~> 1.3) - regexp_parser (~> 1.5) + regexp_parser (~> 2.0) uri_template (~> 0.7) jsonpath (1.1.0) multi_json @@ -1010,6 +1011,7 @@ GEM ffi (>= 1.0.6) msgpack (>= 0.4.3) optimist (>= 3.0.0) + rbtree (0.4.4) rchardet (1.8.0) rdoc (6.3.2) re2 (1.2.0) @@ -1035,8 +1037,8 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.8.1) redis (>= 4, < 5) - regexp_parser (1.8.2) - regexp_property_values (0.3.5) + regexp_parser (2.1.1) + regexp_property_values (1.0.0) representable (3.0.4) declarative (< 0.1.0) declarative-option (< 0.2.0) @@ -1174,6 +1176,7 @@ GEM rubyzip (>= 1.2.2) sentry-raven (3.1.2) faraday (>= 1.0) + set (1.0.1) settingslogic (2.0.9) sexp_processor (4.15.1) shellany (0.0.1) @@ -1218,6 +1221,9 @@ GEM thor (~> 1.0) tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) + sorted_set (1.0.3) + rbtree + set (~> 1.0) spamcheck (0.1.0) grpc (~> 1.0) spring (2.1.1) @@ -1509,9 +1515,9 @@ DEPENDENCIES invisible_captcha (~> 1.1.0) ipaddress (~> 0.8.3) jira-ruby (~> 2.1.4) - js_regex (~> 3.4) + js_regex (~> 3.7) json (~> 2.3.0) - json_schemer (~> 0.2.12) + json_schemer (~> 0.2.18) jwt (~> 2.1.0) kaminari (~> 1.0) kas-grpc (~> 0.0.2) diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js index a4e5df559ff..01e03ed437d 100644 --- a/app/assets/javascripts/pages/admin/groups/edit/index.js +++ b/app/assets/javascripts/pages/admin/groups/edit/index.js @@ -1,3 +1,3 @@ import initFilePickers from '~/file_pickers'; -document.addEventListener('DOMContentLoaded', initFilePickers); +initFilePickers(); diff --git a/app/assets/javascripts/pages/admin/serverless/domains/index.js b/app/assets/javascripts/pages/admin/serverless/domains/index.js index 5be466886a5..4fab7a1d9cb 100644 --- a/app/assets/javascripts/pages/admin/serverless/domains/index.js +++ b/app/assets/javascripts/pages/admin/serverless/domains/index.js @@ -1,19 +1,17 @@ import initSettingsPanels from '~/settings_panels'; -document.addEventListener('DOMContentLoaded', () => { - // Initialize expandable settings panels - initSettingsPanels(); +// Initialize expandable settings panels +initSettingsPanels(); - const domainCard = document.querySelector('.js-domain-cert-show'); - const domainForm = document.querySelector('.js-domain-cert-inputs'); - const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn'); - const domainSubmitButton = document.querySelector('.js-serverless-domain-submit'); +const domainCard = document.querySelector('.js-domain-cert-show'); +const domainForm = document.querySelector('.js-domain-cert-inputs'); +const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn'); +const domainSubmitButton = document.querySelector('.js-serverless-domain-submit'); - if (domainReplaceButton && domainCard && domainForm) { - domainReplaceButton.addEventListener('click', () => { - domainCard.classList.add('hidden'); - domainForm.classList.remove('hidden'); - domainSubmitButton.removeAttribute('disabled'); - }); - } -}); +if (domainReplaceButton && domainCard && domainForm) { + domainReplaceButton.addEventListener('click', () => { + domainCard.classList.add('hidden'); + domainForm.classList.remove('hidden'); + domainSubmitButton.removeAttribute('disabled'); + }); +} diff --git a/app/assets/javascripts/pages/groups/settings/badges/index.js b/app/assets/javascripts/pages/groups/settings/badges/index.js index 3f48e4f281e..9dcea737d51 100644 --- a/app/assets/javascripts/pages/groups/settings/badges/index.js +++ b/app/assets/javascripts/pages/groups/settings/badges/index.js @@ -5,6 +5,4 @@ import Translate from '~/vue_shared/translate'; Vue.use(Translate); -document.addEventListener('DOMContentLoaded', () => { - mountBadgeSettings(GROUP_BADGE); -}); +mountBadgeSettings(GROUP_BADGE); diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js index 98ddb8b3aa4..4c427b72372 100644 --- a/app/assets/javascripts/pages/import/github/status/index.js +++ b/app/assets/javascripts/pages/import/github/status/index.js @@ -1,7 +1,5 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; -document.addEventListener('DOMContentLoaded', () => { - const mountElement = document.getElementById('import-projects-mount-element'); +const mountElement = document.getElementById('import-projects-mount-element'); - mountImportProjectsTable(mountElement); -}); +mountImportProjectsTable(mountElement); diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js index 549e596cb8d..5edaa7f7e51 100644 --- a/app/assets/javascripts/pages/projects/compare/show/index.js +++ b/app/assets/javascripts/pages/projects/compare/show/index.js @@ -5,9 +5,7 @@ import initCompareSelector from '~/projects/compare'; initCompareSelector(); -document.addEventListener('DOMContentLoaded', () => { - new Diff(); // eslint-disable-line no-new - const paddingTop = 16; - initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); - GpgBadges.fetch(); -}); +new Diff(); // eslint-disable-line no-new +const paddingTop = 16; +initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); +GpgBadges.fetch(); diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js index 255d05b39be..bef21ef8fdf 100644 --- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js +++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js @@ -1,3 +1,3 @@ import initCycleAnalytics from '~/cycle_analytics'; -document.addEventListener('DOMContentLoaded', initCycleAnalytics); +initCycleAnalytics(); diff --git a/app/assets/javascripts/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js index 09d9c78c446..4f5a5bfe6fe 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/index.js +++ b/app/assets/javascripts/pages/projects/graphs/show/index.js @@ -1,3 +1,3 @@ import initContributorsGraphs from '~/contributors'; -document.addEventListener('DOMContentLoaded', initContributorsGraphs); +initContributorsGraphs(); diff --git a/app/assets/javascripts/pages/projects/import/jira/index.js b/app/assets/javascripts/pages/projects/import/jira/index.js index cb7a7bde55d..5876e5283b5 100644 --- a/app/assets/javascripts/pages/projects/import/jira/index.js +++ b/app/assets/javascripts/pages/projects/import/jira/index.js @@ -1,3 +1,3 @@ import mountJiraImportApp from '~/jira_import'; -document.addEventListener('DOMContentLoaded', mountJiraImportApp); +mountJiraImportApp(); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index aecc6484b26..48afd2142ee 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,3 @@ import initForm from 'ee_else_ce/pages/projects/issues/form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/jobs/terminal/index.js b/app/assets/javascripts/pages/projects/jobs/terminal/index.js index 7129e24cee1..d42c163a41b 100644 --- a/app/assets/javascripts/pages/projects/jobs/terminal/index.js +++ b/app/assets/javascripts/pages/projects/jobs/terminal/index.js @@ -1,3 +1,3 @@ import initTerminal from '~/terminal/'; -document.addEventListener('DOMContentLoaded', initTerminal); +initTerminal(); diff --git a/app/assets/javascripts/pages/projects/pages_domains/new/index.js b/app/assets/javascripts/pages/projects/pages_domains/new/index.js index 27e4433ad4d..17fa49a46e0 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/new/index.js +++ b/app/assets/javascripts/pages/projects/pages_domains/new/index.js @@ -1,3 +1,3 @@ import initForm from '~/pages/projects/pages_domains/form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/pages_domains/show/index.js b/app/assets/javascripts/pages/projects/pages_domains/show/index.js index 27e4433ad4d..17fa49a46e0 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/show/index.js +++ b/app/assets/javascripts/pages/projects/pages_domains/show/index.js @@ -1,3 +1,3 @@ import initForm from '~/pages/projects/pages_domains/form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js index 40730ec7e60..cd4bc35e74e 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue'; -document.addEventListener('DOMContentLoaded', () => { +function initPipelineSchedules() { const el = document.getElementById('pipeline-schedules-callout'); if (!el) { @@ -21,4 +21,6 @@ document.addEventListener('DOMContentLoaded', () => { return createElement(PipelineSchedulesCallout); }, }); -}); +} + +initPipelineSchedules(); diff --git a/app/controllers/concerns/dependency_proxy/auth.rb b/app/controllers/concerns/dependency_proxy/auth.rb deleted file mode 100644 index 1276feedba6..00000000000 --- a/app/controllers/concerns/dependency_proxy/auth.rb +++ /dev/null @@ -1,43 +0,0 @@ -# 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 diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb index 2a923d02752..07aca72b22f 100644 --- a/app/controllers/concerns/dependency_proxy/group_access.rb +++ b/app/controllers/concerns/dependency_proxy/group_access.rb @@ -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 diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb index b896b240daf..b037aa52939 100644 --- a/app/controllers/groups/dependency_proxies_controller.rb +++ b/app/controllers/groups/dependency_proxies_controller.rb @@ -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 diff --git a/app/controllers/groups/dependency_proxy/application_controller.rb b/app/controllers/groups/dependency_proxy/application_controller.rb new file mode 100644 index 00000000000..c6484ffb5f1 --- /dev/null +++ b/app/controllers/groups/dependency_proxy/application_controller.rb @@ -0,0 +1,76 @@ +# 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 diff --git a/app/controllers/groups/dependency_proxy_auth_controller.rb b/app/controllers/groups/dependency_proxy_auth_controller.rb index e3e9bd88e24..60b2371fa9a 100644 --- a/app/controllers/groups/dependency_proxy_auth_controller.rb +++ b/app/controllers/groups/dependency_proxy_auth_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -class Groups::DependencyProxyAuthController < ApplicationController - include DependencyProxy::Auth - +class Groups::DependencyProxyAuthController < ::Groups::DependencyProxy::ApplicationController feature_category :dependency_proxy def authenticate diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index e341b9d75e0..f7dc552bd3e 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -1,11 +1,12 @@ # 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 diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 0d5d63371fc..537d18646c5 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -128,7 +128,6 @@ module Ci end scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } - scope :eager_load_job_artifacts_archive, -> { includes(:job_artifacts_archive) } scope :eager_load_tags, -> { includes(:tags) } scope :eager_load_everything, -> do diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 5fa9f2ef9f9..326d3fb8470 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -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. diff --git a/app/models/group_deploy_token.rb b/app/models/group_deploy_token.rb index 084a8672460..d9667e7c74d 100644 --- a/app/models/group_deploy_token.rb +++ b/app/models/group_deploy_token.rb @@ -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? diff --git a/app/models/label.rb b/app/models/label.rb index 1a07620f944..a46d6bc5c0f 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -9,10 +9,6 @@ class Label < ApplicationRecord include Sortable include FromUnion include Presentable - include IgnorableColumns - - # TODO: Project#create_labels can remove column exception when this column is dropped from all envs - ignore_column :remove_on_close, remove_with: '14.1', remove_after: '2021-06-22' cache_markdown_field :description, pipeline: :single_line diff --git a/app/models/project.rb b/app/models/project.rb index 5cad761ce2c..0d32138b08c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1427,8 +1427,7 @@ class Project < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def create_labels Label.templates.each do |label| - # TODO: remove_on_close exception can be removed after the column is dropped from all envs - params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type', 'remove_on_close') + params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type') Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true) end end diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 8aeeae1330c..8c3b85ac4c3 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -20,7 +20,6 @@ module Terraform foreign_key: :terraform_state_id, inverse_of: :terraform_state - scope :versioning_not_enabled, -> { where(versioning_enabled: false) } scope :ordered_by_name, -> { order(:name) } scope :with_name, -> (name) { where(name: name) } diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 7e07cd12ede..fa7834c10df 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -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') diff --git a/app/services/auth/dependency_proxy_authentication_service.rb b/app/services/auth/dependency_proxy_authentication_service.rb index 4335fb0bd06..164594d6f6c 100644 --- a/app/services/auth/dependency_proxy_authentication_service.rb +++ b/app/services/auth/dependency_proxy_authentication_service.rb @@ -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 diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 4bf47c3d60d..b6266c3ea34 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -9,6 +9,7 @@ = f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold' = f.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, @application_setting.default_project_creation), {}, class: 'form-control' = render_if_exists 'admin/application_settings/default_project_deletion_protection_setting', form: f + = render_if_exists 'admin/application_settings/default_delayed_project_deletion_setting', form: f = render_if_exists 'admin/application_settings/default_project_deletion_adjourned_period_setting', form: f .form-group.visibility-level-setting = f.label :default_project_visibility, class: 'label-bold' diff --git a/app/views/layouts/nav/sidebar/_group_menus.html.haml b/app/views/layouts/nav/sidebar/_group_menus.html.haml index 42114287cdf..4f171f2777a 100644 --- a/app/views/layouts/nav/sidebar/_group_menus.html.haml +++ b/app/views/layouts/nav/sidebar/_group_menus.html.haml @@ -1,16 +1,3 @@ -- if group_sidebar_link?(:kubernetes) - = nav_link(controller: [:clusters]) do - = link_to group_clusters_path(@group) do - .nav-icon-container - = sprite_icon('cloud-gear') - %span.nav-item-name - = _('Kubernetes') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: [:clusters], html_options: { class: "fly-out-top-item" } ) do - = link_to group_clusters_path(@group), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do - %strong.fly-out-top-item-name - = _('Kubernetes') - = render 'groups/sidebar/packages' = render 'layouts/nav/sidebar/analytics_links', links: group_analytics_navbar_links(@group, current_user) diff --git a/config/feature_flags/development/dast_runner_site_validation.yml b/config/feature_flags/development/dast_runner_site_validation.yml index f8ad90062f6..e39a8a6d1e3 100644 --- a/config/feature_flags/development/dast_runner_site_validation.yml +++ b/config/feature_flags/development/dast_runner_site_validation.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331082 milestone: '14.0' type: development group: group::dynamic analysis -default_enabled: false +default_enabled: true diff --git a/config/feature_flags/development/dependency_proxy_deploy_tokens.yml b/config/feature_flags/development/dependency_proxy_deploy_tokens.yml new file mode 100644 index 00000000000..f3cb1fc2c18 --- /dev/null +++ b/config/feature_flags/development/dependency_proxy_deploy_tokens.yml @@ -0,0 +1,8 @@ +--- +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 diff --git a/doc/api/settings.md b/doc/api/settings.md index e3366cf176c..d481d336c4c 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -96,7 +96,7 @@ Example response: ``` Users on GitLab [Premium or Ultimate](https://about.gitlab.com/pricing/) may also see -the `file_template_project_id`, `deletion_adjourned_period`, or the `geo_node_allowed_ips` parameters: +the `file_template_project_id`, `delayed_project_deletion`, `deletion_adjourned_period`, or the `geo_node_allowed_ips` parameters: ```json { @@ -104,6 +104,7 @@ the `file_template_project_id`, `deletion_adjourned_period`, or the `geo_node_al "signup_enabled" : true, "file_template_project_id": 1, "geo_node_allowed_ips": "0.0.0.0/0, ::/0", + "delayed_project_deletion": false, "deletion_adjourned_period": 7, ... } @@ -200,6 +201,7 @@ these parameters: - `file_template_project_id` - `geo_node_allowed_ips` - `geo_status_timeout` +- `delayed_project_delection` - `deletion_adjourned_period` Example responses: **(PREMIUM SELF)** @@ -250,6 +252,7 @@ listed in the descriptions of the relevant settings. | `default_project_visibility` | string | no | What visibility level new projects receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. | | `default_projects_limit` | integer | no | Project limit per user. Default is `100000`. | | `default_snippet_visibility` | string | no | What visibility level new snippets receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. | +| `delayed_project_deletion` | boolean | no | **(PREMIUM SELF)** Enable delayed project deletion by default in new groups. Default is `false`. | | `deletion_adjourned_period` | integer | no | **(PREMIUM SELF)** The number of days to wait before deleting a project or group that is marked for deletion. Value must be between 0 and 90. | `diff_max_patch_bytes` | integer | no | Maximum [diff patch size](../user/admin_area/diff_limits.md), in bytes. | | `diff_max_files` | integer | no | Maximum [files in a diff](../user/admin_area/diff_limits.md). | diff --git a/doc/user/admin_area/settings/visibility_and_access_controls.md b/doc/user/admin_area/settings/visibility_and_access_controls.md index 4af33c133a4..7474d9f8821 100644 --- a/doc/user/admin_area/settings/visibility_and_access_controls.md +++ b/doc/user/admin_area/settings/visibility_and_access_controls.md @@ -71,6 +71,18 @@ To ensure only Administrator users can delete projects: 1. Check the **Default project deletion protection** checkbox. 1. Click **Save changes**. +## Default delayed project deletion **(PREMIUM SELF)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/255449) in GitLab 14.2. + +Projects in a group (but not a personal namespace) can be deleted after a delayed period, by +[configuring in Group Settings](../../group/index.md#enable-delayed-project-removal). + +To enable delayed project deletion by default in new groups: + +1. Check the **Default delayed project deletion** checkbox. +1. Click **Save changes**. + ## Default deletion delay **(PREMIUM SELF)** > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/32935) in GitLab 12.6. diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md index 93ece70655e..500ec8208bf 100644 --- a/doc/user/application_security/dast/index.md +++ b/doc/user/application_security/dast/index.md @@ -1063,6 +1063,9 @@ follows: Both methods are equivalent in functionality. Use whichever is feasible. +In [GitLab 14.2 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/324990), site profile +validation happens in a CI job using the [GitLab Runner](../../../ci/runners/index.md). + #### Create a site profile To create a site profile: diff --git a/doc/user/packages/dependency_proxy/index.md b/doc/user/packages/dependency_proxy/index.md index e2957aff756..dd7ad7d4f8d 100644 --- a/doc/user/packages/dependency_proxy/index.md +++ b/doc/user/packages/dependency_proxy/index.md @@ -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 diff --git a/doc/user/project/settings/project_access_tokens.md b/doc/user/project/settings/project_access_tokens.md index 5e045ee2455..34f7a1aee92 100644 --- a/doc/user/project/settings/project_access_tokens.md +++ b/doc/user/project/settings/project_access_tokens.md @@ -94,3 +94,48 @@ the following table. You may enable or disable project access token creation for all projects in a group in **Group > Settings > General > Permissions, LFS, 2FA > Allow project access token creation**. Even when creation is disabled, you can still use and revoke existing project access tokens. This setting is available only on top-level groups. + +## Group access token workaround **(FREE SELF)** + +NOTE: +This section describes a workaround and is subject to change. + +Group access tokens let you use a single token to: + +- Perform actions at the group level. +- Manage the projects within the group. + +We don't support group access tokens in the GitLab UI, though GitLab self-managed +administrators can create them using the [Rails console](../../../administration/operations/rails_console.md). + +
+ For a demo of the group access token workaround, see Demo: Group Level Access Tokens. +
+
+ +
+ +### Create a group access token + +To create a group access token, run the following in a Rails console: + +```ruby +admin = User.find(1) # group admin +group = Group.find(109) # the group you want to create a token for +bot = Users::CreateService.new(admin, { name: 'group_token', username: "group_#{group.id}_bot", email: "group_#{group.id}_bot@example.com", user_type: :project_bot }).execute # create the group bot user +# for further group access tokens, the username should be group_#{group.id}_bot#{bot_count}, e.g. group_109_bot2, and their email should be group_109_bot2@example.com +bot.confirm # confirm the bot +group.add_user(bot, :maintainer) # add the bot to the group at the desired access level +token = bot.personal_access_tokens.create(scopes:[:api, :write_repository], name: 'group_token') # give it a PAT +gtoken = token.token # get the token value +``` + +### Revoke a group access token + +To revoke a group access token, run the following in a Rails console: + +```ruby +bot = User.find_by(username: 'group_109_bot') # the owner of the token you want to revoke +token = bot.personal_access_tokens.last # the token you want to revoke +token.revoke! +``` diff --git a/lib/api/v3/github.rb b/lib/api/v3/github.rb index 29e4a79110f..310054c298a 100644 --- a/lib/api/v3/github.rb +++ b/lib/api/v3/github.rb @@ -214,6 +214,8 @@ module API update_project_feature_usage_for(user_project) + next [] unless user_project.repo_exists? + branches = ::Kaminari.paginate_array(user_project.repository.branches.sort_by(&:name)) present paginate(branches), with: ::API::Github::Entities::Branch, project: user_project diff --git a/lib/sidebars/groups/menus/kubernetes_menu.rb b/lib/sidebars/groups/menus/kubernetes_menu.rb new file mode 100644 index 00000000000..4ea294a4837 --- /dev/null +++ b/lib/sidebars/groups/menus/kubernetes_menu.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Sidebars + module Groups + module Menus + class KubernetesMenu < ::Sidebars::Menu + override :link + def link + group_clusters_path(context.group) + end + + override :title + def title + _('Kubernetes') + end + + override :sprite_icon + def sprite_icon + 'cloud-gear' + end + + override :render? + def render? + can?(context.current_user, :read_cluster, context.group) + end + + override :extra_container_html_options + def extra_container_html_options + { + class: 'shortcuts-kubernetes' + } + end + + override :active_routes + def active_routes + { controller: :clusters } + end + end + end + end +end diff --git a/lib/sidebars/groups/panel.rb b/lib/sidebars/groups/panel.rb index 398cbd73b2a..e3fe1e0ea3b 100644 --- a/lib/sidebars/groups/panel.rb +++ b/lib/sidebars/groups/panel.rb @@ -11,6 +11,7 @@ module Sidebars add_menu(Sidebars::Groups::Menus::IssuesMenu.new(context)) add_menu(Sidebars::Groups::Menus::MergeRequestsMenu.new(context)) add_menu(Sidebars::Groups::Menus::CiCdMenu.new(context)) + add_menu(Sidebars::Groups::Menus::KubernetesMenu.new(context)) end override :render_raw_menus_partial diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f56f6d97f55..b0017b7bbfe 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10555,6 +10555,9 @@ msgstr "" msgid "Default classification label" msgstr "" +msgid "Default delayed project deletion" +msgstr "" + msgid "Default deletion delay" msgstr "" @@ -11735,6 +11738,9 @@ msgstr "" msgid "Documents reindexed: %{processed_documents} (%{percentage}%%)" msgstr "" +msgid "Does not apply to projects in personal namespaces, which are deleted immediately on request." +msgstr "" + msgid "Domain" msgstr "" @@ -12260,6 +12266,9 @@ msgstr "" msgid "Enable container expiration and retention policies for projects created earlier than GitLab 12.7." msgstr "" +msgid "Enable delayed project deletion by default for newly-created groups." +msgstr "" + msgid "Enable error tracking" msgstr "" @@ -15944,7 +15953,7 @@ msgstr "" msgid "GroupSettings|Disable group mentions" msgstr "" -msgid "GroupSettings|Enable delayed project removal" +msgid "GroupSettings|Enable delayed project deletion" msgstr "" msgid "GroupSettings|Export group" @@ -22577,9 +22586,6 @@ msgstr "" msgid "Not all data has been processed yet, the accuracy of the chart for the selected timeframe is limited." msgstr "" -msgid "Not applicable to personal namespaced projects, which are deleted immediately on request." -msgstr "" - msgid "Not available" msgstr "" diff --git a/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb b/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb index f67b2022219..50e19d5b482 100644 --- a/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb @@ -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 diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb index 6417da34161..0f0fb781512 100644 --- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb @@ -7,8 +7,8 @@ RSpec.describe Groups::DependencyProxyForContainersController do include DependencyProxyHelpers let_it_be(:user) { create(:user) } + let_it_be_with_reload(:group) { create(:group, :private) } - let(:group) { create(:group) } let(:token_response) { { status: :success, token: 'abcd1234' } } let(:jwt) { build_jwt(user) } let(:token_header) { "Bearer #{jwt.encoded}" } @@ -20,6 +20,8 @@ RSpec.describe Groups::DependencyProxyForContainersController do end context 'feature flag disabled' do + let_it_be(:group) { create(:group) } + before do stub_feature_flags(dependency_proxy_for_private_groups: false) end @@ -35,13 +37,12 @@ RSpec.describe Groups::DependencyProxyForContainersController do stub_feature_flags(dependency_proxy_for_private_groups: false) end - it 'redirects', :aggregate_failures do + it 'returns not found' do group.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) subject - expect(response).to have_gitlab_http_status(:redirect) - expect(response.location).to end_with(new_user_session_path) + expect(response).to have_gitlab_http_status(:not_found) end end @@ -53,21 +54,95 @@ RSpec.describe Groups::DependencyProxyForContainersController 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 'with valid user that does not have access' do - let(:group) { create(:group, :private) } - before do - user = double('bad_user', id: 999) - token_header = "Bearer #{build_jwt(user).encoded}" request.headers['HTTP_AUTHORIZATION'] = token_header end it { is_expected.to have_gitlab_http_status(:not_found) } end + context 'deploy tokens with dependency_proxy_deploy_tokens disabled' do + before do + stub_feature_flags(dependency_proxy_deploy_tokens: false) + 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) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'with expired deploy token' do + let_it_be(:user) { create(:deploy_token, :expired, :group, :dependency_proxy_scopes) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'with deploy token with insufficient scopes' do + let_it_be(:user) { create(:deploy_token, :group) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'when a group is not found' do + before do + expect(Group).to receive(:find_by_full_path).and_return(nil) + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + end + + context 'deploy tokens with dependency_proxy_deploy_tokens enabled' do + 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) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: 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) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + + context 'with deploy token with insufficient scopes' do + let_it_be(:user) { create(:deploy_token, :group) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'when a group is not found' do + before do + expect(Group).to receive(:find_by_full_path).and_return(nil) + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + end + context 'when user is not found' do before do allow(User).to receive(:find).and_return(nil) @@ -115,6 +190,25 @@ RSpec.describe Groups::DependencyProxyForContainersController do subject { get_manifest } + shared_examples 'a successful manifest pull' do + it 'sends a file' do + expect(controller).to receive(:send_file).with(manifest.file.path, type: manifest.content_type) + + subject + end + + it 'returns Content-Disposition: attachment', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Docker-Content-Digest']).to eq(manifest.digest) + expect(response.headers['Content-Length']).to eq(manifest.size) + expect(response.headers['Docker-Distribution-Api-Version']).to eq(DependencyProxy::DISTRIBUTION_API_VERSION) + expect(response.headers['Etag']).to eq("\"#{manifest.digest}\"") + expect(response.headers['Content-Disposition']).to match(/^attachment/) + end + end + context 'feature enabled' do before do enable_dependency_proxy @@ -123,14 +217,6 @@ RSpec.describe Groups::DependencyProxyForContainersController do it_behaves_like 'without a token' it_behaves_like 'without permission' it_behaves_like 'feature flag disabled with private group' - it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest' - - context 'with a cache entry' do - let(:pull_response) { { status: :success, manifest: manifest, from_cache: true } } - - it_behaves_like 'returning response status', :success - it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest_from_cache' - end context 'remote token request fails' do let(:token_response) do @@ -141,6 +227,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do } end + before do + group.add_reporter(user) + end + it 'proxies status from the remote token request', :aggregate_failures do subject @@ -158,6 +248,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do } end + before do + group.add_reporter(user) + end + it 'proxies status from the remote manifest request', :aggregate_failures do subject @@ -166,21 +260,58 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end - it 'sends a file' do - expect(controller).to receive(:send_file).with(manifest.file.path, type: manifest.content_type) + context 'a valid user' do + before do + group.add_reporter(user) + end - subject + it_behaves_like 'a successful manifest pull' + it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest' + + context 'with a cache entry' do + let(:pull_response) { { status: :success, manifest: manifest, from_cache: true } } + + it_behaves_like 'returning response status', :success + it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest_from_cache' + end + + context 'with dependency_proxy_deploy_tokens feature flag disabled' do + before do + stub_feature_flags(dependency_proxy_deploy_tokens: false) + end + + it_behaves_like 'a successful manifest pull' + end end - it 'returns Content-Disposition: attachment' do - subject + context 'a valid deploy token with dependency_proxy_deploy_tokens feature flag disabled' do + let_it_be(:user) { create(:deploy_token, :dependency_proxy_scopes, :group) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } - expect(response).to have_gitlab_http_status(:ok) - expect(response.headers['Docker-Content-Digest']).to eq(manifest.digest) - expect(response.headers['Content-Length']).to eq(manifest.size) - expect(response.headers['Docker-Distribution-Api-Version']).to eq(DependencyProxy::DISTRIBUTION_API_VERSION) - expect(response.headers['Etag']).to eq("\"#{manifest.digest}\"") - expect(response.headers['Content-Disposition']).to match(/^attachment/) + before do + stub_feature_flags(dependency_proxy_deploy_tokens: false) + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'a valid deploy token' do + let_it_be(:user) { create(:deploy_token, :dependency_proxy_scopes, :group) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + it_behaves_like 'a successful manifest pull' + + context 'pulling from a subgroup' do + let_it_be_with_reload(:parent_group) { create(:group) } + let_it_be_with_reload(:group) { create(:group, parent: parent_group) } + + before do + parent_group.create_dependency_proxy_setting!(enabled: true) + group_deploy_token.update_column(:group_id, parent_group.id) + end + + it_behaves_like 'a successful manifest pull' + end end end @@ -203,42 +334,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end - subject { get_blob } - - context 'feature enabled' do - before do - enable_dependency_proxy - end - - it_behaves_like 'without a token' - it_behaves_like 'without permission' - it_behaves_like 'feature flag disabled with private group' - it_behaves_like 'a package tracking event', described_class.name, 'pull_blob' - - context 'with a cache entry' do - let(:blob_response) { { status: :success, blob: blob, from_cache: true } } - - it_behaves_like 'returning response status', :success - it_behaves_like 'a package tracking event', described_class.name, 'pull_blob_from_cache' - end - - context 'remote blob request fails' do - let(:blob_response) do - { - status: :error, - http_status: 400, - message: '' - } - end - - it 'proxies status from the remote blob request', :aggregate_failures do - subject - - expect(response).to have_gitlab_http_status(:bad_request) - expect(response.body).to be_empty - end - end - + shared_examples 'a successful blob pull' do it 'sends a file' do expect(controller).to receive(:send_file).with(blob.file.path, {}) @@ -253,6 +349,93 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end + subject { get_blob } + + context 'feature enabled' do + before do + enable_dependency_proxy + end + + it_behaves_like 'without a token' + it_behaves_like 'without permission' + it_behaves_like 'feature flag disabled with private group' + + context 'remote blob request fails' do + let(:blob_response) do + { + status: :error, + http_status: 400, + message: '' + } + end + + before do + group.add_reporter(user) + end + + it 'proxies status from the remote blob request', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.body).to be_empty + end + end + + context 'a valid user' do + before do + group.add_reporter(user) + end + + it_behaves_like 'a successful blob pull' + it_behaves_like 'a package tracking event', described_class.name, 'pull_blob' + + context 'with a cache entry' do + let(:blob_response) { { status: :success, blob: blob, from_cache: true } } + + it_behaves_like 'returning response status', :success + it_behaves_like 'a package tracking event', described_class.name, 'pull_blob_from_cache' + end + + context 'with dependency_proxy_deploy_tokens feature flag disabled' do + before do + stub_feature_flags(dependency_proxy_deploy_tokens: false) + end + + it_behaves_like 'a successful blob pull' + end + end + + context 'a valid deploy token with dependency_proxy_deploy_tokens feature flag disabled' do + let_it_be(:user) { create(:deploy_token, :group, :dependency_proxy_scopes) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + before do + stub_feature_flags(dependency_proxy_deploy_tokens: false) + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'a valid deploy token' do + let_it_be(:user) { create(:deploy_token, :group, :dependency_proxy_scopes) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + it_behaves_like 'a successful blob pull' + + context 'pulling from a subgroup' do + let_it_be_with_reload(:parent_group) { create(:group) } + let_it_be_with_reload(:group) { create(:group, parent: parent_group) } + + before do + parent_group.create_dependency_proxy_setting!(enabled: true) + group_deploy_token.update_column(:group_id, parent_group.id) + end + + it_behaves_like 'a successful blob pull' + end + end + end + it_behaves_like 'not found when disabled' def get_blob diff --git a/spec/factories/deploy_tokens.rb b/spec/factories/deploy_tokens.rb index d4127f78ebf..b2c478fd3fe 100644 --- a/spec/factories/deploy_tokens.rb +++ b/spec/factories/deploy_tokens.rb @@ -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 diff --git a/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb b/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb new file mode 100644 index 00000000000..76e58367c9d --- /dev/null +++ b/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::Groups::Menus::KubernetesMenu do + let_it_be(:owner) { create(:user) } + let_it_be(:group) do + build(:group, :private).tap do |g| + g.add_owner(owner) + end + end + + let(:user) { owner } + let(:context) { Sidebars::Groups::Context.new(current_user: user, container: group) } + let(:menu) { described_class.new(context) } + + describe '#render?' do + context 'when user can read clusters' do + it 'returns true' do + expect(menu.render?).to eq true + end + end + + context 'when user cannot read clusters rules' do + let(:user) { nil } + + it 'returns false' do + expect(menu.render?).to eq false + end + end + end +end diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb index c9f7895a616..88451307efb 100644 --- a/spec/models/deploy_token_spec.rb +++ b/spec/models/deploy_token_spec.rb @@ -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) } diff --git a/spec/models/group_deploy_token_spec.rb b/spec/models/group_deploy_token_spec.rb index d38abafa7ed..bc44c473ddb 100644 --- a/spec/models/group_deploy_token_spec.rb +++ b/spec/models/group_deploy_token_spec.rb @@ -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 diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb index 4100b246218..255f53e4c7c 100644 --- a/spec/requests/api/v3/github_spec.rb +++ b/spec/requests/api/v3/github_spec.rb @@ -472,6 +472,17 @@ RSpec.describe API::V3::Github do expect(response).to have_gitlab_http_status(:ok) end + + context 'when the project has no repository', :aggregate_failures do + let_it_be(:project) { create(:project, creator: user) } + + it 'returns an empty collection response' do + jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_empty + end + end end context 'unauthenticated' do diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb index 4bb832a7172..70097234762 100644 --- a/spec/requests/jwt_controller_spec.rb +++ b/spec/requests/jwt_controller_spec.rb @@ -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' } diff --git a/spec/services/auth/dependency_proxy_authentication_service_spec.rb b/spec/services/auth/dependency_proxy_authentication_service_spec.rb index c54509a3536..667f361dc34 100644 --- a/spec/services/auth/dependency_proxy_authentication_service_spec.rb +++ b/spec/services/auth/dependency_proxy_authentication_service_spec.rb @@ -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 diff --git a/spec/services/dependency_proxy/auth_token_service_spec.rb b/spec/services/dependency_proxy/auth_token_service_spec.rb index 4b96f9d75a9..6214d75dfa0 100644 --- a/spec/services/dependency_proxy/auth_token_service_spec.rb +++ b/spec/services/dependency_proxy/auth_token_service_spec.rb @@ -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 diff --git a/spec/support/helpers/dependency_proxy_helpers.rb b/spec/support/helpers/dependency_proxy_helpers.rb index 0d8f56906e3..9413cb93199 100644 --- a/spec/support/helpers/dependency_proxy_helpers.rb +++ b/spec/support/helpers/dependency_proxy_helpers.rb @@ -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 diff --git a/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb index fc62fbda2cc..f4e681b70ff 100644 --- a/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb @@ -100,4 +100,12 @@ RSpec.describe 'layouts/nav/sidebar/_group' do expect(rendered).to have_link('Runners', href: group_runners_path(group)) end end + + describe 'Kubernetes menu' do + it 'has a link to the group cluster list path' do + render + + expect(rendered).to have_link('Kubernetes', href: group_clusters_path(group)) + end + end end diff --git a/tooling/bin/parallel_rspec b/tooling/bin/parallel_rspec index a706df69a1e..06e5603a6b8 100755 --- a/tooling/bin/parallel_rspec +++ b/tooling/bin/parallel_rspec @@ -16,4 +16,4 @@ OptionParser.new do |opts| end end.parse! -Tooling::ParallelRSpecRunner.run(options) +Tooling::ParallelRSpecRunner.run(**options)