diff options
author | Ahmad Sherif <me@ahmadsherif.com> | 2019-07-22 16:56:40 +0200 |
---|---|---|
committer | Ahmad Sherif <me@ahmadsherif.com> | 2019-09-10 13:43:11 +0200 |
commit | 3c2b4a1cede956d5160ccf08d0a561bf31248161 (patch) | |
tree | 9462f59d477ffe7ac1eee0fe56cf9f343b568d1f | |
parent | f7e7ee713aa21874bf6810d01976c2b5342c0995 (diff) | |
download | gitlab-ce-3c2b4a1cede956d5160ccf08d0a561bf31248161.tar.gz |
Enable serving static objects from an external storagestatic-objects-external-storage
It consists of two parts:
1. Redirecting users to the configured external storage
1. Allowing the external storage to request the static object(s)
on behalf of the user by means of specific tokens
Part of https://gitlab.com/gitlab-com/gl-infra/infrastructure/issues/6829
35 files changed, 646 insertions, 5 deletions
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb index 4304b8565ce..ba06384a37a 100644 --- a/app/controllers/concerns/sessionless_authentication.rb +++ b/app/controllers/concerns/sessionless_authentication.rb @@ -2,10 +2,10 @@ # == SessionlessAuthentication # -# Controller concern to handle PAT and RSS token authentication methods +# Controller concern to handle PAT, RSS, and static objects token authentication methods # module SessionlessAuthentication - # This filter handles personal access tokens, and atom requests with rss tokens + # This filter handles personal access tokens, atom requests with rss tokens, and static object tokens def authenticate_sessionless_user!(request_format) user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user(request_format) diff --git a/app/controllers/concerns/static_object_external_storage.rb b/app/controllers/concerns/static_object_external_storage.rb new file mode 100644 index 00000000000..dbfe0ed3adf --- /dev/null +++ b/app/controllers/concerns/static_object_external_storage.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module StaticObjectExternalStorage + extend ActiveSupport::Concern + + included do + include ApplicationHelper + end + + def redirect_to_external_storage + return if external_storage_request? + + redirect_to external_storage_url_or_path(request.fullpath, project) + end + + def external_storage_request? + header_token = request.headers['X-Gitlab-External-Storage-Token'] + return false unless header_token.present? + + external_storage_token = Gitlab::CurrentSettings.static_objects_external_storage_auth_token + ActiveSupport::SecurityUtils.secure_compare(header_token, external_storage_token) || + raise(Gitlab::Access::AccessDeniedError) + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 1d16ddb1608..958a24b6c0e 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -46,6 +46,15 @@ class ProfilesController < Profiles::ApplicationController redirect_to profile_personal_access_tokens_path end + def reset_static_object_token + Users::UpdateService.new(current_user, user: @user).execute! do |user| + user.reset_static_object_token! + end + + redirect_to profile_personal_access_tokens_path, + notice: s_('Profiles|Static object token was successfully reset') + end + # rubocop: disable CodeReuse/ActiveRecord def audit_log @events = AuditEvent.where(entity_type: "User", entity_id: current_user.id) diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index a51759641e4..d69f9e65874 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -2,6 +2,9 @@ class Projects::RepositoriesController < Projects::ApplicationController include ExtractsPath + include StaticObjectExternalStorage + + prepend_before_action(only: [:archive]) { authenticate_sessionless_user!(:archive) } # Authorize before_action :require_non_empty_project, except: :create @@ -9,6 +12,7 @@ class Projects::RepositoriesController < Projects::ApplicationController before_action :assign_append_sha, only: :archive before_action :authorize_download_code! before_action :authorize_admin_project!, only: :create + before_action :redirect_to_external_storage, only: :archive, if: :static_objects_external_storage_enabled? def create @project.create_repository diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ffa5719fefb..1671aa5bd04 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -169,6 +169,25 @@ module ApplicationHelper Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/' end + def static_objects_external_storage_enabled? + Gitlab::CurrentSettings.static_objects_external_storage_enabled? + end + + def external_storage_url_or_path(path, project = @project) + return path unless static_objects_external_storage_enabled? + + uri = URI(Gitlab::CurrentSettings.static_objects_external_storage_url) + path = URI(path) # `path` could have query parameters, so we need to split query and path apart + + query = Rack::Utils.parse_nested_query(path.query) + query['token'] = current_user.static_object_token unless project.public? + + uri.path = path.path + uri.query = query.to_query unless query.empty? + + uri.to_s + end + def page_filter_path(options = {}) without = options.delete(:without) diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 84021d0da56..be3b67c60e8 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -168,6 +168,8 @@ module ApplicationSettingsHelper :asset_proxy_secret_key, :asset_proxy_url, :asset_proxy_whitelist, + :static_objects_external_storage_auth_token, + :static_objects_external_storage_url, :authorized_keys_enabled, :auto_devops_enabled, :auto_devops_domain, diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index e39d655325f..3409411c3b1 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -8,6 +8,7 @@ class ApplicationSetting < ApplicationRecord add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required } add_authentication_token_field :health_check_access_token + add_authentication_token_field :static_objects_external_storage_auth_token belongs_to :instance_administration_project, class_name: "Project" @@ -211,6 +212,13 @@ class ApplicationSetting < ApplicationRecord allow_blank: false, if: :asset_proxy_enabled? + validates :static_objects_external_storage_url, + addressable_url: true, allow_blank: true + + validates :static_objects_external_storage_auth_token, + presence: true, + if: :static_objects_external_storage_url? + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index f402c0e2775..8d9597aa5a4 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -306,6 +306,10 @@ module ApplicationSettingImplementation archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds end + def static_objects_external_storage_enabled? + static_objects_external_storage_url.present? + end + private def array_to_string(arr) diff --git a/app/models/user.rb b/app/models/user.rb index 67d730e2fa3..75532aeebb3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -31,6 +31,7 @@ class User < ApplicationRecord add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } add_authentication_token_field :feed_token + add_authentication_token_field :static_object_token default_value_for :admin, false default_value_for(:external) { Gitlab::CurrentSettings.user_default_external } @@ -1437,6 +1438,13 @@ class User < ApplicationRecord ensure_feed_token! end + # Each existing user needs to have a `static_object_token`. + # We do this on read since migrating all existing users is not a feasible + # solution. + def static_object_token + ensure_static_object_token! + end + def sync_attribute?(attribute) return true if ldap_user? && attribute == :email diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml new file mode 100644 index 00000000000..03aa48b2282 --- /dev/null +++ b/app/views/admin/application_settings/_repository_static_objects.html.haml @@ -0,0 +1,18 @@ += form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :static_objects_external_storage_url, class: 'label-bold' do + = _('External storage URL') + = f.text_field :static_objects_external_storage_url, class: 'form-control' + %span.form-text.text-muted#static_objects_external_storage_url_help_block + = _('URL of the external storage that will serve the repository static objects (e.g. archives, blobs, ...).') + .form-group + = f.label :static_objects_external_storage_auth_token, class: 'label-bold' do + = _('External storage authentication token') + = f.text_field :static_objects_external_storage_auth_token, class: 'form-control' + %span.form-text.text-muted#static_objects_external_storage_auth_token_help_block + = _('A secure token that identifies an external storage request.') + + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index b50a0dd5a18..25f8b6541b5 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -34,3 +34,14 @@ = _('Configure automatic git checks and housekeeping on repositories.') .settings-content = render 'repository_check' + +%section.settings.as-repository-static-objects.no-animate#js-repository-static-objects-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Repository static objects') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Serve repository static objects (e.g. archives, blobs, ...) from an external storage (e.g. a CDN).') + .settings-content + = render 'repository_static_objects' diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 08a39fc4f58..d9e94908b80 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -54,3 +54,23 @@ - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') } - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } = reset_message.html_safe + +- if static_objects_external_storage_enabled? + %hr + .row.prepend-top-default + .col-lg-4 + %h4.prepend-top-0 + = s_('AccessTokens|Static object token') + %p + = s_('AccessTokens|Your static object token is used to authenticate you when repository static objects (e.g. archives, blobs, ...) are being served from an external storage.') + %p + = s_('AccessTokens|It cannot be used to access any other data.') + .col-lg-8 + = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold" + = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control', readonly: true, onclick: 'this.select()' + %p.form-text.text-muted + - reset_link = url_for [:reset, :static_object_token, :profile] + - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link } + - reset_link_end = '</a>'.html_safe + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can access repository static objects as if they were you. You should %{reset_link_start}reset it%{reset_link_end} if that ever happens.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end } + = reset_message.html_safe diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml index d344167a6c5..b256d94065b 100644 --- a/app/views/projects/buttons/_download_links.html.haml +++ b/app/views/projects/buttons/_download_links.html.haml @@ -2,4 +2,5 @@ .btn-group.ml-0.w-100 - formats.each do |(fmt, extra_class)| - = link_to fmt, project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}" + - archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt) + = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}" diff --git a/changelogs/unreleased/static-objects-external-storage.yml b/changelogs/unreleased/static-objects-external-storage.yml new file mode 100644 index 00000000000..fd687b2262c --- /dev/null +++ b/changelogs/unreleased/static-objects-external-storage.yml @@ -0,0 +1,5 @@ +--- +title: Enable serving static objects from an external storage +merge_request: 31025 +author: +type: added diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 83a2b33514b..403f430850e 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -8,6 +8,7 @@ resource :profile, only: [:show, :update] do put :reset_incoming_email_token put :reset_feed_token + put :reset_static_object_token put :update_username end diff --git a/db/migrate/20190722104947_add_static_object_token_to_users.rb b/db/migrate/20190722104947_add_static_object_token_to_users.rb new file mode 100644 index 00000000000..6ef85d9acaa --- /dev/null +++ b/db/migrate/20190722104947_add_static_object_token_to_users.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddStaticObjectTokenToUsers < ActiveRecord::Migration[5.2] + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column :users, :static_object_token, :string, limit: 255 + end + + def down + remove_column :users, :static_object_token + end +end diff --git a/db/migrate/20190722132830_add_static_objects_external_storage_columns_to_application_settings.rb b/db/migrate/20190722132830_add_static_objects_external_storage_columns_to_application_settings.rb new file mode 100644 index 00000000000..a23e6ed66cd --- /dev/null +++ b/db/migrate/20190722132830_add_static_objects_external_storage_columns_to_application_settings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddStaticObjectsExternalStorageColumnsToApplicationSettings < ActiveRecord::Migration[5.2] + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :application_settings, :static_objects_external_storage_url, :string, limit: 255 + add_column :application_settings, :static_objects_external_storage_auth_token, :string, limit: 255 + end +end diff --git a/db/migrate/20190725183432_add_index_to_index_on_static_object_token.rb b/db/migrate/20190725183432_add_index_to_index_on_static_object_token.rb new file mode 100644 index 00000000000..423c45b9543 --- /dev/null +++ b/db/migrate/20190725183432_add_index_to_index_on_static_object_token.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddIndexToIndexOnStaticObjectToken < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :users, :static_object_token, unique: true + end + + def down + remove_concurrent_index :users, :static_object_token + end +end diff --git a/db/schema.rb b/db/schema.rb index 61f7787f192..d4db1db5c3f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -284,6 +284,8 @@ ActiveRecord::Schema.define(version: 2019_09_05_223900) do t.text "asset_proxy_whitelist" t.text "encrypted_asset_proxy_secret_key" t.string "encrypted_asset_proxy_secret_key_iv" + t.string "static_objects_external_storage_url", limit: 255 + t.string "static_objects_external_storage_auth_token", limit: 255 t.index ["custom_project_templates_group_id"], name: "index_application_settings_on_custom_project_templates_group_id" t.index ["file_template_project_id"], name: "index_application_settings_on_file_template_project_id" t.index ["instance_administration_project_id"], name: "index_applicationsettings_on_instance_administration_project_id" @@ -3548,6 +3550,7 @@ ActiveRecord::Schema.define(version: 2019_09_05_223900) do t.integer "bot_type", limit: 2 t.string "first_name", limit: 255 t.string "last_name", limit: 255 + t.string "static_object_token", limit: 255 t.index ["accepted_term_id"], name: "index_users_on_accepted_term_id" t.index ["admin"], name: "index_users_on_admin" t.index ["bot_type"], name: "index_users_on_bot_type" @@ -3567,6 +3570,7 @@ ActiveRecord::Schema.define(version: 2019_09_05_223900) do t.index ["state"], name: "index_users_on_state" t.index ["state"], name: "index_users_on_state_and_internal", where: "(ghost IS NOT TRUE)" t.index ["state"], name: "index_users_on_state_and_internal_ee", where: "((ghost IS NOT TRUE) AND (bot_type IS NULL))" + t.index ["static_object_token"], name: "index_users_on_static_object_token", unique: true t.index ["unconfirmed_email"], name: "index_users_on_unconfirmed_email", where: "(unconfirmed_email IS NOT NULL)" t.index ["username"], name: "index_users_on_username" t.index ["username"], name: "index_users_on_username_trigram", opclass: :gin_trgm_ops, using: :gin diff --git a/doc/administration/index.md b/doc/administration/index.md index d557068e6c8..ae38834b68c 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -142,6 +142,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. - [Repository storage types](repository_storage_types.md): Information about the different repository storage types. - [Repository storage rake tasks](raketasks/storage.md): A collection of rake tasks to list and migrate existing projects and attachments associated with it from Legacy storage to Hashed storage. - [Limit repository size](../user/admin_area/settings/account_and_limit_settings.md): Set a hard limit for your repositories' size. **(STARTER ONLY)** +- [Static objects external storage](static_objects_external_storage.md): Set external storage for static objects in a repository. ## Continuous Integration settings diff --git a/doc/administration/static_objects_external_storage.md b/doc/administration/static_objects_external_storage.md new file mode 100644 index 00000000000..e4d60c77199 --- /dev/null +++ b/doc/administration/static_objects_external_storage.md @@ -0,0 +1,50 @@ +# Static objects external storage + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/31025) in GitLab 12.3. + +GitLab can be configured to serve repository static objects (for example, archives) from an external +storage, such as a CDN. + +## Configuring + +To configure external storage for static objects: + +1. Navigate to **Admin Area > Settings > Repository**. +1. Expand the **Repository static objects** section. +1. Enter the base URL and an arbitrary token. + +The token is required to distinguish requests coming from the external storage, so users don't +circumvent the external storage and go for the application directly. The token is expected to be +set in the `X-Gitlab-External-Storage-Token` header in requests originating from the external +storage. + +## Serving private static objects + +GitLab will append a user-specific token for static object URLs that belong to private projects, +so an external storage can be authenticated on behalf of the user. When processing requests originating +from the external storage, GitLab will look for the token in the `token` query parameter or in +the `X-Gitlab-Static-Object-Token` header to check the user's ability to access the requested object. + +## Requests flow example + +The following example shows a sequence of requests and responses between the user, +GitLab, and the CDN: + +```mermaid +sequenceDiagram + User->>GitLab: GET /project/-/archive/master.zip + GitLab->>User: 302 Found + Note over User,GitLab: Location: https://cdn.com/project/-/archive/master.zip?token=secure-user-token + User->>CDN: GET /project/-/archive/master.zip?token=secure-user-token + alt object not in cache + CDN->>GitLab: GET /project/-/archive/master.zip + Note over CDN,GitLab: X-Gitlab-External-Storage-Token: secure-cdn-token<br/>X-Gitlab-Static-Object-Token: secure-user-token + GitLab->>CDN: 200 OK + CDN->>User: master.zip + else object in cache + CDN->>GitLab: GET /project/-/archive/master.zip + Note over CDN,GitLab: X-Gitlab-External-Storage-Token: secure-cdn-token<br/>X-Gitlab-Static-Object-Token: secure-user-token<br/>If-None-Match: etag-value + GitLab->>CDN: 304 Not Modified + CDN->>User: master.zip + end +``` diff --git a/lib/gitlab/auth/request_authenticator.rb b/lib/gitlab/auth/request_authenticator.rb index 176766d1a8b..aca8804b04c 100644 --- a/lib/gitlab/auth/request_authenticator.rb +++ b/lib/gitlab/auth/request_authenticator.rb @@ -24,7 +24,9 @@ module Gitlab end def find_sessionless_user(request_format) - find_user_from_web_access_token(request_format) || find_user_from_feed_token(request_format) + find_user_from_web_access_token(request_format) || + find_user_from_feed_token(request_format) || + find_user_from_static_object_token(request_format) rescue Gitlab::Auth::AuthenticationError nil end diff --git a/lib/gitlab/auth/user_auth_finders.rb b/lib/gitlab/auth/user_auth_finders.rb index 97755117edc..76d41eede23 100644 --- a/lib/gitlab/auth/user_auth_finders.rb +++ b/lib/gitlab/auth/user_auth_finders.rb @@ -28,6 +28,15 @@ module Gitlab current_request.env['warden']&.authenticate if verified_request? end + def find_user_from_static_object_token(request_format) + return unless valid_static_objects_format?(request_format) + + token = current_request.params[:token].presence || current_request.headers['X-Gitlab-Static-Object-Token'].presence + return unless token + + User.find_by_static_object_token(token) || raise(UnauthorizedError) + end + def find_user_from_feed_token(request_format) return unless valid_rss_format?(request_format) @@ -154,6 +163,15 @@ module Gitlab end end + def valid_static_objects_format?(request_format) + case request_format + when :archive + archive_request? + else + false + end + end + def rss_request? current_request.path.ends_with?('.atom') || current_request.format.atom? end @@ -165,6 +183,10 @@ module Gitlab def api_request? current_request.path.starts_with?("/api/") end + + def archive_request? + current_request.path.include?('/-/archive/') + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e15000b5184..3f525529e0b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -517,6 +517,9 @@ msgstr "" msgid "A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable" msgstr "" +msgid "A secure token that identifies an external storage request." +msgstr "" + msgid "A user with write access to the source branch selected this option" msgstr "" @@ -568,6 +571,9 @@ msgstr "" msgid "AccessTokens|Access Tokens" msgstr "" +msgid "AccessTokens|Are you sure?" +msgstr "" + msgid "AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working." msgstr "" @@ -586,6 +592,9 @@ msgstr "" msgid "AccessTokens|It cannot be used to access any other data." msgstr "" +msgid "AccessTokens|Keep this token secret. Anyone who gets ahold of it can access repository static objects as if they were you. You should %{reset_link_start}reset it%{reset_link_end} if that ever happens." +msgstr "" + msgid "AccessTokens|Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. You should %{link_reset_it} if that ever happens." msgstr "" @@ -595,6 +604,9 @@ msgstr "" msgid "AccessTokens|Personal Access Tokens" msgstr "" +msgid "AccessTokens|Static object token" +msgstr "" + msgid "AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled." msgstr "" @@ -610,6 +622,9 @@ msgstr "" msgid "AccessTokens|Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses." msgstr "" +msgid "AccessTokens|Your static object token is used to authenticate you when repository static objects (e.g. archives, blobs, ...) are being served from an external storage." +msgstr "" + msgid "AccessTokens|reset it" msgstr "" @@ -4852,6 +4867,12 @@ msgstr "" msgid "External authorization request timeout" msgstr "" +msgid "External storage URL" +msgstr "" + +msgid "External storage authentication token" +msgstr "" + msgid "ExternalAuthorizationService|Classification label" msgstr "" @@ -8668,6 +8689,9 @@ msgstr "" msgid "Profiles|Some options are unavailable for LDAP accounts" msgstr "" +msgid "Profiles|Static object token was successfully reset" +msgstr "" + msgid "Profiles|Tell us about yourself in fewer than 250 characters" msgstr "" @@ -9693,6 +9717,9 @@ msgstr "" msgid "Repository mirror" msgstr "" +msgid "Repository static objects" +msgstr "" + msgid "Repository storage" msgstr "" @@ -10244,6 +10271,9 @@ msgstr "" msgid "September" msgstr "" +msgid "Serve repository static objects (e.g. archives, blobs, ...) from an external storage (e.g. a CDN)." +msgstr "" + msgid "Server supports batch API only, please update your Git LFS client to version 1.0.1 and up." msgstr "" @@ -12421,6 +12451,9 @@ msgstr "" msgid "U2F only works with HTTPS-enabled websites. Contact your administrator for more details." msgstr "" +msgid "URL of the external storage that will serve the repository static objects (e.g. archives, blobs, ...)." +msgstr "" + msgid "Unable to apply suggestions to a deleted line." msgstr "" diff --git a/spec/controllers/concerns/static_object_external_storage_spec.rb b/spec/controllers/concerns/static_object_external_storage_spec.rb new file mode 100644 index 00000000000..3a0219ddaa1 --- /dev/null +++ b/spec/controllers/concerns/static_object_external_storage_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe StaticObjectExternalStorage do + controller(Projects::ApplicationController) do + include StaticObjectExternalStorage # rubocop:disable RSpec/DescribedClass + + before_action :redirect_to_external_storage, if: :static_objects_external_storage_enabled? + + def show + head :ok + end + end + + let(:project) { create(:project, :public) } + let(:user) { create(:user, static_object_token: 'hunter1') } + + before do + project.add_developer(user) + sign_in(user) + end + + context 'when external storage is not configured' do + it 'calls the action normally' do + expect(Gitlab::CurrentSettings.static_objects_external_storage_url).to be_blank + + do_request + + expect(response).to have_gitlab_http_status(200) + end + end + + context 'when external storage is configured' do + before do + allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_url).and_return('https://cdn.gitlab.com') + allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_auth_token).and_return('letmein') + + routes.draw { get '/:namespace_id/:id' => 'projects/application#show' } + end + + context 'when external storage token is empty' do + let(:base_redirect_url) { "https://cdn.gitlab.com/#{project.namespace.to_param}/#{project.to_param}" } + + context 'when project is public' do + it 'redirects to external storage URL without adding a token parameter' do + do_request + + expect(response).to redirect_to(base_redirect_url) + end + end + + context 'when project is not public' do + let(:project) { create(:project, :private) } + + it 'redirects to external storage URL a token parameter added' do + do_request + + expect(response).to redirect_to("#{base_redirect_url}?token=#{user.static_object_token}") + end + + context 'when path includes extra parameters' do + it 'includes the parameters in the redirect URL' do + do_request(foo: 'bar') + + expect(response.location).to eq("#{base_redirect_url}?foo=bar&token=#{user.static_object_token}") + end + end + end + end + + context 'when external storage token is present' do + context 'when token is correct' do + it 'calls the action normally' do + request.headers['X-Gitlab-External-Storage-Token'] = 'letmein' + do_request + + expect(response).to have_gitlab_http_status(200) + end + end + + context 'when token is incorrect' do + it 'return 403' do + request.headers['X-Gitlab-External-Storage-Token'] = 'donotletmein' + do_request + + expect(response).to have_gitlab_http_status(403) + end + end + end + end + + def do_request(extra_params = {}) + get :show, params: { namespace_id: project.namespace, id: project }.merge(extra_params) + end +end diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb index fcab4d73dca..084644484c5 100644 --- a/spec/controllers/projects/repositories_controller_spec.rb +++ b/spec/controllers/projects/repositories_controller_spec.rb @@ -125,5 +125,59 @@ describe Projects::RepositoriesController do end end end + + context 'as a sessionless user' do + let(:user) { create(:user) } + + before do + project.add_developer(user) + end + + context 'when no token is provided' do + it 'redirects to sign in page' do + get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: 'zip' + + expect(response).to have_gitlab_http_status(302) + end + end + + context 'when a token param is present' do + context 'when token is correct' do + it 'calls the action normally' do + get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master', token: user.static_object_token }, format: 'zip' + + expect(response).to have_gitlab_http_status(200) + end + end + + context 'when token is incorrect' do + it 'redirects to sign in page' do + get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master', token: 'foobar' }, format: 'zip' + + expect(response).to have_gitlab_http_status(302) + end + end + end + + context 'when a token header is present' do + context 'when token is correct' do + it 'calls the action normally' do + request.headers['X-Gitlab-Static-Object-Token'] = user.static_object_token + get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: 'zip' + + expect(response).to have_gitlab_http_status(200) + end + end + + context 'when token is incorrect' do + it 'redirects to sign in page' do + request.headers['X-Gitlab-Static-Object-Token'] = 'foobar' + get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: 'zip' + + expect(response).to have_gitlab_http_status(302) + end + end + end + end end end diff --git a/spec/features/projects/branches/download_buttons_spec.rb b/spec/features/projects/branches/download_buttons_spec.rb index 401425187b0..e0b0e22823e 100644 --- a/spec/features/projects/branches/download_buttons_spec.rb +++ b/spec/features/projects/branches/download_buttons_spec.rb @@ -29,6 +29,11 @@ describe 'Download buttons in branches page' do end describe 'when checking branches' do + it_behaves_like 'archive download buttons' do + let(:ref) { 'binary-encoding' } + let(:path_to_visit) { project_branches_filtered_path(project, state: 'all', search: ref) } + end + context 'with artifacts' do before do visit project_branches_filtered_path(project, state: 'all', search: 'binary-encoding') diff --git a/spec/features/projects/files/download_buttons_spec.rb b/spec/features/projects/files/download_buttons_spec.rb index a4889f8d4c4..871f5212ddd 100644 --- a/spec/features/projects/files/download_buttons_spec.rb +++ b/spec/features/projects/files/download_buttons_spec.rb @@ -24,11 +24,17 @@ describe 'Projects > Files > Download buttons in files tree' do before do sign_in(user) project.add_developer(user) + end - visit project_tree_path(project, project.default_branch) + it_behaves_like 'archive download buttons' do + let(:path_to_visit) { project_tree_path(project, project.default_branch) } end context 'with artifacts' do + before do + visit project_tree_path(project, project.default_branch) + end + it 'shows download artifacts button' do href = latest_succeeded_project_artifacts_path(project, "#{project.default_branch}/download", job: 'build') diff --git a/spec/features/projects/show/download_buttons_spec.rb b/spec/features/projects/show/download_buttons_spec.rb index 5e7453bcdb7..0d609069426 100644 --- a/spec/features/projects/show/download_buttons_spec.rb +++ b/spec/features/projects/show/download_buttons_spec.rb @@ -29,6 +29,8 @@ describe 'Projects > Show > Download buttons' do end describe 'when checking project main page' do + it_behaves_like 'archive download buttons' + context 'with artifacts' do before do visit project_path(project) diff --git a/spec/features/projects/tags/download_buttons_spec.rb b/spec/features/projects/tags/download_buttons_spec.rb index 76b2704ae49..64141cf5dc9 100644 --- a/spec/features/projects/tags/download_buttons_spec.rb +++ b/spec/features/projects/tags/download_buttons_spec.rb @@ -30,6 +30,11 @@ describe 'Download buttons in tags page' do end describe 'when checking tags' do + it_behaves_like 'archive download buttons' do + let(:path_to_visit) { project_tags_path(project) } + let(:ref) { tag } + end + context 'with artifacts' do before do visit project_tags_path(project) diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index b81249a1e29..4a3ff7e0095 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -195,4 +195,41 @@ describe ApplicationHelper do end end end + + describe '#external_storage_url_or_path' do + let(:project) { create(:project) } + + context 'when external storage is disabled' do + it 'returns the passed path' do + expect(helper.external_storage_url_or_path('/foo/bar', project)).to eq('/foo/bar') + end + end + + context 'when external storage is enabled' do + let(:user) { create(:user, static_object_token: 'hunter1') } + + before do + allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_url).and_return('https://cdn.gitlab.com') + allow(helper).to receive(:current_user).and_return(user) + end + + it 'returns the external storage URL prepended to the path' do + expect(helper.external_storage_url_or_path('/foo/bar', project)).to eq("https://cdn.gitlab.com/foo/bar?token=#{user.static_object_token}") + end + + it 'preserves the path query parameters' do + url = helper.external_storage_url_or_path('/foo/bar?unicode=1', project) + + expect(url).to eq("https://cdn.gitlab.com/foo/bar?token=#{user.static_object_token}&unicode=1") + end + + context 'when project is public' do + let(:project) { create(:project, :public) } + + it 'returns does not append a token parameter' do + expect(helper.external_storage_url_or_path('/foo/bar', project)).to eq('https://cdn.gitlab.com/foo/bar') + end + end + end + end end diff --git a/spec/lib/gitlab/auth/user_auth_finders_spec.rb b/spec/lib/gitlab/auth/user_auth_finders_spec.rb index 41265da97a4..dd8070c1240 100644 --- a/spec/lib/gitlab/auth/user_auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/user_auth_finders_spec.rb @@ -115,6 +115,60 @@ describe Gitlab::Auth::UserAuthFinders do end end + describe '#find_user_from_static_object_token' do + context 'when request format is archive' do + before do + env['SCRIPT_NAME'] = 'project/-/archive/master.zip' + end + + context 'when token header param is present' do + context 'when token is correct' do + it 'returns the user' do + request.headers['X-Gitlab-Static-Object-Token'] = user.static_object_token + + expect(find_user_from_static_object_token(:archive)).to eq(user) + end + end + + context 'when token is incorrect' do + it 'returns the user' do + request.headers['X-Gitlab-Static-Object-Token'] = 'foobar' + + expect { find_user_from_static_object_token(:archive) }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + end + end + + context 'when token query param is present' do + context 'when token is correct' do + it 'returns the user' do + set_param(:token, user.static_object_token) + + expect(find_user_from_static_object_token(:archive)).to eq(user) + end + end + + context 'when token is incorrect' do + it 'returns the user' do + set_param(:token, 'foobar') + + expect { find_user_from_static_object_token(:archive) }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + end + end + end + + context 'when request format is not archive' do + before do + env['script_name'] = 'url' + end + + it 'returns nil' do + expect(find_user_from_static_object_token(:foo)).to be_nil + end + end + end + describe '#find_user_from_access_token' do let(:personal_access_token) { create(:personal_access_token, user: user) } diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 4f7a6d102b8..d12f9b9100a 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -48,6 +48,10 @@ describe ApplicationSetting do it { is_expected.not_to allow_value(nil).for(:outbound_local_requests_whitelist) } it { is_expected.to allow_value([]).for(:outbound_local_requests_whitelist) } + it { is_expected.to allow_value(nil).for(:static_objects_external_storage_url) } + it { is_expected.to allow_value(http).for(:static_objects_external_storage_url) } + it { is_expected.to allow_value(https).for(:static_objects_external_storage_url) } + context "when user accepted let's encrypt terms of service" do before do setting.update(lets_encrypt_terms_of_service_accepted: true) @@ -420,6 +424,16 @@ describe ApplicationSetting do end end end + + context 'static objects external storage' do + context 'when URL is set' do + before do + subject.static_objects_external_storage_url = http + end + + it { is_expected.not_to allow_value(nil).for(:static_objects_external_storage_auth_token) } + end + end end context 'restrict creating duplicates' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b8c323904b8..741563292e9 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -945,6 +945,16 @@ describe User do end end + describe 'static object token' do + it 'ensures a static object token on read' do + user = create(:user, static_object_token: nil) + static_object_token = user.static_object_token + + expect(static_object_token).not_to be_blank + expect(user.reload.static_object_token).to eq static_object_token + end + end + describe '#recently_sent_password_reset?' do it 'is false when reset_password_sent_at is nil' do user = build_stubbed(:user, reset_password_sent_at: nil) diff --git a/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb b/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb new file mode 100644 index 00000000000..920fcbde483 --- /dev/null +++ b/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +shared_examples 'archive download buttons' do + let(:formats) { %w(zip tar.gz tar.bz2 tar) } + let(:path_to_visit) { project_path(project) } + let(:ref) { project.default_branch } + + context 'when static objects external storage is enabled' do + before do + allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_url).and_return('https://cdn.gitlab.com') + visit path_to_visit + end + + context 'private project' do + it 'shows archive download buttons with external storage URL prepended and user token appended to their href' do + formats.each do |format| + path = archive_path(project, ref, format) + uri = URI('https://cdn.gitlab.com') + uri.path = path + uri.query = "token=#{user.static_object_token}" + + expect(page).to have_link format, href: uri.to_s + end + end + end + + context 'public project' do + let(:project) { create(:project, :repository, :public) } + + it 'shows archive download buttons with external storage URL prepended to their href' do + formats.each do |format| + path = archive_path(project, ref, format) + uri = URI('https://cdn.gitlab.com') + uri.path = path + + expect(page).to have_link format, href: uri.to_s + end + end + end + end + + context 'when static objects external storage is disabled' do + before do + visit path_to_visit + end + + it 'shows default archive download buttons' do + formats.each do |format| + path = archive_path(project, ref, format) + + expect(page).to have_link format, href: path + end + end + end + + def archive_path(project, ref, format) + project_archive_path(project, id: "#{ref}/#{project.path}-#{ref}", path: nil, format: format) + end +end |