diff --git a/db/docs/audit_events_streaming_headers.yml b/db/docs/audit_events_streaming_headers.yml new file mode 100644 index 0000000000000000000000000000000000000000..034ed2c664454ce4d33d661cd0f496627968fa0d --- /dev/null +++ b/db/docs/audit_events_streaming_headers.yml @@ -0,0 +1,9 @@ +--- +table_name: audit_events_streaming_headers +classes: + - AuditEvents::Streaming::Header +feature_categories: + - audit_events +description: Represents a HTTP header sent with streaming audit events +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88063 +milestone: '15.1' diff --git a/db/migrate/20220524141800_create_audit_events_streaming_headers.rb b/db/migrate/20220524141800_create_audit_events_streaming_headers.rb new file mode 100644 index 0000000000000000000000000000000000000000..2bd0362874cf8ac18631c82c2f8b745664f4dc73 --- /dev/null +++ b/db/migrate/20220524141800_create_audit_events_streaming_headers.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateAuditEventsStreamingHeaders < Gitlab::Database::Migration[2.0] + INDEX_NAME = 'idx_streaming_headers_on_external_audit_event_destination_id' + UNIQ_INDEX_NAME = 'idx_external_audit_event_destination_id_key_uniq' + + def change + create_table :audit_events_streaming_headers do |t| + t.timestamps_with_timezone null: false + t.references :external_audit_event_destination, + null: false, + index: { name: INDEX_NAME }, + foreign_key: { to_table: 'audit_events_external_audit_event_destinations', on_delete: :cascade } + t.text :key, null: false, limit: 255 + t.text :value, null: false, limit: 255 + + t.index [:key, :external_audit_event_destination_id], unique: true, name: UNIQ_INDEX_NAME + end + end +end diff --git a/db/schema_migrations/20220524141800 b/db/schema_migrations/20220524141800 new file mode 100644 index 0000000000000000000000000000000000000000..fd15c443b08676b74097bc607100968bd58e8c08 --- /dev/null +++ b/db/schema_migrations/20220524141800 @@ -0,0 +1 @@ +9dddbbdb3e72763cc331b5690536312970c92c64d66d7cb2efc118c107ae204c \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 6973eed89d42c533ad8656c5b563f9d88f338e4d..40b34a19665560ba4ceab68e4ee9eb89b35e2f9a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -11655,6 +11655,26 @@ CREATE SEQUENCE audit_events_id_seq ALTER SEQUENCE audit_events_id_seq OWNED BY audit_events.id; +CREATE TABLE audit_events_streaming_headers ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + external_audit_event_destination_id bigint NOT NULL, + key text NOT NULL, + value text NOT NULL, + CONSTRAINT check_53c3152034 CHECK ((char_length(key) <= 255)), + CONSTRAINT check_ac213cca22 CHECK ((char_length(value) <= 255)) +); + +CREATE SEQUENCE audit_events_streaming_headers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE audit_events_streaming_headers_id_seq OWNED BY audit_events_streaming_headers.id; + CREATE TABLE authentication_events ( id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -22545,6 +22565,8 @@ ALTER TABLE ONLY audit_events ALTER COLUMN id SET DEFAULT nextval('audit_events_ ALTER TABLE ONLY audit_events_external_audit_event_destinations ALTER COLUMN id SET DEFAULT nextval('audit_events_external_audit_event_destinations_id_seq'::regclass); +ALTER TABLE ONLY audit_events_streaming_headers ALTER COLUMN id SET DEFAULT nextval('audit_events_streaming_headers_id_seq'::regclass); + ALTER TABLE ONLY authentication_events ALTER COLUMN id SET DEFAULT nextval('authentication_events_id_seq'::regclass); ALTER TABLE ONLY award_emoji ALTER COLUMN id SET DEFAULT nextval('award_emoji_id_seq'::regclass); @@ -24166,6 +24188,9 @@ ALTER TABLE ONLY audit_events_external_audit_event_destinations ALTER TABLE ONLY audit_events ADD CONSTRAINT audit_events_pkey PRIMARY KEY (id, created_at); +ALTER TABLE ONLY audit_events_streaming_headers + ADD CONSTRAINT audit_events_streaming_headers_pkey PRIMARY KEY (id); + ALTER TABLE ONLY authentication_events ADD CONSTRAINT authentication_events_pkey PRIMARY KEY (id); @@ -26782,6 +26807,8 @@ CREATE INDEX idx_elastic_reindexing_slices_on_elastic_reindexing_subtask_id ON e CREATE UNIQUE INDEX idx_environment_merge_requests_unique_index ON deployment_merge_requests USING btree (environment_id, merge_request_id); +CREATE UNIQUE INDEX idx_external_audit_event_destination_id_key_uniq ON audit_events_streaming_headers USING btree (key, external_audit_event_destination_id); + CREATE INDEX idx_geo_con_rep_updated_events_on_container_repository_id ON geo_container_repository_updated_events USING btree (container_repository_id); CREATE INDEX idx_installable_helm_pkgs_on_project_id_id ON packages_packages USING btree (project_id, id); @@ -26892,6 +26919,8 @@ CREATE INDEX idx_security_scans_on_scan_type ON security_scans USING btree (scan CREATE UNIQUE INDEX idx_serverless_domain_cluster_on_clusters_applications_knative ON serverless_domain_cluster USING btree (clusters_applications_knative_id); +CREATE INDEX idx_streaming_headers_on_external_audit_event_destination_id ON audit_events_streaming_headers USING btree (external_audit_event_destination_id); + CREATE INDEX idx_user_details_on_provisioned_by_group_id_user_id ON user_details USING btree (provisioned_by_group_id, user_id); CREATE UNIQUE INDEX idx_vuln_signatures_on_occurrences_id_and_signature_sha ON vulnerability_finding_signatures USING btree (finding_id, signature_sha); @@ -32287,6 +32316,9 @@ ALTER TABLE ONLY vulnerability_exports ALTER TABLE ONLY prometheus_alert_events ADD CONSTRAINT fk_rails_106f901176 FOREIGN KEY (prometheus_alert_id) REFERENCES prometheus_alerts(id) ON DELETE CASCADE; +ALTER TABLE ONLY audit_events_streaming_headers + ADD CONSTRAINT fk_rails_109fcf96e2 FOREIGN KEY (external_audit_event_destination_id) REFERENCES audit_events_external_audit_event_destinations(id) ON DELETE CASCADE; + ALTER TABLE ONLY ci_sources_projects ADD CONSTRAINT fk_rails_10a1eb379a FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE; diff --git a/doc/administration/audit_event_streaming.md b/doc/administration/audit_event_streaming.md index 1caf8baf9441617093b2be2a657f43674c65f564..c7b2406174dc6b1bedb0a35c604bc594f863dd31 100644 --- a/doc/administration/audit_event_streaming.md +++ b/doc/administration/audit_event_streaming.md @@ -143,6 +143,31 @@ Destination is deleted if: - The returned `errors` object is empty. - The API responds with `200 OK`. +## Custom HTTP header values + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/361216) in GitLab 15.1 [with a flag](feature_flags.md) named `streaming_audit_event_headers`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available per group, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `streaming_audit_event_headers`. +On GitLab.com, this feature is not available. +The feature is not ready for production use. + +Each streaming destination can have up to 20 custom HTTP headers included with each streamed event. + +### Add with the API + +Group owners can add a HTTP header using the GraphQL `auditEventsStreamingHeadersCreate` mutation. + +```graphql +mutation { + auditEventsStreamingHeadersCreate(input: { destinationId: "gid://gitlab/AuditEvents::ExternalAuditEventDestination/24601", key: "foo", value: "bar" }) { + errors + } +} +``` + +The header is created if the returned `errors` object is empty. + ## Verify event authenticity > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345424) in GitLab 14.8. diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index f46b0f4d2dc56370d379584be2fbef1ccf1dff59..5381a2187d5fd61d5cfb02b675c401c862f08494 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -717,6 +717,27 @@ Input type: `ApiFuzzingCiConfigurationCreateInput` | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `gitlabCiYamlEditPath` **{warning-solid}** | [`String`](#string) | **Deprecated:** The configuration snippet is now generated client-side. Deprecated in 14.6. | +### `Mutation.auditEventsStreamingHeadersCreate` + +Input type: `AuditEventsStreamingHeadersCreateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `destinationId` | [`AuditEventsExternalAuditEventDestinationID!`](#auditeventsexternalauditeventdestinationid) | Destination to associate header with. | +| `key` | [`String!`](#string) | Header key. | +| `value` | [`String!`](#string) | Header value. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `header` | [`AuditEventStreamingHeader`](#auditeventstreamingheader) | Created header. | + ### `Mutation.awardEmojiAdd` Input type: `AwardEmojiAddInput` @@ -5679,6 +5700,29 @@ The edge type for [`AlertManagementIntegration`](#alertmanagementintegration). | `cursor` | [`String!`](#string) | A cursor for use in pagination. | | `node` | [`AlertManagementIntegration`](#alertmanagementintegration) | The item at the end of the edge. | +#### `AuditEventStreamingHeaderConnection` + +The connection type for [`AuditEventStreamingHeader`](#auditeventstreamingheader). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `edges` | [`[AuditEventStreamingHeaderEdge]`](#auditeventstreamingheaderedge) | A list of edges. | +| `nodes` | [`[AuditEventStreamingHeader]`](#auditeventstreamingheader) | A list of nodes. | +| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `AuditEventStreamingHeaderEdge` + +The edge type for [`AuditEventStreamingHeader`](#auditeventstreamingheader). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `cursor` | [`String!`](#string) | A cursor for use in pagination. | +| `node` | [`AuditEventStreamingHeader`](#auditeventstreamingheader) | The item at the end of the edge. | + #### `AwardEmojiConnection` The connection type for [`AwardEmoji`](#awardemoji). @@ -9137,6 +9181,18 @@ Represents a vulnerability asset type. | `type` | [`String!`](#string) | Type of the asset. | | `url` | [`String!`](#string) | URL of the asset. | +### `AuditEventStreamingHeader` + +Represents a HTTP header key/value that belongs to an audit streaming destination. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `id` | [`ID!`](#id) | ID of the header. | +| `key` | [`String!`](#string) | Key of the header. | +| `value` | [`String!`](#string) | Value of the header. | + ### `AwardEmoji` An emoji awarded by a user. @@ -11293,6 +11349,7 @@ Represents an external resource to send audit events to. | ---- | ---- | ----------- | | `destinationUrl` | [`String!`](#string) | External destination to send audit events to. | | `group` | [`Group!`](#group) | Group the destination belongs to. | +| `headers` | [`AuditEventStreamingHeaderConnection!`](#auditeventstreamingheaderconnection) | List of additional HTTP headers sent with each event. Available only when feature flag `streaming_audit_event_headers` is enabled. This flag is disabled by default, because the feature is experimental and is subject to change without notice. (see [Connections](#connections)) | | `id` | [`ID!`](#id) | ID of the destination. | | `verificationToken` | [`String!`](#string) | Verification token to validate source of event. | diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index 24605286011bd0263e21522af11f31c87646a74d..83b3edad92cf644fa38dab7ecba1c6a5f052f08f 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -89,6 +89,7 @@ module MutationType mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Destroy mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Update mount_mutation ::Mutations::Ci::NamespaceCiCdSettingsUpdate + mount_mutation ::Mutations::AuditEvents::Streaming::Headers::Create prepend(Types::DeprecatedMutations) end diff --git a/ee/app/graphql/mutations/audit_events/streaming/headers/create.rb b/ee/app/graphql/mutations/audit_events/streaming/headers/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..17353dc0f651d8d43430200cc24ff343094ffdf2 --- /dev/null +++ b/ee/app/graphql/mutations/audit_events/streaming/headers/create.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Mutations + module AuditEvents + module Streaming + module Headers + class Create < BaseMutation + graphql_name 'AuditEventsStreamingHeadersCreate' + authorize :admin_external_audit_events + + argument :key, GraphQL::Types::String, + required: true, + description: 'Header key.' + + argument :value, GraphQL::Types::String, + required: true, + description: 'Header value.' + + argument :destination_id, ::Types::GlobalIDType[::AuditEvents::ExternalAuditEventDestination], + required: true, + description: 'Destination to associate header with.' + + field :header, ::Types::AuditEvents::Streaming::HeaderType, + null: true, + description: 'Created header.' + + def resolve(destination_id:, key:, value:) + destination = authorized_find!(destination_id) + unless Feature.enabled?(:streaming_audit_event_headers, destination.group) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'feature disabled' + end + + header = destination.headers.new(key: key, value: value) + + { header: (header if header.save), errors: Array(header.errors) } + end + + private + + def find_object(destination_id) + GitlabSchema.object_from_id(destination_id, expected_type: ::AuditEvents::ExternalAuditEventDestination) + end + end + end + end + end +end diff --git a/ee/app/graphql/types/audit_events/external_audit_event_destination_type.rb b/ee/app/graphql/types/audit_events/external_audit_event_destination_type.rb index 6b147f7576f8f89748cad0b429c413cba27171d7..c0073eecc96199f44bd037d6d175ca036001d08f 100644 --- a/ee/app/graphql/types/audit_events/external_audit_event_destination_type.rb +++ b/ee/app/graphql/types/audit_events/external_audit_event_destination_type.rb @@ -22,6 +22,11 @@ class ExternalAuditEventDestinationType < ::Types::BaseObject field :verification_token, GraphQL::Types::String, null: false, description: 'Verification token to validate source of event.' + + field :headers, ::Types::AuditEvents::Streaming::HeaderType.connection_type, + null: false, + description: 'List of additional HTTP headers sent with each event.', + feature_flag: :streaming_audit_event_headers end end end diff --git a/ee/app/graphql/types/audit_events/streaming/header_type.rb b/ee/app/graphql/types/audit_events/streaming/header_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..677576fe8e2654c791d27f9080d8142250299682 --- /dev/null +++ b/ee/app/graphql/types/audit_events/streaming/header_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Headers are only available through destinations +# which are already authorized. +# +# rubocop:disable Graphql/AuthorizeTypes +module Types + module AuditEvents + module Streaming + class HeaderType < ::Types::BaseObject + graphql_name 'AuditEventStreamingHeader' + description 'Represents a HTTP header key/value that belongs to an audit streaming destination.' + + field :id, GraphQL::Types::ID, + null: false, + description: 'ID of the header.' + + field :key, GraphQL::Types::String, + null: false, + description: 'Key of the header.' + + field :value, GraphQL::Types::String, + null: false, + description: 'Value of the header.' + end + end + end +end +# rubocop:enable Graphql/AuthorizeTypes diff --git a/ee/app/models/audit_events/external_audit_event_destination.rb b/ee/app/models/audit_events/external_audit_event_destination.rb index a29e17c9dcc96f71c1b6a2348ab7d1a4c77b8b7b..0ce71fc1edf7c024aa7ab941480cd94aa0103f58 100644 --- a/ee/app/models/audit_events/external_audit_event_destination.rb +++ b/ee/app/models/audit_events/external_audit_event_destination.rb @@ -4,20 +4,36 @@ module AuditEvents class ExternalAuditEventDestination < ApplicationRecord include Limitable + STREAMING_TOKEN_HEADER_KEY = "X-Gitlab-Event-Streaming-Token" + MAXIMUM_HEADER_COUNT = 20 + self.limit_name = 'external_audit_event_destinations' self.limit_scope = :group self.table_name = 'audit_events_external_audit_event_destinations' belongs_to :group, class_name: '::Group', foreign_key: 'namespace_id' + has_many :headers, + class_name: 'AuditEvents::Streaming::Header' validates :destination_url, public_url: true, presence: true validates :destination_url, uniqueness: { scope: :namespace_id }, length: { maximum: 255 } has_secure_token :verification_token, length: 24 + validate :has_fewer_than_20_headers? validate :root_level_group? + def headers_hash + { STREAMING_TOKEN_HEADER_KEY => verification_token }.merge(headers.map(&:to_hash).inject(:merge).to_h) + end + private + def has_fewer_than_20_headers? + if headers.count > MAXIMUM_HEADER_COUNT + errors.add(:headers, "are limited to #{MAXIMUM_HEADER_COUNT} per destination") + end + end + def root_level_group? errors.add(:group, 'must not be a subgroup') if group.subgroup? end diff --git a/ee/app/models/audit_events/streaming/header.rb b/ee/app/models/audit_events/streaming/header.rb new file mode 100644 index 0000000000000000000000000000000000000000..d0852879431de9dacf069d097957506d7d43374e --- /dev/null +++ b/ee/app/models/audit_events/streaming/header.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module AuditEvents + module Streaming + class Header < ApplicationRecord + self.table_name = 'audit_events_streaming_headers' + + validates :key, + presence: true, + length: { maximum: 255 }, + uniqueness: { scope: :external_audit_event_destination_id } + validates :value, presence: true, length: { maximum: 255 } + + belongs_to :external_audit_event_destination, foreign_key: 'external_audit_event_destination_id' + + def to_hash + { key => value } + end + end + end +end diff --git a/ee/app/workers/audit_events/audit_event_streaming_worker.rb b/ee/app/workers/audit_events/audit_event_streaming_worker.rb index 3513649eaf8b1509c03ee28addd32f08de989335..b93af6b902304b85f116b0f77b08cd7155c22b07 100644 --- a/ee/app/workers/audit_events/audit_event_streaming_worker.rb +++ b/ee/app/workers/audit_events/audit_event_streaming_worker.rb @@ -4,7 +4,6 @@ module AuditEvents class AuditEventStreamingWorker include ApplicationWorker - STREAMING_TOKEN_HEADER_KEY = "X-Gitlab-Event-Streaming-Token" EVENT_TYPE_HEADER_KEY = "X-Gitlab-Audit-Event-Type" REQUEST_BODY_SIZE_LIMIT = 25.megabytes @@ -26,13 +25,13 @@ def perform(audit_operation, audit_event_id, audit_event_json = nil) return unless group.licensed_feature_available?(:external_audit_events) group.external_audit_event_destinations.each do |destination| + headers = destination.headers_hash + headers[EVENT_TYPE_HEADER_KEY] = audit_operation if audit_operation.present? + Gitlab::HTTP.post(destination.destination_url, body: request_body(audit_event, audit_operation), use_read_total_timeout: true, - headers: { - STREAMING_TOKEN_HEADER_KEY => destination.verification_token, - EVENT_TYPE_HEADER_KEY => audit_operation - }) + headers: headers) rescue URI::InvalidURIError => e Gitlab::ErrorTracking.log_exception(e) rescue *Gitlab::HTTP::HTTP_ERRORS diff --git a/ee/config/feature_flags/development/streaming_audit_event_headers.yml b/ee/config/feature_flags/development/streaming_audit_event_headers.yml new file mode 100644 index 0000000000000000000000000000000000000000..78138f750f0c0de5714b7f208416e69d244fae12 --- /dev/null +++ b/ee/config/feature_flags/development/streaming_audit_event_headers.yml @@ -0,0 +1,8 @@ +--- +name: streaming_audit_event_headers +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88063 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/362941 +milestone: '15.1' +type: development +group: group::compliance +default_enabled: false diff --git a/ee/spec/factories/audit_events/streaming/headers.rb b/ee/spec/factories/audit_events/streaming/headers.rb new file mode 100644 index 0000000000000000000000000000000000000000..968db8f5a306bd20824d8ae18e9d4dcac0f0502c --- /dev/null +++ b/ee/spec/factories/audit_events/streaming/headers.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :audit_events_streaming_header, class: 'AuditEvents::Streaming::Header' do + sequence :key do |i| + "key-#{i}" + end + value { 'bar' } + external_audit_event_destination + end +end diff --git a/ee/spec/graphql/mutations/audit_events/streaming/headers/create_spec.rb b/ee/spec/graphql/mutations/audit_events/streaming/headers/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e559035c81df1736e42f5fd9c56aa6b6927b3d2b --- /dev/null +++ b/ee/spec/graphql/mutations/audit_events/streaming/headers/create_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::AuditEvents::Streaming::Headers::Create do + let_it_be(:current_user) { create(:user) } + let_it_be(:destination) { create(:external_audit_event_destination) } + + let(:group) { destination.group } + let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) } + let(:params) do + { + destination_id: destination.to_gid, + key: 'foo', + value: 'bar' + } + end + + subject { mutation.resolve(**params) } + + describe '#resolve' do + context 'feature is unlicensed' do + before do + stub_licensed_features(external_audit_events: false) + end + + it 'is not authorized' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'feature is licensed' do + before do + stub_licensed_features(external_audit_events: true) + end + + context 'current_user is not group owner' do + it 'returns useful error messages' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'current_user is group owner' do + before do + group.add_owner(current_user) + stub_feature_flags(streaming_audit_event_headers: true) + end + + it 'creates a new header' do + expect { subject }.to change { destination.headers.count }.by 1 + end + + context 'feature is disabled' do + before do + stub_feature_flags(streaming_audit_event_headers: false) + end + + it 'is not authorized' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + end + end + end +end diff --git a/ee/spec/graphql/types/audit_events/exterrnal_audit_event_destination_type_spec.rb b/ee/spec/graphql/types/audit_events/exterrnal_audit_event_destination_type_spec.rb index c2570a749b6d11acf888f415ce001bdbfdb13e10..07090496c559cc021c6b57c89e716949f4cca234 100644 --- a/ee/spec/graphql/types/audit_events/exterrnal_audit_event_destination_type_spec.rb +++ b/ee/spec/graphql/types/audit_events/exterrnal_audit_event_destination_type_spec.rb @@ -4,10 +4,18 @@ RSpec.describe GitlabSchema.types['ExternalAuditEventDestination'] do let(:fields) do - %i[id destination_url group verification_token] + %i[id destination_url group verification_token headers] end specify { expect(described_class.graphql_name).to eq('ExternalAuditEventDestination') } specify { expect(described_class).to have_graphql_fields(fields) } specify { expect(described_class).to require_graphql_authorizations(:admin_external_audit_events) } + + context 'streaming_audit_event_headers flag is disabled' do + before do + stub_feature_flags(streaming_audit_event_headers: false) + end + + specify { expect(described_class).to have_graphql_fields(fields - ['headers']) } + end end diff --git a/ee/spec/graphql/types/audit_events/streaming/header_type_spec.rb b/ee/spec/graphql/types/audit_events/streaming/header_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c9a16a4510cb134eb5d70f8edc5a686afd6a5a18 --- /dev/null +++ b/ee/spec/graphql/types/audit_events/streaming/header_type_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['AuditEventStreamingHeader'] do + let(:fields) do + %i[id key value] + end + + specify { expect(described_class.graphql_name).to eq('AuditEventStreamingHeader') } + specify { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/ee/spec/models/audit_events/external_audit_event_destination_spec.rb b/ee/spec/models/audit_events/external_audit_event_destination_spec.rb index f8c16fe65292258a5e127fd1d3bb9204afd82ace..9aba6d6ec4d8eab636926f3f3dde0b07a3c48f4c 100644 --- a/ee/spec/models/audit_events/external_audit_event_destination_spec.rb +++ b/ee/spec/models/audit_events/external_audit_event_destination_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe AuditEvents::ExternalAuditEventDestination do - subject { create(:external_audit_event_destination) } + subject(:destination) { create(:external_audit_event_destination) } let_it_be(:group) { create(:group) } @@ -17,6 +17,37 @@ it { is_expected.to validate_length_of(:destination_url).is_at_most(255) } it { is_expected.to validate_presence_of(:destination_url) } it { is_expected.to have_db_column(:verification_token).of_type(:text) } + it { is_expected.to have_many(:headers).class_name('AuditEvents::Streaming::Header') } + + it 'can have 20 headers' do + create_list(:audit_events_streaming_header, 20, external_audit_event_destination: subject) + + expect(subject).to be_valid + end + + it 'can have no more than 20 headers' do + create_list(:audit_events_streaming_header, 21, external_audit_event_destination: subject) + + expect(subject).not_to be_valid + expect(subject.errors.full_messages).to contain_exactly('Headers are limited to 20 per destination') + end + end + + describe '#headers_hash' do + subject { destination.headers_hash } + + context "destination has 2 headers" do + before do + create(:audit_events_streaming_header, external_audit_event_destination: destination, key: 'X-GitLab-Hello') + create(:audit_events_streaming_header, external_audit_event_destination: destination, key: 'X-GitLab-World' ) + end + + it do + is_expected.to eq({ 'X-GitLab-Hello' => 'bar', + 'X-GitLab-World' => 'bar', + 'X-Gitlab-Event-Streaming-Token' => destination.verification_token }) + end + end it 'must have a unique destination_url' do create(:external_audit_event_destination, destination_url: 'https://example.com/1', group: group) diff --git a/ee/spec/models/audit_events/streaming/header_spec.rb b/ee/spec/models/audit_events/streaming/header_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ecd4e56152d2ac47a3f3399a82fca7a37bb743d7 --- /dev/null +++ b/ee/spec/models/audit_events/streaming/header_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AuditEvents::Streaming::Header do + subject(:header) { build(:audit_events_streaming_header, key: 'foo', value: 'bar') } + + describe 'Validations' do + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_presence_of(:value) } + it { is_expected.to validate_length_of(:key).is_at_most(255) } + it { is_expected.to validate_length_of(:value).is_at_most(255) } + it { is_expected.to belong_to(:external_audit_event_destination) } + it { is_expected.to validate_uniqueness_of(:key).scoped_to(:external_audit_event_destination_id) } + end + + describe '#to_hash' do + subject { header.to_hash } + + it { is_expected.to eq({ 'foo' => 'bar' }) } + end +end diff --git a/ee/spec/requests/api/graphql/audit_events/streaming/headers/create_spec.rb b/ee/spec/requests/api/graphql/audit_events/streaming/headers/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d1214c3ff6362921ef6c497b85c221fbf816701 --- /dev/null +++ b/ee/spec/requests/api/graphql/audit_events/streaming/headers/create_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create an external audit event destination header' do + include GraphqlHelpers + + let_it_be(:destination) { create(:external_audit_event_destination) } + let_it_be(:owner) { create(:user) } + + let(:current_user) { owner } + let(:group) { destination.group } + let(:mutation) { graphql_mutation(:audit_events_streaming_headers_create, input) } + let(:mutation_response) { graphql_mutation_response(:audit_events_streaming_headers_create) } + + let(:input) do + { + 'destinationId': destination.to_gid, + 'key': 'foo', + 'value': 'bar' + } + end + + let(:invalid_input) do + { + 'destinationId': destination.to_gid, + 'key': '', + 'value': 'bar' + } + end + + shared_examples 'a mutation that does not create a header' do + it 'does not create a header' do + expect { post_graphql_mutation(mutation, current_user: owner) } + .not_to change { destination.headers.count } + end + end + + context 'when feature is licensed' do + subject { post_graphql_mutation(mutation, current_user: owner) } + + before do + stub_licensed_features(external_audit_events: true) + end + + context 'when current user is a group owner' do + before do + group.add_owner(owner) + end + + it 'creates the header with the correct attributes', :aggregate_failures do + expect { subject } + .to change { destination.headers.count }.by(1) + + header = AuditEvents::Streaming::Header.last + + expect(header.key).to eq('foo') + expect(header.value).to eq('bar') + end + + context 'when the header attributes are invalid' do + let(:mutation) { graphql_mutation(:audit_events_streaming_headers_create, invalid_input) } + + it 'returns correct errors' do + post_graphql_mutation(mutation, current_user: owner) + + expect(mutation_response['header']).to be_nil + expect(mutation_response['errors']).to contain_exactly("Key can't be blank") + end + + it_behaves_like 'a mutation that does not create a header' + end + end + + context 'when current user is a group maintainer' do + before do + group.add_maintainer(owner) + end + + it_behaves_like 'a mutation that does not create a header' + end + + context 'when current user is a group developer' do + before do + group.add_developer(owner) + end + + it_behaves_like 'a mutation that does not create a header' + end + + context 'when current user is a group guest' do + before do + group.add_guest(owner) + end + + it_behaves_like 'a mutation that does not create a header' + end + end + + context 'when feature is unlicensed' do + before do + stub_licensed_features(external_audit_events: false) + end + + it_behaves_like 'a mutation on an unauthorized resource' + it_behaves_like 'a mutation that does not create a header' + end +end diff --git a/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb b/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb index e81f6717909ff8191931fa520fff979b1098891b..8f262b633d5ee0658aee8beb4423c9fe9263626a 100644 --- a/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb +++ b/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb @@ -78,6 +78,23 @@ subject end end + + context 'when the destination has custom headers' do + it 'sends the headers with the payload' do + create_list(:audit_events_streaming_header, 2, external_audit_event_destination: group.external_audit_event_destinations.last) + + # rubocop:disable Lint/DuplicateHashKey + expected_hash = { + /key-\d/ => "bar", + /key-\d/ => "bar" + } + # rubocop:enable Lint/DuplicateHashKey + + expect(Gitlab::HTTP).to receive(:post).with(an_instance_of(String), a_hash_including(headers: a_hash_including(expected_hash))).once + + subject + end + end end context 'when the group has several destinations' do diff --git a/lib/gitlab/database/gitlab_schemas.yml b/lib/gitlab/database/gitlab_schemas.yml index 67c27496d678af2cb765113daed0b1af77a0148c..301f019d80c8f0392df1fbce6cbbab25d47819e4 100644 --- a/lib/gitlab/database/gitlab_schemas.yml +++ b/lib/gitlab/database/gitlab_schemas.yml @@ -39,6 +39,7 @@ ar_internal_metadata: :gitlab_shared atlassian_identities: :gitlab_main audit_events_external_audit_event_destinations: :gitlab_main audit_events: :gitlab_main +audit_events_streaming_headers: :gitlab_main authentication_events: :gitlab_main award_emoji: :gitlab_main aws_roles: :gitlab_main