From 4d10c7f68e2980e11ba6ef88f4e21d6dfd762e71 Mon Sep 17 00:00:00 2001 From: Andrii Mysko Date: Tue, 6 Dec 2022 11:17:12 +0200 Subject: [PATCH] Securely signed releases PRD (#1249) * Add custom_keys * Fix error messages * Add spec * Add audit logging * Adjust custom key audit --- lib/travis/api/v3/models/custom_key.rb | 45 +++++++++ lib/travis/api/v3/models/organization.rb | 5 + lib/travis/api/v3/models/user.rb | 5 + lib/travis/api/v3/queries/custom_key.rb | 65 ++++++++++++ lib/travis/api/v3/renderer/custom_key.rb | 10 ++ lib/travis/api/v3/renderer/owner.rb | 2 +- lib/travis/api/v3/renderer/user.rb | 2 +- lib/travis/api/v3/routes.rb | 10 ++ lib/travis/api/v3/services.rb | 2 + .../api/v3/services/custom_key/delete.rb | 10 ++ .../api/v3/services/custom_keys/create.rb | 12 +++ lib/travis/model/organization.rb | 1 + lib/travis/model/user.rb | 1 + spec/v3/models/custom_key_spec.rb | 23 +++++ spec/v3/queries/custom_key_spec.rb | 99 +++++++++++++++++++ spec/v3/services/custom_key/delete_spec.rb | 15 +++ spec/v3/services/custom_keys/create_spec.rb | 50 ++++++++++ 17 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 lib/travis/api/v3/models/custom_key.rb create mode 100644 lib/travis/api/v3/queries/custom_key.rb create mode 100644 lib/travis/api/v3/renderer/custom_key.rb create mode 100644 lib/travis/api/v3/services/custom_key/delete.rb create mode 100644 lib/travis/api/v3/services/custom_keys/create.rb create mode 100644 spec/v3/models/custom_key_spec.rb create mode 100644 spec/v3/queries/custom_key_spec.rb create mode 100644 spec/v3/services/custom_key/delete_spec.rb create mode 100644 spec/v3/services/custom_keys/create_spec.rb diff --git a/lib/travis/api/v3/models/custom_key.rb b/lib/travis/api/v3/models/custom_key.rb new file mode 100644 index 0000000000..a62ecfbec5 --- /dev/null +++ b/lib/travis/api/v3/models/custom_key.rb @@ -0,0 +1,45 @@ +module Travis::API::V3 + class Models::CustomKey < Model + belongs_to :owner, polymorphic: true + + serialize :private_key, Travis::Model::EncryptedColumn.new + + validates_each :private_key do |record, attr, private_key| + record.errors.add(attr, :missing_attr, message: 'missing_attr') if private_key.blank? + record.errors.add(attr, :invalid_pem, message: 'invalid_pem') unless record.valid_pem? + end + + def save_key!(owner_type, owner_id, name, description, private_key, added_by) + self.owner_type = owner_type + self.owner_id = owner_id + self.private_key = private_key + self.name = name + self.description = description + self.added_by = added_by + + if self.valid? + self.fingerprint = calculate_fingerprint(private_key) + self.public_key = OpenSSL::PKey::RSA.new(private_key).public_key.to_s + + self.save! + end + + self + end + + def valid_pem? + private_key && OpenSSL::PKey::RSA.new(private_key) + true + rescue OpenSSL::PKey::RSAError + false + end + + private + + def calculate_fingerprint(source) + rsa_key = OpenSSL::PKey::RSA.new(source) + public_ssh_rsa = "\x00\x00\x00\x07ssh-rsa" + rsa_key.e.to_s(0) + rsa_key.n.to_s(0) + OpenSSL::Digest::MD5.new(public_ssh_rsa).hexdigest.scan(/../).join(':') + end + end +end diff --git a/lib/travis/api/v3/models/organization.rb b/lib/travis/api/v3/models/organization.rb index f1530db96a..5cd24b23e7 100644 --- a/lib/travis/api/v3/models/organization.rb +++ b/lib/travis/api/v3/models/organization.rb @@ -29,6 +29,11 @@ def build_priorities_enabled? Travis::Features.owner_active?(:build_priorities_org, self) end + def custom_keys + return @custom_keys if defined? @custom_keys + @custom_keys = Models::CustomKey.where(owner_type: 'Organization', owner_id: id) + end + alias members users end end diff --git a/lib/travis/api/v3/models/user.rb b/lib/travis/api/v3/models/user.rb index dcc936b13c..f2bc6eae6f 100644 --- a/lib/travis/api/v3/models/user.rb +++ b/lib/travis/api/v3/models/user.rb @@ -82,5 +82,10 @@ def installation def github? vcs_type == 'GithubUser' end + + def custom_keys + return @custom_keys if defined? @custom_keys + @custom_keys = Models::CustomKey.where(owner_type: 'User', owner_id: id) + end end end diff --git a/lib/travis/api/v3/queries/custom_key.rb b/lib/travis/api/v3/queries/custom_key.rb new file mode 100644 index 0000000000..17ba775c78 --- /dev/null +++ b/lib/travis/api/v3/queries/custom_key.rb @@ -0,0 +1,65 @@ +module Travis::API::V3 + class Queries::CustomKey < Query + def create(params, current_user) + raise UnprocessableEntity, 'Key with this identifier already exists.' unless Travis::API::V3::Models::CustomKey.where(name: params['name'], owner_id: params['owner_id'], owner_type: params['owner_type']).count.zero? + + if params['owner_type'] == 'User' + org_ids = User.find(params['owner_id']).organizations.map(&:id) + + raise UnprocessableEntity, 'Key with this identifier already exists in one of your organizations.' unless Travis::API::V3::Models::CustomKey.where(name: params['name'], owner_id: org_ids, owner_type: 'Organization').count.zero? + elsif params['owner_type'] == 'Organization' + user_ids = Membership.where(organization_id: params['owner_id']).map(&:user_id) + + raise UnprocessableEntity, 'Key with this identifier already exists for your user.' unless Travis::API::V3::Models::CustomKey.where(name: params['name'], owner_id: user_ids, owner_type: 'User').count.zero? + end + + key = Travis::API::V3::Models::CustomKey.new.save_key!( + params['owner_type'], + params['owner_id'], + params['name'], + params['description'], + params['private_key'], + params['added_by'] + ) + handle_errors(key) unless key.valid? + + Travis::API::V3::Models::Audit.create!( + owner: current_user, + change_source: 'travis-api', + source: key, + source_changes: { + action: 'create', + fingerprint: key.fingerprint + } + ) + + key + end + + def delete(params, current_user) + key = Travis::API::V3::Models::CustomKey.find(params['id']) + Travis::API::V3::Models::Audit.create!( + owner: current_user, + change_source: 'travis-api', + source: key, + source_changes: { + action: 'delete', + name: key.name, + owner_type: key.owner_type, + owner_id: key.owner_id, + fingerprint: key.fingerprint + } + ) + + key.destroy + end + + private + + def handle_errors(key) + private_key = key.errors[:private_key] + raise UnprocessableEntity, 'This key is not a private key.' if private_key.include?('invalid_pem') + raise WrongParams if private_key.include?('missing_attr') + end + end +end diff --git a/lib/travis/api/v3/renderer/custom_key.rb b/lib/travis/api/v3/renderer/custom_key.rb new file mode 100644 index 0000000000..0820261629 --- /dev/null +++ b/lib/travis/api/v3/renderer/custom_key.rb @@ -0,0 +1,10 @@ +module Travis::API::V3 + class Renderer::CustomKey < ModelRenderer + representation :standard, :id, :name, :description, :public_key, :fingerprint, :added_by_login, :created_at + representation :minimal, *representations[:standard] + + def added_by_login + model.added_by.nil? ? '' : User.find(model.added_by).login + end + end +end diff --git a/lib/travis/api/v3/renderer/owner.rb b/lib/travis/api/v3/renderer/owner.rb index ea641cc0fc..414dcac5bf 100644 --- a/lib/travis/api/v3/renderer/owner.rb +++ b/lib/travis/api/v3/renderer/owner.rb @@ -6,7 +6,7 @@ class Renderer::Owner < ModelRenderer representation(:minimal, :id, :login, :name, :vcs_type, :ro_mode) representation(:standard, :id, :login, :name, :github_id, :vcs_id, :vcs_type, :avatar_url, :education, - :allow_migration, :allowance, :ro_mode) + :allow_migration, :allowance, :ro_mode, :custom_keys) representation(:additional, :repositories, :installation) def initialize(*) diff --git a/lib/travis/api/v3/renderer/user.rb b/lib/travis/api/v3/renderer/user.rb index 567ad3efce..05b1ebf457 100644 --- a/lib/travis/api/v3/renderer/user.rb +++ b/lib/travis/api/v3/renderer/user.rb @@ -2,7 +2,7 @@ module Travis::API::V3 class Renderer::User < Renderer::Owner - representation(:standard, :email, :is_syncing, :synced_at, :recently_signed_up, :secure_user_hash, :ro_mode, :confirmed_at) + representation(:standard, :email, :is_syncing, :synced_at, :recently_signed_up, :secure_user_hash, :ro_mode, :confirmed_at, :custom_keys) representation(:additional, :emails) def email diff --git a/lib/travis/api/v3/routes.rb b/lib/travis/api/v3/routes.rb index 5ecd8407fa..083c5ef00a 100644 --- a/lib/travis/api/v3/routes.rb +++ b/lib/travis/api/v3/routes.rb @@ -311,6 +311,16 @@ module Routes end end + hidden_resource :custom_keys do + route '/custom_keys' + post :create + end + + hidden_resource :custom_key do + route '/custom_key/{id}' + delete :delete + end + hidden_resource :beta_migration_requests do route '/beta_migration_requests' diff --git a/lib/travis/api/v3/services.rb b/lib/travis/api/v3/services.rb index dc47f38fde..90b850d571 100644 --- a/lib/travis/api/v3/services.rb +++ b/lib/travis/api/v3/services.rb @@ -23,6 +23,8 @@ module Services CreditsCalculator = Module.new { extend Services } Cron = Module.new { extend Services } Crons = Module.new { extend Services } + CustomKey = Module.new { extend Services } + CustomKeys = Module.new { extend Services } EmailSubscription = Module.new { extend Services } EnvVar = Module.new { extend Services } EnvVars = Module.new { extend Services } diff --git a/lib/travis/api/v3/services/custom_key/delete.rb b/lib/travis/api/v3/services/custom_key/delete.rb new file mode 100644 index 0000000000..0293cc9ab2 --- /dev/null +++ b/lib/travis/api/v3/services/custom_key/delete.rb @@ -0,0 +1,10 @@ +module Travis::API::V3 + class Services::CustomKey::Delete < Service + def run! + raise LoginRequired unless access_control.full_access_or_logged_in? + + query(:custom_key).delete(params, access_control.user) + deleted + end + end +end diff --git a/lib/travis/api/v3/services/custom_keys/create.rb b/lib/travis/api/v3/services/custom_keys/create.rb new file mode 100644 index 0000000000..a098dc0485 --- /dev/null +++ b/lib/travis/api/v3/services/custom_keys/create.rb @@ -0,0 +1,12 @@ +module Travis::API::V3 + class Services::CustomKeys::Create < Service + params :owner_id, :owner_type, :name, :description, :private_key, :added_by + result_type :custom_key + + def run! + raise LoginRequired unless access_control.full_access_or_logged_in? + + result query(:custom_key).create(params, access_control.user) + end + end +end diff --git a/lib/travis/model/organization.rb b/lib/travis/model/organization.rb index 50f1c33687..38009c843e 100644 --- a/lib/travis/model/organization.rb +++ b/lib/travis/model/organization.rb @@ -6,6 +6,7 @@ class Organization < Travis::Model has_many :users, :through => :memberships has_many :repositories, :as => :owner has_one :owner_group, as: :owner + has_many :custom_keys, as: :owner def education? Travis::Features.owner_active?(:educational_org, self) diff --git a/lib/travis/model/user.rb b/lib/travis/model/user.rb index c878857f08..7f6c195eb2 100644 --- a/lib/travis/model/user.rb +++ b/lib/travis/model/user.rb @@ -13,6 +13,7 @@ class User < Travis::Model has_many :repositories, through: :permissions has_many :emails, dependent: :destroy has_one :owner_group, as: :owner + has_many :custom_keys, as: :owner before_create :set_as_recent after_create :create_a_token diff --git a/spec/v3/models/custom_key_spec.rb b/spec/v3/models/custom_key_spec.rb new file mode 100644 index 0000000000..8d2fe88340 --- /dev/null +++ b/spec/v3/models/custom_key_spec.rb @@ -0,0 +1,23 @@ +describe Travis::API::V3::Models::CustomKey do + let(:user) { FactoryBot.create(:user) } + let(:owner_type) { 'User' } + let(:owner_id) { user.id } + let(:name) { 'TEST_KEY' } + let(:added_by) { user.id } + let(:private_key) { OpenSSL::PKey::RSA.new(TEST_PRIVATE_KEY).to_pem } + + subject { Travis::API::V3::Models::CustomKey.new } + + it 'must save valid private key' do + key = subject.save_key!(owner_type, owner_id, name, '', private_key, added_by) + + expect(key.name).to eq(name) + expect(key.fingerprint).to eq('57:78:65:c2:c9:c8:c9:f7:dd:2b:35:39:40:27:d2:40') + end + + it 'must not save invalid private key' do + key = subject.save_key!(owner_type, owner_id, name, '', 'INVALID', added_by) + + expect(key.errors.messages[:private_key]).to eq(['invalid_pem']) + end +end diff --git a/spec/v3/queries/custom_key_spec.rb b/spec/v3/queries/custom_key_spec.rb new file mode 100644 index 0000000000..6aa5c7ec2e --- /dev/null +++ b/spec/v3/queries/custom_key_spec.rb @@ -0,0 +1,99 @@ +describe Travis::API::V3::Queries::CustomKey do + let(:private_key) { OpenSSL::PKey::RSA.new(TEST_PRIVATE_KEY).to_pem } + + context 'owner type is user' do + let(:user) { FactoryBot.create(:user) } + let(:params) do + { + 'owner_id' => user.id, + 'owner_type' => 'User', + 'added_by' => user.id, + 'name' => 'TEST_KEY', + 'private_key' => private_key, + 'description' => '' + } + end + + context 'key with identifier does not exist in user or organization' do + it 'creates custom key' do + expect(described_class.new({}, 'CustomKey').create(params).fingerprint).to eq('57:78:65:c2:c9:c8:c9:f7:dd:2b:35:39:40:27:d2:40') + end + end + + context 'key with identifier exists for this user' do + before do + Travis::API::V3::Models::CustomKey.new.save_key!( + params['owner_type'], + params['owner_id'], + params['name'], + params['description'], + params['private_key'], + params['added_by'] + ) + end + + it 'returns error' do + expect { described_class.new({}, 'CustomKey').create(params) }.to raise_error(Travis::API::V3::UnprocessableEntity) + end + end + + context 'key with identifier exists for users organization' do + let(:org) { FactoryBot.create(:org) } + let!(:membership) { org.memberships.create(user: user, role: 'admin', build_permission: true) } + + before do + Travis::API::V3::Models::CustomKey.new.save_key!( + 'Organization', + org.id, + params['name'], + params['description'], + params['private_key'], + params['added_by'] + ) + end + + it 'returns error' do + expect { described_class.new({}, 'CustomKey').create(params) }.to raise_error(Travis::API::V3::UnprocessableEntity) + end + end + end + + context 'owner type is organization' do + let(:org) { FactoryBot.create(:org) } + let(:user) { FactoryBot.create(:user) } + let!(:membership) { org.memberships.create(user: user, role: 'admin', build_permission: true) } + let(:params) do + { + 'owner_id' => org.id, + 'owner_type' => 'Organization', + 'added_by' => user.id, + 'name' => 'TEST_KEY', + 'private_key' => private_key, + 'description' => '' + } + end + + context 'key with identifier does not exist in user or organization' do + it 'creates custom key' do + expect(described_class.new({}, 'CustomKey').create(params).fingerprint).to eq('57:78:65:c2:c9:c8:c9:f7:dd:2b:35:39:40:27:d2:40') + end + end + + context 'key with identifier exists for this user' do + before do + Travis::API::V3::Models::CustomKey.new.save_key!( + 'User', + user.id, + params['name'], + params['description'], + params['private_key'], + params['added_by'] + ) + end + + it 'returns error' do + expect { described_class.new({}, 'CustomKey').create(params) }.to raise_error(Travis::API::V3::UnprocessableEntity) + end + end + end +end diff --git a/spec/v3/services/custom_key/delete_spec.rb b/spec/v3/services/custom_key/delete_spec.rb new file mode 100644 index 0000000000..6b062ce385 --- /dev/null +++ b/spec/v3/services/custom_key/delete_spec.rb @@ -0,0 +1,15 @@ +describe Travis::API::V3::Services::CustomKey::Delete, set_app: true do + let(:user) { FactoryBot.create(:user) } + let(:private_key) { OpenSSL::PKey::RSA.new(TEST_PRIVATE_KEY).to_pem } + let(:custom_key) { Travis::API::V3::Models::CustomKey.new.save_key!('User', user.id, 'TEST_KEY', '', private_key, user.id) } + let(:token) { Travis::Api::App::AccessToken.create(user: user, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}" }} + let(:parsed_body) { JSON.load(body) } + + describe "deleting a custom key by id" do + before { delete("/v3/custom_key/#{custom_key.id}", {}, headers) } + example { expect(last_response.status).to eq 204 } + example { expect(Travis::API::V3::Models::CustomKey.where(id: custom_key.id)).to be_empty } + example { expect(parsed_body).to be_nil } + end +end diff --git a/spec/v3/services/custom_keys/create_spec.rb b/spec/v3/services/custom_keys/create_spec.rb new file mode 100644 index 0000000000..1feadf4c97 --- /dev/null +++ b/spec/v3/services/custom_keys/create_spec.rb @@ -0,0 +1,50 @@ +describe Travis::API::V3::Services::CustomKeys::Create, set_app: true do + let(:private_key) { OpenSSL::PKey::RSA.new(TEST_PRIVATE_KEY).to_pem } + let(:user) { FactoryBot.create(:user) } + let(:token) { Travis::Api::App::AccessToken.create(user: user, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}", "Content-Type" => "application/json" }} + let(:options) do + { + 'owner_id' => user.id, + 'owner_type' => 'User', + 'added_by' => user.id, + 'name' => 'TEST_KEY', + 'private_key' => private_key, + 'description' => '' + } + end + let(:parsed_body) { JSON.load(body) } + + describe "try creating a custom key without login" do + before { post('/v3/custom_keys', options) } + example { expect(parsed_body).to eql_json({ + "@type" => "error", + "error_type" => "login_required", + "error_message" => "login required" + })} + end + + describe "creating custom key" do + before { post('/v3/custom_keys', options, headers) } + example { expect(parsed_body).to eql_json({ + "@type" => "custom_key", + "@representation" => "standard", + "id" => 1, + "name" => "TEST_KEY", + "description" => "", + "public_key" => + "-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6Dm1n+fc0ILeLWeiwqsW\n" + + "s1MZaGAfccrmpvuxfcE9UaJp2POy079g+mdiBgtWfnQlU84YX31rU2x9GJwnb8G6\n" + + "UcvkEjqczOgHHmELtaNmrRH1g8qOfJpzXB8XiNib1L3TDs7qYMKLDCbl2bWrcO7D\n" + + "ol9bSqIeb7f9rzkCd4tuXObL3pMD/VIW5uzeVqLBAc0Er+qw6U7clnMnHHMekXt4\n" + + "JSRfauSCxktR2FzigoQbJc8t4iWOrmNi5Q84VkXB3X7PO/eajUw+RJOl6FnPN1Zh\n" + + "08ceqcqmSMM4RzeVQaczXg7P92P4mRF41R97jIJyzUGwheb2Z4Q2rltck4V7R5Bv\n" + + "MwIDAQAB\n" + + "-----END PUBLIC KEY-----\n", + "fingerprint" => "57:78:65:c2:c9:c8:c9:f7:dd:2b:35:39:40:27:d2:40", + "added_by_login" => "svenfuchs", + "created_at" => Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + })} + end +end