diff --git a/db/docs/audit_events_group_external_streaming_destinations.yml b/db/docs/audit_events_group_external_streaming_destinations.yml new file mode 100644 index 0000000000000000000000000000000000000000..f7d3b4912b6ad5d5a7c7640586a73a0f10b2537b --- /dev/null +++ b/db/docs/audit_events_group_external_streaming_destinations.yml @@ -0,0 +1,13 @@ +--- +table_name: audit_events_group_external_streaming_destinations +classes: +- AuditEvents::Group::ExternalStreamingDestination +feature_categories: +- audit_events +description: Stores external audit event destinations configurations for top-level + groups. +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/141739 +milestone: '16.9' +gitlab_schema: gitlab_main_cell +sharding_key: + group_id: namespaces diff --git a/db/migrate/20240112124030_create_audit_events_group_external_streaming_destinations.rb b/db/migrate/20240112124030_create_audit_events_group_external_streaming_destinations.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ca225f9c71b5a31b0708ba5ab69b427704be32b --- /dev/null +++ b/db/migrate/20240112124030_create_audit_events_group_external_streaming_destinations.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateAuditEventsGroupExternalStreamingDestinations < Gitlab::Database::Migration[2.2] + milestone '16.9' + + NAMESPACE_INDEX_NAME = 'idx_audit_events_group_external_destinations_on_group_id' + + def change + create_table :audit_events_group_external_streaming_destinations do |t| + t.timestamps_with_timezone null: false + t.references :group, null: false, + index: { name: NAMESPACE_INDEX_NAME }, + foreign_key: { to_table: :namespaces, on_delete: :cascade } + t.integer :type, null: false, limit: 2 + t.text :name, null: false, limit: 72 + t.jsonb :config, null: false + t.binary :encrypted_secret_token, null: false + t.binary :encrypted_secret_token_iv, null: false + end + end +end diff --git a/db/schema_migrations/20240112124030 b/db/schema_migrations/20240112124030 new file mode 100644 index 0000000000000000000000000000000000000000..340f1ddb653c1bb1a7b4fb2ca87de9f72437b43a --- /dev/null +++ b/db/schema_migrations/20240112124030 @@ -0,0 +1 @@ +c4a2b947eae6ae3cda7a6bd6e4fc3e4674020a4929d6848f08d17733c5ef5817 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index a1abb5092b7cff3580381d94b2349463ba2169a2..bf51d637ab1697dd4c4d51ca6af923b39203f62f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -13145,6 +13145,28 @@ CREATE SEQUENCE audit_events_google_cloud_logging_configurations_id_seq ALTER SEQUENCE audit_events_google_cloud_logging_configurations_id_seq OWNED BY audit_events_google_cloud_logging_configurations.id; +CREATE TABLE audit_events_group_external_streaming_destinations ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + group_id bigint NOT NULL, + type smallint NOT NULL, + name text NOT NULL, + config jsonb NOT NULL, + encrypted_secret_token bytea NOT NULL, + encrypted_secret_token_iv bytea NOT NULL, + CONSTRAINT check_97d157fbd0 CHECK ((char_length(name) <= 72)) +); + +CREATE SEQUENCE audit_events_group_external_streaming_destinations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE audit_events_group_external_streaming_destinations_id_seq OWNED BY audit_events_group_external_streaming_destinations.id; + CREATE SEQUENCE audit_events_id_seq START WITH 1 INCREMENT BY 1 @@ -26993,6 +27015,8 @@ ALTER TABLE ONLY audit_events_external_audit_event_destinations ALTER COLUMN id ALTER TABLE ONLY audit_events_google_cloud_logging_configurations ALTER COLUMN id SET DEFAULT nextval('audit_events_google_cloud_logging_configurations_id_seq'::regclass); +ALTER TABLE ONLY audit_events_group_external_streaming_destinations ALTER COLUMN id SET DEFAULT nextval('audit_events_group_external_streaming_destinations_id_seq'::regclass); + ALTER TABLE ONLY audit_events_instance_amazon_s3_configurations ALTER COLUMN id SET DEFAULT nextval('audit_events_instance_amazon_s3_configurations_id_seq'::regclass); ALTER TABLE ONLY audit_events_instance_external_audit_event_destinations ALTER COLUMN id SET DEFAULT nextval('audit_events_instance_external_audit_event_destinations_id_seq'::regclass); @@ -28961,6 +28985,9 @@ ALTER TABLE ONLY audit_events_external_audit_event_destinations ALTER TABLE ONLY audit_events_google_cloud_logging_configurations ADD CONSTRAINT audit_events_google_cloud_logging_configurations_pkey PRIMARY KEY (id); +ALTER TABLE ONLY audit_events_group_external_streaming_destinations + ADD CONSTRAINT audit_events_group_external_streaming_destinations_pkey PRIMARY KEY (id); + ALTER TABLE ONLY audit_events_instance_amazon_s3_configurations ADD CONSTRAINT audit_events_instance_amazon_s3_configurations_pkey PRIMARY KEY (id); @@ -32327,6 +32354,8 @@ CREATE INDEX idx_approval_project_rules_on_scan_result_policy_id ON approval_pro CREATE INDEX idx_approval_project_rules_on_sec_orchestration_config_id ON approval_project_rules USING btree (security_orchestration_policy_configuration_id); +CREATE INDEX idx_audit_events_group_external_destinations_on_group_id ON audit_events_group_external_streaming_destinations USING btree (group_id); + CREATE INDEX idx_audit_events_part_on_entity_id_desc_author_id_created_at ON ONLY audit_events USING btree (entity_id, entity_type, id DESC, author_id, created_at); CREATE INDEX idx_award_emoji_on_user_emoji_name_awardable_type_awardable_id ON award_emoji USING btree (user_id, name, awardable_type, awardable_id); @@ -40325,6 +40354,9 @@ ALTER TABLE ONLY milestone_releases ALTER TABLE ONLY resource_state_events ADD CONSTRAINT fk_rails_7ddc5f7457 FOREIGN KEY (source_merge_request_id) REFERENCES merge_requests(id) ON DELETE SET NULL; +ALTER TABLE ONLY audit_events_group_external_streaming_destinations + ADD CONSTRAINT fk_rails_7dffb88f29 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; + ALTER TABLE ONLY clusters_kubernetes_namespaces ADD CONSTRAINT fk_rails_7e7688ecaf FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE; diff --git a/ee/app/models/audit_events/group/external_streaming_destination.rb b/ee/app/models/audit_events/group/external_streaming_destination.rb new file mode 100644 index 0000000000000000000000000000000000000000..017d5f434b9bba850aabac6af07110712f8761ba --- /dev/null +++ b/ee/app/models/audit_events/group/external_streaming_destination.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module AuditEvents + module Group + class ExternalStreamingDestination < ApplicationRecord + include Limitable + include ExternallyStreamable + + self.limit_name = 'external_audit_event_destinations' + self.limit_scope = :group + self.table_name = 'audit_events_group_external_streaming_destinations' + + belongs_to :group, class_name: '::Group', inverse_of: :audit_events + validate :top_level_group? + validates :name, uniqueness: { scope: :group_id } + + def top_level_group? + errors.add(:group, 'must not be a subgroup. Use a top-level group.') if group.subgroup? + end + end + end +end diff --git a/ee/app/models/concerns/audit_events/externally_streamable.rb b/ee/app/models/concerns/audit_events/externally_streamable.rb new file mode 100644 index 0000000000000000000000000000000000000000..58426f537a2f74d1d6ded5d317263c6e07993f95 --- /dev/null +++ b/ee/app/models/concerns/audit_events/externally_streamable.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module AuditEvents + module ExternallyStreamable + extend ActiveSupport::Concern + + included do + before_validation :assign_default_name + + enum type: { + http: 0, + gcp: 1, + aws: 2 + } + + validates :name, length: { maximum: 72 } + validates :type, presence: true + + validates :config, presence: true, json_schema: { filename: 'external_streaming_destination_config' } + validates :secret_token, presence: true + + attr_encrypted :secret_token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm', + encode: false, + encode_iv: false + + private + + def assign_default_name + self.name ||= "Destination_#{SecureRandom.uuid}" + end + end + end +end diff --git a/ee/app/validators/json_schemas/external_streaming_destination_config.json b/ee/app/validators/json_schemas/external_streaming_destination_config.json new file mode 100644 index 0000000000000000000000000000000000000000..308c3fddfbd0366f8a0491d6d1d00f853f154efd --- /dev/null +++ b/ee/app/validators/json_schemas/external_streaming_destination_config.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Config for external audit event streaming destinations", + "type": "object", + "properties": { + } +} diff --git a/ee/spec/factories/audit_events/audit_events_group_external_streaming_destination.rb b/ee/spec/factories/audit_events/audit_events_group_external_streaming_destination.rb new file mode 100644 index 0000000000000000000000000000000000000000..d0ee93fd29d9ebcd76bd914c97299d617f82614f --- /dev/null +++ b/ee/spec/factories/audit_events/audit_events_group_external_streaming_destination.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :audit_events_group_external_streaming_destination, + class: 'AuditEvents::Group::ExternalStreamingDestination' do + group + type { 'http' } + config { { url: 'https://www.example.com' } } + secret_token { 'hello' } + end +end diff --git a/ee/spec/models/audit_events/group/external_streaming_destination_spec.rb b/ee/spec/models/audit_events/group/external_streaming_destination_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d495fc2dbdd905b6a9f02fdc8cf94d76877d6ed6 --- /dev/null +++ b/ee/spec/models/audit_events/group/external_streaming_destination_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AuditEvents::Group::ExternalStreamingDestination, feature_category: :audit_events do + subject(:destination) { build(:audit_events_group_external_streaming_destination) } + + describe 'Associations' do + it 'belongs to a group' do + expect(destination.group).not_to be_nil + end + end + + describe 'Validations' do + let_it_be(:group) { create(:group) } + + it 'validates uniqueness of name scoped to namespace' do + create(:audit_events_group_external_streaming_destination, name: 'Test Destination', group: group) + destination = build(:audit_events_group_external_streaming_destination, name: 'Test Destination', group: group) + + expect(destination).not_to be_valid + expect(destination.errors.full_messages).to include('Name has already been taken') + end + + context 'when group' do + it 'is a subgroup' do + destination.group = build(:group, :nested) + + expect(destination).to be_invalid + expect(destination.errors.full_messages).to include('Group must not be a subgroup. Use a top-level group.') + end + end + end + + it_behaves_like 'includes Limitable concern' do + subject { build(:audit_events_group_external_streaming_destination) } + end + + it_behaves_like 'includes ExternallyStreamable concern' do + subject { build(:audit_events_group_external_streaming_destination) } + + let(:model_factory_name) { :audit_events_group_external_streaming_destination } + end +end diff --git a/ee/spec/support/shared_examples/models/concerns/externally_streamable_shared_examples.rb b/ee/spec/support/shared_examples/models/concerns/externally_streamable_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..165c13accb52eeb73cab048265063e8751f67bc5 --- /dev/null +++ b/ee/spec/support/shared_examples/models/concerns/externally_streamable_shared_examples.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'includes ExternallyStreamable concern' do + describe 'validations' do + it { is_expected.to validate_presence_of(:config) } + it { is_expected.to validate_presence_of(:secret_token) } + it { is_expected.to validate_presence_of(:type) } + it { is_expected.to be_a(AuditEvents::ExternallyStreamable) } + it { is_expected.to validate_length_of(:name).is_at_most(72) } + + context 'when type' do + it 'is valid' do + expect(destination).to be_valid + end + + it 'is nil' do + destination.type = nil + + expect(destination).not_to be_valid + expect(destination.errors.full_messages) + .to match_array(["Type can't be blank"]) + end + + it 'is invalid' do + expect { destination.type = 'invalid' }.to raise_error(ArgumentError) + end + end + + it_behaves_like 'having unique enum values' + + context 'when config' do + it 'is invalid' do + destination.config = 'hello' + + expect(destination).not_to be_valid + expect(destination.errors.full_messages).to include('Config must be a valid json schema') + end + end + + context 'when creating without a name' do + before do + allow(SecureRandom).to receive(:uuid).and_return('12345678') + end + + it 'assigns a default name' do + destination = build(model_factory_name, name: nil) + + expect(destination).to be_valid + expect(destination.name).to eq('Destination_12345678') + end + end + end +end