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