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