diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb new file mode 100644 index 0000000000000000000000000000000000000000..28ad4453333bc9c6d76cac23b6c755fa88374659 --- /dev/null +++ b/app/models/ci/secure_file.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ci + class SecureFile < Ci::ApplicationRecord + include FileStoreMounter + + FILE_SIZE_LIMIT = 5.megabytes.freeze + CHECKSUM_ALGORITHM = 'sha256' + + belongs_to :project, optional: false + + validates :file, presence: true, file_size: { maximum: FILE_SIZE_LIMIT } + + before_validation :assign_checksum + + enum permissions: { read_only: 0, read_write: 1, execute: 2 } + + default_value_for(:file_store) { Ci::SecureFileUploader.default_store } + + mount_file_store_uploader Ci::SecureFileUploader + + def checksum_algorithm + CHECKSUM_ALGORITHM + end + + private + + def assign_checksum + self.checksum = file.checksum if file.present? && file_changed? + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 5c4ffd083043ca53df10b383975011527355e0f2..d430faefe7b1a31cd6b6a8d87354907a9a8c0a71 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -339,6 +339,7 @@ def self.integration_association_name(name) has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, class_name: 'Ci::Variable' + has_many :secure_files, class_name: 'Ci::SecureFile' has_many :triggers, class_name: 'Ci::Trigger' has_many :environments has_many :environments_for_dashboard, -> { from(with_rank.unfoldered.available, :environments).where('rank <= 3') }, class_name: 'Environment' diff --git a/app/uploaders/ci/secure_file_uploader.rb b/app/uploaders/ci/secure_file_uploader.rb new file mode 100644 index 0000000000000000000000000000000000000000..514d88dd177dac8c9accdc68ba37cc10b4229d99 --- /dev/null +++ b/app/uploaders/ci/secure_file_uploader.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Ci + class SecureFileUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.ci_secure_files + + # Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks) + encrypt(key: :key) + + def key + OpenSSL::HMAC.digest('SHA256', Gitlab::Application.secrets.db_key_base, model.project_id.to_s) + end + + def checksum + @checksum ||= Digest::SHA256.hexdigest(model.file.read) + end + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + Gitlab::HashedPath.new('secure_files', model.id, root_hash: model.project_id) + end + + class << self + # direct upload is disabled since the file + # must always be encrypted + def direct_upload_enabled? + false + end + + def background_upload_enabled? + false + end + + def default_store + object_store_enabled? ? ObjectStorage::Store::REMOTE : ObjectStorage::Store::LOCAL + end + end + end +end diff --git a/config/feature_flags/development/ci_secure_files.yml b/config/feature_flags/development/ci_secure_files.yml new file mode 100644 index 0000000000000000000000000000000000000000..529f6b02a2704ab26b6863ece873c025d2f55a71 --- /dev/null +++ b/config/feature_flags/development/ci_secure_files.yml @@ -0,0 +1,8 @@ +--- +name: ci_secure_files +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75695 +rollout_issue_url: TBD +milestone: '14.7' +type: development +group: group::incubation +default_enabled: false diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 4ff5c3b517956181b6364ea1931c330abd3a03b1..2b67572c470244b0426beca66c3fe0f0550e7471 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -1425,6 +1425,18 @@ test: aws_secret_access_key: AWS_SECRET_ACCESS_KEY region: us-east-1 + ci_secure_files: + enabled: true + storage_path: tmp/tests/ci_secure_files + object_store: + enabled: false + remote_directory: ci_secure_files + connection: + provider: AWS # Only AWS supported at the moment + aws_access_key_id: AWS_ACCESS_KEY_ID + aws_secret_access_key: AWS_SECRET_ACCESS_KEY + region: us-east-1 + gitlab: host: localhost port: 80 diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 2587347719a8c667aa91cc3df512e0aefdf7b4d8..f65c76d8b6b0d208e900c839ff9c9eb049f19723 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -246,6 +246,14 @@ Settings.gitlab_ci['builds_path'] = Settings.absolute(Settings.gitlab_ci['builds_path'] || "builds/") Settings.gitlab_ci['url'] ||= Settings.__send__(:build_gitlab_ci_url) +# +# CI Secure Files +# +Settings['ci_secure_files'] ||= Settingslogic.new({}) +Settings.ci_secure_files['enabled'] = true if Settings.ci_secure_files['enabled'].nil? +Settings.ci_secure_files['storage_path'] = Settings.absolute(Settings.ci_secure_files['storage_path'] || File.join(Settings.shared['path'], "ci_secure_files")) +Settings.ci_secure_files['object_store'] = ObjectStoreSettings.legacy_parse(Settings.ci_secure_files['object_store']) + # # Reply by email # diff --git a/config/object_store_settings.rb b/config/object_store_settings.rb index 8cbb3451a1658e10d9f7fc32f74d222985ea244c..53fbfb088db44b4b0c54a5b6d2bed770f42253ac 100644 --- a/config/object_store_settings.rb +++ b/config/object_store_settings.rb @@ -2,7 +2,7 @@ # Set default values for object_store settings class ObjectStoreSettings - SUPPORTED_TYPES = %w(artifacts external_diffs lfs uploads packages dependency_proxy terraform_state pages).freeze + SUPPORTED_TYPES = %w(artifacts external_diffs lfs uploads packages dependency_proxy terraform_state pages secure_files).freeze ALLOWED_OBJECT_STORE_OVERRIDES = %w(bucket enabled proxy_download).freeze # To ensure the one Workhorse credential matches the Rails config, we diff --git a/db/migrate/20211129203539_create_ci_secure_files.rb b/db/migrate/20211129203539_create_ci_secure_files.rb new file mode 100644 index 0000000000000000000000000000000000000000..e0a5dbf7b1946d140f2445cdcd71a3e1781b0986 --- /dev/null +++ b/db/migrate/20211129203539_create_ci_secure_files.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateCiSecureFiles < Gitlab::Database::Migration[1.0] + def up + create_table :ci_secure_files do |t| + t.references :project, index: true, null: false, foreign_key: { on_delete: :cascade } + t.text :name, null: false, limit: 255 + t.integer :file_store, limit: 2, null: false, default: 1 + t.text :file, null: false, limit: 255 + t.binary :checksum, null: false + t.integer :permissions, null: false, default: 0, limit: 2 + t.timestamps_with_timezone null: false + end + end + + def down + drop_table :ci_secure_files, if_exists: true + end +end diff --git a/db/schema_migrations/20211129203539 b/db/schema_migrations/20211129203539 new file mode 100644 index 0000000000000000000000000000000000000000..4646f224fd364e57d0d436d61987d7f358490e84 --- /dev/null +++ b/db/schema_migrations/20211129203539 @@ -0,0 +1 @@ +cba8db1e046712f9eafeb1d3631630a5528752cea885419833085ac1d9a8fff9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index e39a7e2ccbf2fcc833c2f6b7844dd5421048c22a..a0365bb2e8266a60365e363e7e55a55ad7a7e873 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -12207,6 +12207,29 @@ CREATE SEQUENCE ci_running_builds_id_seq ALTER SEQUENCE ci_running_builds_id_seq OWNED BY ci_running_builds.id; +CREATE TABLE ci_secure_files ( + id bigint NOT NULL, + project_id bigint NOT NULL, + name text NOT NULL, + file_store smallint DEFAULT 1 NOT NULL, + file text NOT NULL, + checksum bytea NOT NULL, + permissions smallint DEFAULT 0 NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT check_320790634d CHECK ((char_length(file) <= 255)), + CONSTRAINT check_402c7b4a56 CHECK ((char_length(name) <= 255)) +); + +CREATE SEQUENCE ci_secure_files_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE ci_secure_files_id_seq OWNED BY ci_secure_files.id; + CREATE TABLE ci_sources_pipelines ( id integer NOT NULL, project_id integer, @@ -21438,6 +21461,8 @@ ALTER TABLE ONLY ci_runners ALTER COLUMN id SET DEFAULT nextval('ci_runners_id_s ALTER TABLE ONLY ci_running_builds ALTER COLUMN id SET DEFAULT nextval('ci_running_builds_id_seq'::regclass); +ALTER TABLE ONLY ci_secure_files ALTER COLUMN id SET DEFAULT nextval('ci_secure_files_id_seq'::regclass); + ALTER TABLE ONLY ci_sources_pipelines ALTER COLUMN id SET DEFAULT nextval('ci_sources_pipelines_id_seq'::regclass); ALTER TABLE ONLY ci_sources_projects ALTER COLUMN id SET DEFAULT nextval('ci_sources_projects_id_seq'::regclass); @@ -22928,6 +22953,9 @@ ALTER TABLE ONLY ci_runners ALTER TABLE ONLY ci_running_builds ADD CONSTRAINT ci_running_builds_pkey PRIMARY KEY (id); +ALTER TABLE ONLY ci_secure_files + ADD CONSTRAINT ci_secure_files_pkey PRIMARY KEY (id); + ALTER TABLE ONLY ci_sources_pipelines ADD CONSTRAINT ci_sources_pipelines_pkey PRIMARY KEY (id); @@ -25621,6 +25649,8 @@ CREATE INDEX index_ci_running_builds_on_project_id ON ci_running_builds USING bt CREATE INDEX index_ci_running_builds_on_runner_id ON ci_running_builds USING btree (runner_id); +CREATE INDEX index_ci_secure_files_on_project_id ON ci_secure_files USING btree (project_id); + CREATE INDEX index_ci_sources_pipelines_on_pipeline_id ON ci_sources_pipelines USING btree (pipeline_id); CREATE INDEX index_ci_sources_pipelines_on_project_id ON ci_sources_pipelines USING btree (project_id); @@ -31369,6 +31399,9 @@ ALTER TABLE ONLY experiment_subjects ALTER TABLE ONLY ci_daily_build_group_report_results ADD CONSTRAINT fk_rails_ee072d13b3 FOREIGN KEY (last_pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE; +ALTER TABLE ONLY ci_secure_files + ADD CONSTRAINT fk_rails_eee98d5857 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY packages_debian_group_architectures ADD CONSTRAINT fk_rails_ef667d1b03 FOREIGN KEY (distribution_id) REFERENCES packages_debian_group_distributions(id) ON DELETE CASCADE; diff --git a/ee/spec/uploaders/every_gitlab_uploader_spec.rb b/ee/spec/uploaders/every_gitlab_uploader_spec.rb index 7c36853ee9d479c4805e1f86bbacc3b924381216..3942afe0d6327338ee65a6b1480aa56f7492df8a 100644 --- a/ee/spec/uploaders/every_gitlab_uploader_spec.rb +++ b/ee/spec/uploaders/every_gitlab_uploader_spec.rb @@ -72,6 +72,7 @@ def klass_from_path(path, root) # Please see https://gitlab.com/gitlab-org/gitlab/-/issues/328491 for more details. def known_unimplemented_uploader?(uploader) [ + Ci::SecureFileUploader, # TODO: Add Geo support for Secure Files https://gitlab.com/gitlab-org/gitlab/-/issues/349893 DeletedObjectUploader, DependencyProxy::FileUploader, Packages::Composer::CacheUploader, diff --git a/lib/api/api.rb b/lib/api/api.rb index 5984879413f2606a4c0e54192334c5f811ca1c09..bfd070ba6da0e44a0c4b8f6932efa32c0d8f95f9 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -171,6 +171,7 @@ class API < ::API::Base mount ::API::Ci::ResourceGroups mount ::API::Ci::Runner mount ::API::Ci::Runners + mount ::API::Ci::SecureFiles mount ::API::Ci::Triggers mount ::API::Ci::Variables mount ::API::Commits diff --git a/lib/api/ci/secure_files.rb b/lib/api/ci/secure_files.rb new file mode 100644 index 0000000000000000000000000000000000000000..a6e3f796b35d7d0763cb46501527578ab2f0fcd4 --- /dev/null +++ b/lib/api/ci/secure_files.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module API + module Ci + class SecureFiles < ::API::Base + include PaginationParams + + before do + authenticate! + authorize! :admin_build, user_project + feature_flag_enabled? + end + + feature_category :pipeline_authoring + + default_format :json + + params do + requires :id, type: String, desc: 'The ID of a project' + end + + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'List all Secure Files for a Project' + params do + use :pagination + end + route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + get ':id/secure_files' do + secure_files = user_project.secure_files + present paginate(secure_files), with: Entities::Ci::SecureFile + end + + desc 'Get an individual Secure File' + params do + requires :id, type: Integer, desc: 'The Secure File ID' + end + + route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + get ':id/secure_files/:secure_file_id' do + secure_file = user_project.secure_files.find(params[:secure_file_id]) + not_found!('Secure File') unless secure_file + present secure_file, with: Entities::Ci::SecureFile + end + + desc 'Download a Secure File' + route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + get ':id/secure_files/:secure_file_id/download' do + secure_file = user_project.secure_files.find(params[:secure_file_id]) + not_found!('Secure File') unless secure_file + + content_type 'application/octet-stream' + env['api.format'] = :binary + header['Content-Disposition'] = "attachment; filename=#{secure_file.name}" + body secure_file.file.read + end + + desc 'Upload a Secure File' + params do + requires :name, type: String, desc: 'The name of the file' + requires :file, types: [Rack::Multipart::UploadedFile, ::API::Validations::Types::WorkhorseFile], desc: 'The secure file file to be uploaded' + optional :permissions, type: String, desc: 'The file permissions' + end + + route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + post ':id/secure_files' do + secure_file = user_project.secure_files.new( + name: params[:name], + permissions: params[:permissions] || :read_only + ) + + secure_file.file = params[:file] + + if secure_file.valid? + secure_file.save! + present secure_file, with: Entities::Ci::SecureFile + else + render_validation_error!(secure_file) + end + end + + desc 'Delete an individual Secure File' + route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + delete ':id/secure_files/:secure_file_id' do + secure_file = user_project.secure_files.find(params[:secure_file_id]) + + not_found!('Secure File') unless secure_file + + secure_file.destroy! + + no_content! + end + end + + helpers do + def feature_flag_enabled? + render_api_error!('API not enabled', 404) unless Feature.enabled?(:ci_secure_files, user_project, default_enabled: false) + end + end + end + end +end diff --git a/lib/api/entities/ci/secure_file.rb b/lib/api/entities/ci/secure_file.rb new file mode 100644 index 0000000000000000000000000000000000000000..041c864156b4639ea31c7e2765ad42b38c2fb7db --- /dev/null +++ b/lib/api/entities/ci/secure_file.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module API + module Entities + module Ci + class SecureFile < Grape::Entity + expose :id + expose :name + expose :permissions + expose :checksum + expose :checksum_algorithm + end + end + end +end diff --git a/lib/gitlab/database/gitlab_schemas.yml b/lib/gitlab/database/gitlab_schemas.yml index beabca812a5c35860d665f1a6314de110d323dc6..81b544e5b64782510abbd5a6cf3ba990eece57e1 100644 --- a/lib/gitlab/database/gitlab_schemas.yml +++ b/lib/gitlab/database/gitlab_schemas.yml @@ -106,6 +106,7 @@ ci_runner_namespaces: :gitlab_ci ci_runner_projects: :gitlab_ci ci_runners: :gitlab_ci ci_running_builds: :gitlab_ci +ci_secure_files: :gitlab_ci ci_sources_pipelines: :gitlab_ci ci_sources_projects: :gitlab_ci ci_stages: :gitlab_ci diff --git a/spec/factories/ci/secure_files.rb b/spec/factories/ci/secure_files.rb new file mode 100644 index 0000000000000000000000000000000000000000..9198ea61d1400a611e1eb077dbdd83dd1ae67fd1 --- /dev/null +++ b/spec/factories/ci/secure_files.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_secure_file, class: 'Ci::SecureFile' do + name { 'filename' } + file { fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks', 'application/octet-stream') } + checksum { 'foo1234' } + project + end +end diff --git a/spec/fixtures/ci_secure_files/upload-keystore.jks b/spec/fixtures/ci_secure_files/upload-keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..af5ae5027e1bf163c3407b03dddbe6cc4ee76d8a Binary files /dev/null and b/spec/fixtures/ci_secure_files/upload-keystore.jks differ diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index fb7eb4668b9a9b100aa0eeaa750a393c86d9ca09..625e94e3d77038cb77422ebb06b9120ab02322a6 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -602,6 +602,7 @@ project: - bulk_import_exports - ci_project_mirror - sync_events +- secure_files award_emoji: - awardable - user diff --git a/spec/models/ci/secure_file_spec.rb b/spec/models/ci/secure_file_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c6e65ce9a032cc829e59a1acd7174b829e517932 --- /dev/null +++ b/spec/models/ci/secure_file_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::SecureFile do + let(:sample_file) { fixture_file('ci_secure_files/upload-keystore.jks') } + + subject { create(:ci_secure_file) } + + before do + stub_ci_secure_file_object_storage + end + + it { is_expected.to be_a FileStoreMounter } + + it { is_expected.to belong_to(:project).required } + + it_behaves_like 'having unique enum values' + + context 'file storage' do + describe '#permissions' do + it 'defaults to read_only file permssions' do + expect(subject.permissions).to eq('read_only') + end + end + + describe '#checksum' do + it 'computes SHA256 checksum on the file before encrypted' do + subject.file = CarrierWaveStringFile.new(sample_file) + subject.save! + expect(subject.checksum).to eq(Digest::SHA256.hexdigest(sample_file)) + end + end + + describe '#file' do + it 'returns the saved file' do + subject.file = CarrierWaveStringFile.new(sample_file) + subject.save! + expect(Base64.encode64(subject.file.read)).to eq(Base64.encode64(sample_file)) + end + end + end +end diff --git a/spec/requests/api/ci/secure_files_spec.rb b/spec/requests/api/ci/secure_files_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..74db77a3da117c812b5709c127fa1b83e31f5b27 --- /dev/null +++ b/spec/requests/api/ci/secure_files_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Ci::SecureFiles do + before do + stub_ci_secure_file_object_storage + stub_feature_flags(ci_secure_files: true) + end + + let(:user) { create(:user) } + let(:user2) { create(:user) } + let!(:project) { create(:project, creator_id: user.id) } + let!(:maintainer) { create(:project_member, :maintainer, user: user, project: project) } + let!(:developer) { create(:project_member, :developer, user: user2, project: project) } + let!(:secure_file) { create(:ci_secure_file, project: project) } + + describe 'GET /projects/:id/secure_files' do + context 'ci_secure_files feature flag' do + it 'returns a 404 when the feature flag is disabled' do + stub_feature_flags(ci_secure_files: false) + + get api("/projects/#{project.id}/secure_files", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns a 200 when the feature flag is enabled' do + stub_feature_flags(ci_secure_files: true) + get api("/projects/#{project.id}/secure_files", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_a(Array) + end + end + + context 'authorized user with proper permissions' do + it 'returns project secure files' do + get api("/projects/#{project.id}/secure_files", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_a(Array) + end + end + + context 'authorized user with invalid permissions' do + it 'does not return project secure files' do + get api("/projects/#{project.id}/secure_files", user2) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'unauthorized user' do + it 'does not return project secure files' do + get api("/projects/#{project.id}/secure_files") + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + describe 'GET /projects/:id/secure_files/:secure_file_id' do + context 'authorized user with proper permissions' do + it 'returns project secure file details' do + get api("/projects/#{project.id}/secure_files/#{secure_file.id}", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq(secure_file.name) + expect(json_response['permissions']).to eq(secure_file.permissions) + end + + it 'responds with 404 Not Found if requesting non-existing secure file' do + get api("/projects/#{project.id}/secure_files/99999", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'authorized user with invalid permissions' do + it 'does not return project secure file details' do + get api("/projects/#{project.id}/secure_files/#{secure_file.id}", user2) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'unauthorized user' do + it 'does not return project secure file details' do + get api("/projects/#{project.id}/secure_files/#{secure_file.id}") + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + describe 'POST /projects/:id/secure_files' do + context 'authorized user with proper permissions' do + it 'creates a secure file' do + params = { + file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), + name: 'upload-keystore.jks', + permissions: 'execute' + } + + expect do + post api("/projects/#{project.id}/secure_files", user), params: params + end.to change {project.secure_files.count}.by(1) + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['name']).to eq('upload-keystore.jks') + expect(json_response['permissions']).to eq('execute') + expect(json_response['checksum']).to eq(secure_file.checksum) + expect(json_response['checksum_algorithm']).to eq('sha256') + + secure_file = Ci::SecureFile.find(json_response['id']) + expect(secure_file.checksum).to eq( + Digest::SHA256.hexdigest(fixture_file('ci_secure_files/upload-keystore.jks')) + ) + expect(json_response['id']).to eq(secure_file.id) + end + + it 'creates a secure file with read_only permissions by default' do + params = { + file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), + name: 'upload-keystore.jks' + } + + expect do + post api("/projects/#{project.id}/secure_files", user), params: params + end.to change {project.secure_files.count}.by(1) + + expect(json_response['permissions']).to eq('read_only') + end + + it 'uploads and downloads a secure file' do + post_params = { + file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), + name: 'upload-keystore.jks' + } + + post api("/projects/#{project.id}/secure_files", user), params: post_params + + secure_file_id = json_response['id'] + + get api("/projects/#{project.id}/secure_files/#{secure_file_id}/download", user) + + expect(Base64.encode64(response.body)).to eq(Base64.encode64(fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks').read)) + end + + it 'returns an error when the file checksum fails to validate' do + secure_file.update!(checksum: 'foo') + + get api("/projects/#{project.id}/secure_files/#{secure_file.id}/download", user) + + expect(response.code).to eq("500") + end + end + + context 'authorized user with invalid permissions' do + it 'does not create a secure file' do + post api("/projects/#{project.id}/secure_files", user2) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'unauthorized user' do + it 'does not create a secure file' do + post api("/projects/#{project.id}/secure_files") + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + describe 'DELETE /projects/:id/secure_files/:secure_file_id' do + context 'authorized user with proper permissions' do + it 'deletes the secure file' do + expect do + delete api("/projects/#{project.id}/secure_files/#{secure_file.id}", user) + + expect(response).to have_gitlab_http_status(:no_content) + end.to change {project.secure_files.count}.by(-1) + end + + it 'responds with 404 Not Found if requesting non-existing secure_file' do + delete api("/projects/#{project.id}/secure_files/99999", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'authorized user with invalid permissions' do + it 'does not delete the secure_file' do + delete api("/projects/#{project.id}/secure_files/#{secure_file.id}", user2) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'unauthorized user' do + it 'does not delete the secure_file' do + delete api("/projects/#{project.id}/secure_files/#{secure_file.id}") + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end +end diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index 5e86b08aa45beaff9eb1fb760b5c2f9078fc634c..d49a14f7f5b20ba637032dd389275a2001d35302 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -91,6 +91,12 @@ def stub_uploads_object_storage(uploader = described_class, **params) **params) end + def stub_ci_secure_file_object_storage(**params) + stub_object_storage_uploader(config: Gitlab.config.ci_secure_files.object_store, + uploader: Ci::SecureFileUploader, + **params) + end + def stub_terraform_state_object_storage(**params) stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store, uploader: Terraform::StateUploader, diff --git a/spec/uploaders/ci/secure_file_uploader_spec.rb b/spec/uploaders/ci/secure_file_uploader_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e1457600cbed8b8d5a57176527d289034c8d2573 --- /dev/null +++ b/spec/uploaders/ci/secure_file_uploader_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::SecureFileUploader do + subject { ci_secure_file.file } + + let(:project) { create(:project) } + let(:ci_secure_file) { create(:ci_secure_file) } + let(:sample_file) { fixture_file('ci_secure_files/upload-keystore.jks') } + + before do + stub_ci_secure_file_object_storage + end + + describe '#key' do + it 'creates a digest with a secret key and the project id' do + expect(OpenSSL::HMAC) + .to receive(:digest) + .with('SHA256', Gitlab::Application.secrets.db_key_base, ci_secure_file.project_id.to_s) + .and_return('digest') + + expect(subject.key).to eq('digest') + end + end + + describe '.checksum' do + it 'returns a SHA256 checksum for the unencrypted file' do + expect(subject.checksum).to eq(Digest::SHA256.hexdigest(sample_file)) + end + end + + describe 'encryption' do + it 'encrypts the stored file' do + expect(Base64.encode64(subject.file.read)).not_to eq(Base64.encode64(sample_file)) + end + + it 'decrypts the file when reading' do + expect(Base64.encode64(subject.read)).to eq(Base64.encode64(sample_file)) + end + end + + describe '.direct_upload_enabled?' do + it 'returns false' do + expect(described_class.direct_upload_enabled?).to eq(false) + end + end + + describe '.default_store' do + context 'when object storage is enabled' do + it 'returns REMOTE' do + expect(described_class.default_store).to eq(ObjectStorage::Store::REMOTE) + end + end + + context 'when object storage is disabled' do + before do + stub_ci_secure_file_object_storage(enabled: false) + end + + it 'returns LOCAL' do + expect(described_class.default_store).to eq(ObjectStorage::Store::LOCAL) + end + end + end +end