diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index e0024378422e860247752af2fda2c1f3bc43ec2d..7067241285e01173627d3405a77a9139f9f283fe 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -56,7 +56,8 @@
"InstanceExternalAuditEventDestination"
],
"AuditEventStreamingDestinationInterface": [
- "GroupAuditEventStreamingDestination"
+ "GroupAuditEventStreamingDestination",
+ "InstanceAuditEventStreamingDestination"
],
"GoogleCloudArtifactRegistryArtifact": [
"GoogleCloudArtifactRegistryDockerImage"
diff --git a/doc/administration/audit_event_types.md b/doc/administration/audit_event_types.md
index 83cda27a0b9f28d6353052ff8d09554edecc29d0..8bc33d680aec66171fb765d18f4d804c2cb34898 100644
--- a/doc/administration/audit_event_types.md
+++ b/doc/administration/audit_event_types.md
@@ -60,6 +60,7 @@ Audit event types belong to the following product categories.
| [`create_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74632) | Event triggered when an external audit event destination is created| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [14.6](https://gitlab.com/gitlab-org/gitlab/-/issues/344664) | Group |
| [`create_group_audit_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/147888) | Event triggered when an external audit event destination for a top-level group is created.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.11](https://gitlab.com/gitlab-org/gitlab/-/issues/436610) | Group |
| [`create_http_namespace_filter`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136047) | Event triggered when a namespace filter for an external audit event destination for a top-level group is created.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.6](https://gitlab.com/gitlab-org/gitlab/-/issues/424176) | Group |
+| [`create_instance_audit_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148383) | Event triggered when an external audit event destination for a GitLab instance is created.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.11](https://gitlab.com/gitlab-org/gitlab/-/issues/436615) | Instance |
| [`create_instance_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123882) | Event triggered when an instance level external audit event destination is created| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.2](https://gitlab.com/gitlab-org/gitlab/-/issues/404730) | Instance |
| [`delete_http_namespace_filter`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136302) | Event triggered when a namespace filter for an external audit event destination for a top-level group is deleted.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.7](https://gitlab.com/gitlab-org/gitlab/-/issues/424177) | Group |
| [`destroy_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74632) | Event triggered when an external audit event destination is deleted| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [14.6](https://gitlab.com/gitlab-org/gitlab/-/issues/344664) | Group |
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 776ba0926e8c6e0e071151c1403405626867274c..17f4609a03b1f3de47e98d01ed0beb3dddb876c6 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -4949,6 +4949,32 @@ Input type: `HttpIntegrationUpdateInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `integration` | [`AlertManagementHttpIntegration`](#alertmanagementhttpintegration) | HTTP integration. |
+### `Mutation.instanceAuditEventStreamingDestinationsCreate`
+
+DETAILS:
+**Introduced** in GitLab 16.11.
+**Status**: Experiment.
+
+Input type: `InstanceAuditEventStreamingDestinationsCreateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `category` | [`String!`](#string) | Destination category. |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `config` | [`JSON!`](#json) | Destination config. |
+| `name` | [`String`](#string) | Destination name. |
+| `secretToken` | [`String!`](#string) | Secret token. |
+
+#### 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. |
+| `externalAuditEventDestination` | [`InstanceAuditEventStreamingDestination`](#instanceauditeventstreamingdestination) | Destination created. |
+
### `Mutation.instanceExternalAuditEventDestinationCreate`
Input type: `InstanceExternalAuditEventDestinationCreateInput`
@@ -21937,6 +21963,19 @@ Stores instance level Amazon S3 configurations for audit event streaming.
| `id` | [`ID!`](#id) | ID of the configuration. |
| `name` | [`String!`](#string) | Name of the external destination to send audit events to. |
+### `InstanceAuditEventStreamingDestination`
+
+Represents an external destination to stream instance level audit events.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `category` | [`String!`](#string) | Category of the external destination to send audit events to. |
+| `config` | [`JSON!`](#json) | Config of the external destination. |
+| `id` | [`ID!`](#id) | ID of the destination. |
+| `name` | [`String!`](#string) | Name of the external destination to send audit events to. |
+
### `InstanceExternalAuditEventDestination`
Represents an external resource to send instance audit events to.
@@ -34827,6 +34866,7 @@ Implementations:
Implementations:
- [`GroupAuditEventStreamingDestination`](#groupauditeventstreamingdestination)
+- [`InstanceAuditEventStreamingDestination`](#instanceauditeventstreamingdestination)
##### Fields
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index 0c0df5c73056b4762f3b0dd41914022349475525..65303056b800fd6f45d6654a5853790b9424654a 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -165,6 +165,8 @@ module MutationType
mount_mutation ::Mutations::ApprovalProjectRules::Delete, alpha: { milestone: '16.10' }
mount_mutation ::Mutations::AuditEvents::Group::AuditEventStreamingDestinations::Create,
alpha: { milestone: '16.11' }
+ mount_mutation ::Mutations::AuditEvents::Instance::AuditEventStreamingDestinations::Create,
+ alpha: { milestone: '16.11' }
prepend(Types::DeprecatedMutations)
end
diff --git a/ee/app/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/base.rb b/ee/app/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/base.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0003fff666e166403d18480778040b3dffa91459
--- /dev/null
+++ b/ee/app/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/base.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AuditEvents
+ module Instance
+ module AuditEventStreamingDestinations
+ class Base < BaseMutation
+ authorize :admin_instance_external_audit_events
+
+ def ready?(**args)
+ raise_resource_not_available_error! unless current_user&.can?(:admin_instance_external_audit_events)
+
+ super
+ end
+
+ private
+
+ def audit(destination, action:)
+ audit_context = {
+ name: "#{action}_instance_audit_event_streaming_destination",
+ author: current_user,
+ scope: Gitlab::Audit::InstanceScope.new,
+ target: destination,
+ message: "#{action.capitalize} audit event streaming destination for #{destination.category.upcase}",
+ additional_details: {
+ category: destination.category,
+ id: destination.id
+ }
+ }
+
+ ::Gitlab::Audit::Auditor.audit(audit_context)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/create.rb b/ee/app/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..75d569819e21381d2f77e7e3e34e6c47c75ba0e2
--- /dev/null
+++ b/ee/app/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/create.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AuditEvents
+ module Instance
+ module AuditEventStreamingDestinations
+ class Create < Base
+ graphql_name 'InstanceAuditEventStreamingDestinationsCreate'
+
+ argument :config, GraphQL::Types::JSON, # rubocop:disable Graphql/JSONType -- Different type of destinations will have different configs
+ required: true,
+ description: 'Destination config.'
+
+ argument :name, GraphQL::Types::String,
+ required: false,
+ description: 'Destination name.'
+
+ argument :category, GraphQL::Types::String,
+ required: true,
+ description: 'Destination category.'
+
+ argument :secret_token, GraphQL::Types::String,
+ required: true,
+ description: 'Secret token.'
+
+ field :external_audit_event_destination, ::Types::AuditEvents::Instance::StreamingDestinationType,
+ null: true,
+ description: 'Destination created.'
+
+ def resolve(secret_token: nil, name: nil, category: nil, config: nil)
+ destination = ::AuditEvents::Instance::ExternalStreamingDestination.new(secret_token: secret_token,
+ name: name,
+ config: config,
+ category: category
+ )
+
+ audit(destination, action: :create) if destination.save
+
+ {
+ external_audit_event_destination: (destination if destination.persisted?),
+ errors: Array(destination.errors)
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/types/audit_events/instance/streaming_destination_type.rb b/ee/app/graphql/types/audit_events/instance/streaming_destination_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6c7e24638cf041d58ac13bc3f3d2dc7ff7660a14
--- /dev/null
+++ b/ee/app/graphql/types/audit_events/instance/streaming_destination_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module AuditEvents
+ module Instance
+ class StreamingDestinationType < ::Types::BaseObject
+ graphql_name 'InstanceAuditEventStreamingDestination'
+ description 'Represents an external destination to stream instance level audit events.'
+ authorize :admin_instance_external_audit_events
+
+ implements AuditEventStreamingDestinationInterface
+ end
+ end
+ end
+end
diff --git a/ee/app/policies/audit_events/instance/external_streaming_destination_policy.rb b/ee/app/policies/audit_events/instance/external_streaming_destination_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..88eb0bdb852e2af8d15c8e56733b98b0df6d2dde
--- /dev/null
+++ b/ee/app/policies/audit_events/instance/external_streaming_destination_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module AuditEvents
+ module Instance
+ class ExternalStreamingDestinationPolicy < ::BasePolicy
+ delegate { :global }
+ end
+ end
+end
diff --git a/ee/config/audit_events/types/create_instance_audit_event_streaming_destination.yml b/ee/config/audit_events/types/create_instance_audit_event_streaming_destination.yml
new file mode 100644
index 0000000000000000000000000000000000000000..72c0d8686527e5b128f58f638c1063386709b69f
--- /dev/null
+++ b/ee/config/audit_events/types/create_instance_audit_event_streaming_destination.yml
@@ -0,0 +1,9 @@
+name: create_instance_audit_event_streaming_destination
+description: Event triggered when an external audit event destination for a GitLab instance is created.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/436615
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148383
+feature_category: audit_events
+milestone: "16.11"
+saved_to_database: true
+streamed: true
+scope: [Instance]
diff --git a/ee/spec/requests/api/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/create_spec.rb b/ee/spec/requests/api/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..acd97953dcdb39815794728615ef88e4a0316544
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/audit_events/instance/audit_event_streaming_destinations/create_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create an instance level external audit event destination', feature_category: :audit_events do
+ include GraphqlHelpers
+
+ let_it_be(:owner) { create(:admin) }
+ let_it_be(:config) do
+ {
+ "url" => 'https://gitlab.com/example/testendpoint'
+ }
+ end
+
+ let(:current_user) { owner }
+ let(:mutation) { graphql_mutation(:instance_audit_event_streaming_destinations_create, input) }
+ let(:mutation_response) { graphql_mutation_response(:instance_audit_event_streaming_destinations_create) }
+
+ let(:input) do
+ {
+ config: config,
+ category: 'http',
+ secret_token: 'random_secret_token'
+ }
+ end
+
+ shared_examples 'creates an audit event' do
+ it 'audits the creation' do
+ expect { subject }
+ .to change { AuditEvent.count }.by(1)
+ end
+ end
+
+ shared_examples 'a mutation that does not create a destination' do
+ it 'does not destroy the destination' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }
+ .not_to change { AuditEvents::Instance::ExternalStreamingDestination.count }
+ end
+
+ it 'does not audit the creation' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }
+ .not_to change { AuditEvent.count }
+ end
+ end
+
+ context 'when feature is licensed' do
+ subject(:mutate) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ before do
+ stub_licensed_features(external_audit_events: true)
+ end
+
+ context 'when current user is instance admin' do
+ it 'creates the destination' do
+ expect { mutate }
+ .to change { AuditEvents::Instance::ExternalStreamingDestination.count }.by(1)
+
+ destination = AuditEvents::Instance::ExternalStreamingDestination.last
+ expect(destination.config).to eq(config)
+ expect(destination.name).not_to be_empty
+ expect(destination.category).to eq('http')
+ expect(destination.secret_token).to eq('random_secret_token')
+ end
+
+ it_behaves_like 'creates an audit event'
+
+ context 'for category' do
+ context 'when category is invalid' do
+ let(:input) do
+ {
+ config: config,
+ category: 'invalid',
+ secret_token: 'random_secret_token'
+ }
+ end
+
+ it_behaves_like 'a mutation that does not create a destination'
+ end
+
+ context 'when category is not provided' do
+ let(:input) do
+ {
+ config: config,
+ secret_token: 'random_secret_token'
+ }
+ end
+
+ it_behaves_like 'a mutation that does not create a destination'
+ end
+ end
+
+ context 'when secret_token is not provided' do
+ let(:input) do
+ {
+ config: config,
+ category: 'http'
+ }
+ end
+
+ it_behaves_like 'a mutation that does not create a destination'
+ end
+
+ context 'for config' do
+ context 'when config is invalid' do
+ let(:input) do
+ {
+ config: "string_value",
+ category: 'http',
+ secret_token: 'random_secret_token'
+ }
+ end
+
+ it_behaves_like 'a mutation that does not create a destination'
+ end
+
+ context 'when config is not provided' do
+ let(:input) do
+ {
+ category: 'http',
+ secret_token: 'random_secret_token'
+ }
+ end
+
+ it_behaves_like 'a mutation that does not create a destination'
+ end
+ end
+ end
+
+ context 'when current user is not instance admin' do
+ let(:current_user) { create(:user) }
+
+ it_behaves_like 'a mutation that does not create a destination'
+ 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 'does not create the destination' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }
+ .not_to change { AuditEvents::Instance::ExternalStreamingDestination.count }
+ end
+ end
+end