diff --git a/doc/administration/audit_event_types.md b/doc/administration/audit_event_types.md
index 07ccf5186ba8af28ddc163f2ce0c44ab2ae3018f..4fe70de32947041196e1e86ad0f5f8beda600cb0 100644
--- a/doc/administration/audit_event_types.md
+++ b/doc/administration/audit_event_types.md
@@ -61,9 +61,11 @@ Audit event types belong to the following product categories.
| [`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_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 |
| [`created_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 |
+| [`created_group_namespace_filter`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/150712) | 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 [17.0](https://gitlab.com/gitlab-org/gitlab/-/issues/436612) | Group |
| [`created_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 |
| [`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 |
| [`deleted_group_audit_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148738) | Event triggered when an external audit event destination for a top-level group is deleted.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.11](https://gitlab.com/gitlab-org/gitlab/-/issues/436610) | Group |
+| [`deleted_group_namespace_filter`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/150712) | 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 [17.0](https://gitlab.com/gitlab-org/gitlab/-/issues/436612) | Group |
| [`deleted_instance_audit_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/14910) | Event triggered when an external audit event destination for a GitLab instance is deleted.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.11](https://gitlab.com/gitlab-org/gitlab/-/issues/436615) | Instance |
| [`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 |
| [`destroy_instance_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125846) | Event triggered when an instance level external audit event destination is deleted| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.2](https://gitlab.com/gitlab-org/gitlab/-/issues/404730) | Instance |
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 14ade2b0251e7b41e73885d7a644a017a97c4500..156d2ec6aa66386a7eb0757323d0a950fa87cce5 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1796,6 +1796,52 @@ Input type: `AuditEventsGroupDestinationEventsDeleteInput`
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+### `Mutation.auditEventsGroupDestinationNamespaceFilterCreate`
+
+DETAILS:
+**Introduced** in GitLab 17.0.
+**Status**: Experiment.
+
+Input type: `AuditEventsGroupDestinationNamespaceFilterCreateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `destinationId` | [`AuditEventsGroupExternalStreamingDestinationID!`](#auditeventsgroupexternalstreamingdestinationid) | Destination ID. |
+| `namespacePath` | [`String`](#string) | Full path of the namespace(only project or group). |
+
+#### 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. |
+| `namespaceFilter` | [`GroupAuditEventNamespaceFilter`](#groupauditeventnamespacefilter) | Namespace filter created. |
+
+### `Mutation.auditEventsGroupDestinationNamespaceFilterDelete`
+
+DETAILS:
+**Introduced** in GitLab 17.0.
+**Status**: Experiment.
+
+Input type: `AuditEventsGroupDestinationNamespaceFilterDeleteInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `namespaceFilterId` | [`AuditEventsGroupNamespaceFilterID!`](#auditeventsgroupnamespacefilterid) | Namespace filter ID. |
+
+#### 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. |
+
### `Mutation.auditEventsInstanceAmazonS3ConfigurationCreate`
Input type: `AuditEventsInstanceAmazonS3ConfigurationCreateInput`
@@ -22335,6 +22381,18 @@ four standard [pagination arguments](#pagination-arguments):
| `statusWidget` | [`StatusFilterInput`](#statusfilterinput) | Input for status widget filter. Ignored if `work_items_mvc_2` is disabled. |
| `types` | [`[IssueType!]`](#issuetype) | Filter work items by the given work item types. |
+### `GroupAuditEventNamespaceFilter`
+
+Represents a subgroup or project filter that belongs to a group level external audit event streaming destination.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `externalStreamingDestination` | [`GroupAuditEventStreamingDestination!`](#groupauditeventstreamingdestination) | Destination to which the filter belongs. |
+| `id` | [`ID!`](#id) | ID of the filter. |
+| `namespace` | [`Namespace!`](#namespace) | Group or project namespace the filter belongs to. |
+
### `GroupAuditEventStreamingDestination`
Represents an external destination to stream group level audit events.
@@ -22349,6 +22407,7 @@ Represents an external destination to stream group level audit events.
| `group` | [`Group!`](#group) | Group to which the destination belongs. |
| `id` | [`ID!`](#id) | ID of the destination. |
| `name` | [`String!`](#string) | Name of the external destination to send audit events to. |
+| `namespaceFilters` | [`[GroupAuditEventNamespaceFilter!]`](#groupauditeventnamespacefilter) | List of subgroup or project filters for the destination. |
### `GroupDataTransfer`
@@ -35099,6 +35158,12 @@ A `AuditEventsGroupExternalStreamingDestinationID` is a global ID. It is encoded
An example `AuditEventsGroupExternalStreamingDestinationID` is: `"gid://gitlab/AuditEvents::Group::ExternalStreamingDestination/1"`.
+### `AuditEventsGroupNamespaceFilterID`
+
+A `AuditEventsGroupNamespaceFilterID` is a global ID. It is encoded as a string.
+
+An example `AuditEventsGroupNamespaceFilterID` is: `"gid://gitlab/AuditEvents::Group::NamespaceFilter/1"`.
+
### `AuditEventsInstanceAmazonS3ConfigurationID`
A `AuditEventsInstanceAmazonS3ConfigurationID` is a global ID. It is encoded as a string.
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index 19fee50a185df077bd4851c1fbeae9d6ac30cefc..c572bd52d75f8e9b9b98db6d38744b2256e3c26e 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -192,6 +192,10 @@ module MutationType
alpha: { milestone: '17.0' }
mount_mutation ::Mutations::AuditEvents::Instance::EventTypeFilters::Delete,
alpha: { milestone: '17.0' }
+ mount_mutation ::Mutations::AuditEvents::Group::NamespaceFilters::Create,
+ alpha: { milestone: '17.0' }
+ mount_mutation ::Mutations::AuditEvents::Group::NamespaceFilters::Delete,
+ alpha: { milestone: '17.0' }
prepend(Types::DeprecatedMutations)
end
diff --git a/ee/app/graphql/mutations/audit_events/group/namespace_filters/base.rb b/ee/app/graphql/mutations/audit_events/group/namespace_filters/base.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5c5cdf2c4b5ba17341dfc05f3067a2cccf9f5f09
--- /dev/null
+++ b/ee/app/graphql/mutations/audit_events/group/namespace_filters/base.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AuditEvents
+ module Group
+ module NamespaceFilters
+ class Base < BaseMutation
+ authorize :admin_external_audit_events
+
+ private
+
+ def audit(filter, action:)
+ audit_context = {
+ name: "#{action}_group_namespace_filter",
+ author: current_user,
+ scope: filter.external_streaming_destination.group,
+ target: filter.external_streaming_destination,
+ message: "#{action.capitalize} namespace filter for group audit event streaming destination.",
+ additional_details: {
+ destination_name: filter.external_streaming_destination.name,
+ namespace: filter.namespace.full_path
+ }
+ }
+
+ ::Gitlab::Audit::Auditor.audit(audit_context)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb b/ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d246435119c79d9eb4e28f933d22bbfbdd4e5e55
--- /dev/null
+++ b/ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AuditEvents
+ module Group
+ module NamespaceFilters
+ class Create < Base
+ graphql_name 'AuditEventsGroupDestinationNamespaceFilterCreate'
+
+ argument :destination_id, ::Types::GlobalIDType[::AuditEvents::Group::ExternalStreamingDestination],
+ required: true,
+ description: 'Destination ID.'
+
+ argument :namespace_path, GraphQL::Types::String,
+ required: false,
+ description: 'Full path of the namespace(only project or group).'
+
+ field :namespace_filter, ::Types::AuditEvents::Group::NamespaceFilterType,
+ null: true,
+ description: 'Namespace filter created.'
+
+ def resolve(args)
+ destination = authorized_find!(args[:destination_id])
+
+ namespace = namespace(args[:namespace_path])
+ filter = ::AuditEvents::Group::NamespaceFilter.new(external_streaming_destination: destination,
+ namespace: namespace)
+
+ audit(filter, action: :created) if filter.save
+ { namespace_filter: (filter if filter.persisted?), errors: Array(filter.errors) }
+ end
+
+ private
+
+ def find_object(destination_id)
+ ::GitlabSchema.object_from_id(destination_id,
+ expected_type: ::AuditEvents::Group::ExternalStreamingDestination)
+ end
+
+ def namespace(namespace_path)
+ namespace = Routable.find_by_full_path(namespace_path)
+
+ case namespace
+ when ::Group
+ namespace
+ when ::Project
+ namespace.project_namespace
+ else
+ raise Gitlab::Graphql::Errors::ArgumentError, "namespace_path should be of group or project only."
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/mutations/audit_events/group/namespace_filters/delete.rb b/ee/app/graphql/mutations/audit_events/group/namespace_filters/delete.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2c5fbab530edbb26cde36b3497b84f83e8ed7892
--- /dev/null
+++ b/ee/app/graphql/mutations/audit_events/group/namespace_filters/delete.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AuditEvents
+ module Group
+ module NamespaceFilters
+ class Delete < Base
+ graphql_name 'AuditEventsGroupDestinationNamespaceFilterDelete'
+
+ argument :namespace_filter_id, ::Types::GlobalIDType[::AuditEvents::Group::NamespaceFilter],
+ required: true,
+ description: 'Namespace filter ID.'
+ def resolve(namespace_filter_id:)
+ filter = authorized_find!(id: namespace_filter_id)
+
+ audit(filter, action: :deleted) if filter.destroy
+
+ { namespace_filter: nil, errors: filter.errors }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/types/audit_events/group/namespace_filter_type.rb b/ee/app/graphql/types/audit_events/group/namespace_filter_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0bb30217bff7b53556eb49cdaa412775722096b9
--- /dev/null
+++ b/ee/app/graphql/types/audit_events/group/namespace_filter_type.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Types
+ module AuditEvents
+ module Group
+ class NamespaceFilterType < ::Types::BaseObject
+ graphql_name 'GroupAuditEventNamespaceFilter'
+ description 'Represents a subgroup or project filter that belongs to ' \
+ 'a group level external audit event streaming destination.'
+ authorize :admin_external_audit_events
+
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'ID of the filter.'
+
+ field :namespace, ::Types::NamespaceType,
+ null: false,
+ description: 'Group or project namespace the filter belongs to.'
+
+ field :external_streaming_destination, ::Types::AuditEvents::Group::StreamingDestinationType,
+ null: false,
+ description: 'Destination to which the filter belongs.'
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/types/audit_events/group/streaming_destination_type.rb b/ee/app/graphql/types/audit_events/group/streaming_destination_type.rb
index 9492bce8e2d9df3cef2fb0c168f0b1c107746926..71d3daf10cb48ddcabff9159a9c0322a29f33c99 100644
--- a/ee/app/graphql/types/audit_events/group/streaming_destination_type.rb
+++ b/ee/app/graphql/types/audit_events/group/streaming_destination_type.rb
@@ -13,6 +13,10 @@ class StreamingDestinationType < ::Types::BaseObject
field :group, ::Types::GroupType,
null: false,
description: 'Group to which the destination belongs.'
+
+ field :namespace_filters, [::Types::AuditEvents::Group::NamespaceFilterType],
+ null: true,
+ description: 'List of subgroup or project filters for the destination.'
end
end
end
diff --git a/ee/app/policies/audit_events/group/namespace_filter_policy.rb b/ee/app/policies/audit_events/group/namespace_filter_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..25300b0409ba33f1b12935ec170966a8a727f429
--- /dev/null
+++ b/ee/app/policies/audit_events/group/namespace_filter_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module AuditEvents
+ module Group
+ class NamespaceFilterPolicy < ::BasePolicy
+ delegate { @subject.external_streaming_destination.group }
+ end
+ end
+end
diff --git a/ee/config/audit_events/types/created_group_namespace_filter.yml b/ee/config/audit_events/types/created_group_namespace_filter.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ef4a72da0312ea8448bf2f34a80abe347ee8a2a7
--- /dev/null
+++ b/ee/config/audit_events/types/created_group_namespace_filter.yml
@@ -0,0 +1,9 @@
+name: created_group_namespace_filter
+description: Event triggered when a namespace filter for an external audit event destination for a top-level group is created.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/436612
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/150712
+feature_category: audit_events
+milestone: "17.0"
+saved_to_database: true
+streamed: true
+scope: [Group]
diff --git a/ee/config/audit_events/types/deleted_group_namespace_filter.yml b/ee/config/audit_events/types/deleted_group_namespace_filter.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aca2b7d62d34d0f32d56822b12aef2fbbbd9ae9d
--- /dev/null
+++ b/ee/config/audit_events/types/deleted_group_namespace_filter.yml
@@ -0,0 +1,9 @@
+name: deleted_group_namespace_filter
+description: Event triggered when a namespace filter for an external audit event destination for a top-level group is deleted.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/436612
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/150712
+feature_category: audit_events
+milestone: "17.0"
+saved_to_database: true
+streamed: true
+scope: [Group]
diff --git a/ee/spec/graphql/types/audit_events/group/namespace_filter_type_spec.rb b/ee/spec/graphql/types/audit_events/group/namespace_filter_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c37c5fcd47c3040e6e5350c0d0ddd3b2550fc2c0
--- /dev/null
+++ b/ee/spec/graphql/types/audit_events/group/namespace_filter_type_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['GroupAuditEventNamespaceFilter'], feature_category: :audit_events do
+ let(:fields) do
+ %i[id namespace external_streaming_destination]
+ end
+
+ specify { expect(described_class.graphql_name).to eq('GroupAuditEventNamespaceFilter') }
+ specify { expect(described_class).to have_graphql_fields(fields) }
+end
diff --git a/ee/spec/graphql/types/audit_events/group/streaming_destination_type_spec.rb b/ee/spec/graphql/types/audit_events/group/streaming_destination_type_spec.rb
index 1ff3b4890508176dda8127b7b2c825914dbe0ae6..d0bef72c400c46e83066361005fcd6a314b73dcf 100644
--- a/ee/spec/graphql/types/audit_events/group/streaming_destination_type_spec.rb
+++ b/ee/spec/graphql/types/audit_events/group/streaming_destination_type_spec.rb
@@ -4,7 +4,7 @@
RSpec.describe GitlabSchema.types['GroupAuditEventStreamingDestination'], feature_category: :audit_events do
let(:fields) do
- %i[id name group category config event_type_filters]
+ %i[id name group category config event_type_filters namespace_filters]
end
specify { expect(described_class.graphql_name).to eq('GroupAuditEventStreamingDestination') }
diff --git a/ee/spec/policies/audit_events/group/namespace_filter_policy_spec.rb b/ee/spec/policies/audit_events/group/namespace_filter_policy_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..32c4a837aca4b66c133a2fd9402d2f3bbf4941e1
--- /dev/null
+++ b/ee/spec/policies/audit_events/group/namespace_filter_policy_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AuditEvents::Group::NamespaceFilterPolicy, feature_category: :audit_events do
+ using RSpec::Parameterized::TableSyntax
+
+ before do
+ stub_licensed_features(external_audit_events: true)
+ end
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:destination) { create(:audit_events_group_external_streaming_destination, group: group) }
+ let_it_be(:namespace_filter, reload: true) do
+ create(:audit_events_streaming_group_namespace_filters,
+ external_streaming_destination: destination, namespace: subgroup)
+ end
+
+ subject { described_class.new(user, namespace_filter) }
+
+ where(:user_type, :allowed) do
+ :anonymous | false
+ :guest | false
+ :developer | false
+ :maintainer | false
+ :owner | true
+ end
+
+ with_them do
+ context "for user type #{params[:user_type]}" do
+ before do
+ group.public_send("add_#{user_type}", user) unless user_type == :anonymous
+ end
+
+ if params[:allowed]
+ it { is_expected.to be_allowed(:admin_external_audit_events) }
+ else
+ it { is_expected.not_to be_allowed(:admin_external_audit_events) }
+ end
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/audit_events/group/namespace_filters/create_spec.rb b/ee/spec/requests/api/graphql/audit_events/group/namespace_filters/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ef212c5424671b51bf26ada17df5fb497dc8d9b7
--- /dev/null
+++ b/ee/spec/requests/api/graphql/audit_events/group/namespace_filters/create_spec.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create a namespace filter for group level external audit event destinations', feature_category: :audit_events do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group) }
+ let(:destination) { create(:audit_events_group_external_streaming_destination, group: group) }
+ let_it_be(:current_user) { create(:user) }
+ let(:mutation) { graphql_mutation(:audit_events_group_destination_namespace_filter_create, input) }
+ let(:mutation_response) { graphql_mutation_response(:audit_events_group_destination_namespace_filter_create) }
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ shared_examples 'does not create any namespace filter' do
+ it do
+ expect(::Gitlab::Audit::Auditor).not_to receive(:audit)
+
+ expect { subject }.not_to change { AuditEvents::Group::NamespaceFilter.count }
+
+ expect(graphql_errors).to include(a_hash_including('message' => error_message))
+ expect(mutation_response).to eq(nil)
+ end
+ end
+
+ context 'when feature is licensed' do
+ before do
+ stub_licensed_features(external_audit_events: true)
+ end
+
+ context 'when current user is a group owner' do
+ before_all do
+ group.add_owner(current_user)
+ end
+
+ shared_examples 'creation of namespace filters' do
+ context 'when namespace is a descendant of the destination group' do
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ namespacePath: namespace.full_path
+ }
+ end
+
+ it 'creates a namespace filter', :aggregate_failures do
+ expect(::Gitlab::Audit::Auditor).to receive(:audit).with(a_hash_including(
+ name: 'created_group_namespace_filter',
+ author: current_user,
+ scope: group,
+ target: destination,
+ message: "Created namespace filter for group audit event streaming destination.",
+ additional_details: {
+ destination_name: destination.name,
+ namespace: namespace.full_path
+ }
+ )).once.and_call_original
+
+ expect { subject }
+ .to change { AuditEvent.count }.by(1)
+
+ namespace_filters = destination.namespace_filters
+ expect(namespace_filters.first.namespace).to eq(namespace)
+ expect(namespace_filters.first.external_streaming_destination).to eq(destination)
+
+ expect_graphql_errors_to_be_empty
+
+ expect(mutation_response['errors']).to be_empty
+ expect(mutation_response).to have_key('namespaceFilter')
+ expect(mutation_response['namespaceFilter']['namespace']['fullPath']).to eq(namespace.full_path)
+ expect(mutation_response['namespaceFilter']['externalStreamingDestination']['name'])
+ .to eq(destination.name)
+ end
+
+ context 'when namespace filter for the given namespace already exists' do
+ before do
+ create(:audit_events_streaming_group_namespace_filters,
+ external_streaming_destination: destination,
+ namespace: namespace
+ )
+ end
+
+ it 'returns error' do
+ expect { subject }.not_to change { AuditEvents::Group::NamespaceFilter.count }
+
+ expect(mutation_response['errors']).to match_array(['Namespace has already been taken'])
+ expect(mutation_response['namespaceFilter']).to be nil
+ end
+ end
+ end
+
+ context 'when namespace group is not a descendant of the destination group' do
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ namespace_path: other_namespace.full_path
+ }
+ end
+
+ it 'returns error' do
+ expect(::Gitlab::Audit::Auditor).not_to receive(:audit)
+
+ expect { subject }.not_to change { AuditEvents::Group::NamespaceFilter.count }
+
+ expect(mutation_response).to include(
+ 'errors' => ['External streaming destination does not belong to the top-level group of the namespace.']
+ )
+ expect(mutation_response['namespaceFilter']).to eq(nil)
+ end
+ end
+
+ context 'when given namespace path is invalid' do
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ namespace_path: 'invalid_path'
+ }
+ end
+
+ it 'returns error' do
+ expect(::Gitlab::Audit::Auditor).not_to receive(:audit)
+
+ expect { subject }.not_to change { AuditEvents::Group::NamespaceFilter.count }
+
+ expect(graphql_errors)
+ .to include(a_hash_including('message' => "namespace_path should be of group or project only."))
+ expect(mutation_response).to eq(nil)
+ end
+ end
+ end
+
+ context 'when group_path is passed in params' do
+ it_behaves_like 'creation of namespace filters' do
+ let_it_be(:namespace) { create(:group, parent: group) }
+ let_it_be(:other_namespace) { create(:group) }
+ end
+ end
+
+ context 'when project_path is passed in params' do
+ it_behaves_like 'creation of namespace filters' do
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:namespace) { project.project_namespace }
+ let_it_be(:other_namespace) { create(:project_namespace) }
+ end
+ end
+
+ context 'when namespace_path is invalid' do
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ namespace_path: 'invalid_path'
+ }
+ end
+
+ let(:error_message) { 'namespace_path should be of group or project only.' }
+
+ it_behaves_like 'does not create any namespace filter'
+ end
+ end
+
+ context 'when current user is a group maintainer' do
+ before_all do
+ group.add_maintainer(current_user)
+ end
+
+ let_it_be(:namespace_group) { create(:group, parent: group) }
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ namespacePath: namespace_group.full_path
+ }
+ end
+
+ let(:error_message) { ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR }
+
+ it_behaves_like 'does not create any namespace filter'
+ end
+ end
+
+ context 'when feature is unlicensed' do
+ before do
+ stub_licensed_features(external_audit_events: false)
+ end
+
+ let_it_be(:namespace_group) { create(:group, parent: group) }
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ namespacePath: namespace_group.full_path
+ }
+ end
+
+ let(:error_message) { ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR }
+
+ it_behaves_like 'does not create any namespace filter'
+ end
+end
diff --git a/ee/spec/requests/api/graphql/audit_events/group/namespace_filters/delete_spec.rb b/ee/spec/requests/api/graphql/audit_events/group/namespace_filters/delete_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..91b5463efaf655203c56435dffa71942ca2a785d
--- /dev/null
+++ b/ee/spec/requests/api/graphql/audit_events/group/namespace_filters/delete_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Delete a namespace filter for group level external audit event destinations', feature_category: :audit_events do
+ include GraphqlHelpers
+
+ let(:current_user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:subgroup) { create(:group, parent: group) }
+ let(:destination) { create(:audit_events_group_external_streaming_destination, group: group) }
+ let!(:filter) do
+ create(:audit_events_streaming_group_namespace_filters, external_streaming_destination: destination,
+ namespace: subgroup)
+ end
+
+ let(:mutation) { graphql_mutation(:audit_events_group_destination_namespace_filter_delete, input) }
+ let(:mutation_response) { graphql_mutation_response(:audit_events_group_destination_namespace_filter_delete) }
+
+ let(:input) do
+ { namespaceFilterId: filter.to_gid }
+ end
+
+ subject(:mutate) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ shared_examples 'does not delete the namespace filter' do
+ it do
+ expect(::Gitlab::Audit::Auditor).not_to receive(:audit)
+ .with(a_hash_including(name: 'deleted_group_namespace_filter'))
+
+ expect { subject }.not_to change { destination.reload.namespace_filters.count }
+ end
+ end
+
+ context 'when feature is licensed' do
+ before do
+ stub_licensed_features(external_audit_events: true)
+ end
+
+ context 'when current user is group owner' do
+ before do
+ group.add_owner(current_user)
+ end
+
+ it 'deletes the filter', :aggregate_failures do
+ expect(::Gitlab::Audit::Auditor).to receive(:audit).with(a_hash_including(
+ name: 'deleted_group_namespace_filter',
+ author: current_user,
+ scope: group,
+ target: destination,
+ message: "Deleted namespace filter for group audit event streaming destination."))
+ .once.and_call_original
+
+ expect { mutate }.to change { AuditEvents::Group::NamespaceFilter.count }.by(-1)
+
+ expect(destination.reload.namespace_filters).to be_empty
+ expect_graphql_errors_to_be_empty
+ expect(mutation_response['errors']).to be_empty
+ expect(mutation_response['namespaceFilter']).to be nil
+ end
+ end
+
+ context 'when current user is a group maintainer' do
+ before do
+ group.add_maintainer(current_user)
+ end
+
+ it_behaves_like 'does not delete the namespace filter'
+ end
+ end
+
+ context 'when feature is not licensed' do
+ before do
+ stub_licensed_features(external_audit_events: false)
+ end
+
+ it_behaves_like 'a mutation on an unauthorized resource'
+
+ it_behaves_like 'does not delete the namespace filter'
+ end
+end