diff --git a/doc/administration/audit_event_streaming/audit_event_types.md b/doc/administration/audit_event_streaming/audit_event_types.md
index 34d19327c18b057cb59929c2338408a3790eaa6c..f0abbd255962f3f5cf3f62b995ffd122340a13be 100644
--- a/doc/administration/audit_event_streaming/audit_event_types.md
+++ b/doc/administration/audit_event_streaming/audit_event_types.md
@@ -45,6 +45,7 @@ Audit event types belong to the following product categories.
| [`audit_events_streaming_instance_headers_destroy`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127228) | Triggered when a streaming header for instance level external audit event destination is deleted| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.3](https://gitlab.com/gitlab-org/gitlab/-/issues/417433) |
| [`audit_events_streaming_instance_headers_update`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127228) | Triggered when a streaming header for instance level external audit event destination is updated| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.3](https://gitlab.com/gitlab-org/gitlab/-/issues/417433) |
| [`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) |
+| [`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) |
| [`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) |
| [`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) |
| [`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) |
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 98c5d13e75a4eb03a67a168b91572ee627ca96ed..14ca3565bc5df31e50f1a2445de64f3905e87db0 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1564,6 +1564,27 @@ Input type: `AuditEventsStreamingHeadersUpdateInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `header` | [`AuditEventStreamingHeader`](#auditeventstreamingheader) | Updates header. |
+### `Mutation.auditEventsStreamingHttpNamespaceFiltersAdd`
+
+Input type: `AuditEventsStreamingHTTPNamespaceFiltersAddInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `destinationId` | [`AuditEventsExternalAuditEventDestinationID!`](#auditeventsexternalauditeventdestinationid) | Destination ID. |
+| `groupPath` | [`ID`](#id) | Full path of the group. |
+| `projectPath` | [`ID`](#id) | Full path of the project. |
+
+#### 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` | [`AuditEventStreamingHTTPNamespaceFilter`](#auditeventstreaminghttpnamespacefilter) | Namespace filter created. |
+
### `Mutation.auditEventsStreamingInstanceHeadersCreate`
Input type: `AuditEventsStreamingInstanceHeadersCreateInput`
@@ -14073,6 +14094,18 @@ Represents a HTTP header key/value that belongs to an audit streaming destinatio
| `key` | [`String!`](#string) | Key of the header. |
| `value` | [`String!`](#string) | Value of the header. |
+### `AuditEventsStreamingHTTPNamespaceFiltersAddPayload`
+
+Autogenerated return type of AuditEventsStreamingHTTPNamespaceFiltersAdd.
+
+#### 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` | [`AuditEventStreamingHTTPNamespaceFilter`](#auditeventstreaminghttpnamespacefilter) | Namespace filter created. |
+
### `AuditEventsStreamingInstanceHeader`
Represents a HTTP header key/value that belongs to an instance level audit streaming destination.
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index 4cd40ff64f29316bf152686ab60261295a88683c..42ffa61607094020e894db7cd827a650c001b06a 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -142,6 +142,7 @@ module MutationType
mount_mutation ::Mutations::Analytics::CycleAnalytics::ValueStreams::Create, alpha: { milestone: '16.6' }
mount_mutation ::Mutations::Analytics::CycleAnalytics::ValueStreams::Update, alpha: { milestone: '16.6' }
mount_mutation ::Mutations::Analytics::CycleAnalytics::ValueStreams::Destroy, alpha: { milestone: '16.6' }
+ mount_mutation ::Mutations::AuditEvents::Streaming::HTTP::NamespaceFilters::Create
prepend(Types::DeprecatedMutations)
end
diff --git a/ee/app/graphql/mutations/audit_events/streaming/http/namespace_filters/create.rb b/ee/app/graphql/mutations/audit_events/streaming/http/namespace_filters/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..054d60d0cb3be11c9685e188fceb35efae17228a
--- /dev/null
+++ b/ee/app/graphql/mutations/audit_events/streaming/http/namespace_filters/create.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AuditEvents
+ module Streaming
+ module HTTP
+ module NamespaceFilters
+ class Create < BaseMutation
+ graphql_name 'AuditEventsStreamingHTTPNamespaceFiltersAdd'
+ authorize :admin_external_audit_events
+
+ argument :destination_id, ::Types::GlobalIDType[::AuditEvents::ExternalAuditEventDestination],
+ required: true,
+ description: 'Destination ID.'
+
+ argument :group_path, GraphQL::Types::ID,
+ required: false,
+ description: 'Full path of the group.'
+
+ argument :project_path, GraphQL::Types::ID,
+ required: false,
+ description: 'Full path of the project.'
+
+ field :namespace_filter, ::Types::AuditEvents::Streaming::HTTP::NamespaceFilterType,
+ null: true,
+ description: 'Namespace filter created.'
+
+ def ready?(**args)
+ if args.slice(*mutually_exclusive_args).size != 1
+ arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(' or ')
+ raise Gitlab::Graphql::Errors::ArgumentError, "one and only one of #{arg_str} is required"
+ end
+
+ super
+ end
+
+ def resolve(args)
+ destination = authorized_find!(args[:destination_id])
+
+ namespace = namespace(args[:group_path], args[:project_path])
+
+ filter = ::AuditEvents::Streaming::HTTP::NamespaceFilter
+ .new(external_audit_event_destination: destination,
+ namespace: namespace)
+
+ audit(filter, action: :create) 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::ExternalAuditEventDestination)
+ end
+
+ def namespace(group_path, project_path)
+ if group_path.present?
+ namespace = ::Group.find_by_full_path(group_path)
+ raise_resource_not_available_error! 'group_path is invalid' if namespace.nil?
+ return namespace
+ end
+
+ namespace = ::Project.find_by_full_path(project_path)
+ raise_resource_not_available_error! 'project_path is invalid' if namespace.nil?
+ namespace.project_namespace
+ end
+
+ def audit(filter, action:)
+ audit_context = {
+ name: "#{action}_http_namespace_filter",
+ author: current_user,
+ scope: filter.external_audit_event_destination.group,
+ target: filter.external_audit_event_destination,
+ message: "#{action.capitalize} namespace filter for http audit event streaming destination " \
+ "#{filter.external_audit_event_destination.name} and namespace #{filter.namespace.full_path}"
+ }
+
+ ::Gitlab::Audit::Auditor.audit(audit_context)
+ end
+
+ def mutually_exclusive_args
+ [:group_path, :project_path]
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/config/audit_events/types/create_http_namespace_filter.yml b/ee/config/audit_events/types/create_http_namespace_filter.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b8de6328d07499cd3b56d36a9a52dff21c56d18e
--- /dev/null
+++ b/ee/config/audit_events/types/create_http_namespace_filter.yml
@@ -0,0 +1,8 @@
+name: create_http_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/424176
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136047
+feature_category: audit_events
+milestone: "16.6"
+saved_to_database: true
+streamed: true
diff --git a/ee/spec/requests/api/graphql/audit_events/streaming/http/namespace_filters/create_spec.rb b/ee/spec/requests/api/graphql/audit_events/streaming/http/namespace_filters/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..78b07c56fd905b7efa67b87ec3d1dbe9f9dcfcd4
--- /dev/null
+++ b/ee/spec/requests/api/graphql/audit_events/streaming/http/namespace_filters/create_spec.rb
@@ -0,0 +1,261 @@
+# 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(:external_audit_event_destination, group: group) }
+ let_it_be(:current_user) { create(:user) }
+ let(:mutation) { graphql_mutation(:audit_events_streaming_http_namespace_filters_add, input) }
+ let(:mutation_response) { graphql_mutation_response(:audit_events_streaming_http_namespace_filters_add) }
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ shared_examples 'does not create namespace filter' do
+ it do
+ expect(::Gitlab::Audit::Auditor).not_to receive(:audit)
+
+ expect { subject }.not_to change { AuditEvents::Streaming::HTTP::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 with one path' do
+ context 'when namespace is a descendant of the destination group' do
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ "#{namespace_path.camelize(:lower)}": namespace.full_path
+ }
+ end
+
+ it 'creates a namespace filter', :aggregate_failures do
+ expect(::Gitlab::Audit::Auditor).to receive(:audit).with(a_hash_including(
+ name: 'create_http_namespace_filter',
+ author: current_user,
+ scope: group,
+ target: destination,
+ message: "Create namespace filter for http audit event streaming destination #{destination.name} " \
+ "and namespace #{namespace.full_path}")).once.and_call_original
+
+ expect { subject }
+ .to change { AuditEvent.count }.by(1)
+
+ namespace_filter = destination.namespace_filter
+ expect(namespace_filter.namespace).to eq(namespace)
+ expect(namespace_filter.external_audit_event_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']['externalAuditEventDestination']['name'])
+ .to eq(destination.name)
+ end
+
+ context 'when namespace filter for the destination already exists' do
+ before do
+ create(:audit_events_streaming_http_namespace_filter, external_audit_event_destination: destination,
+ namespace: create(:group, parent: group))
+ end
+
+ it 'returns error' do
+ expect { subject }.not_to change { AuditEvents::Streaming::HTTP::NamespaceFilter.count }
+
+ expect(mutation_response['errors'])
+ .to match_array(['External audit event destination has already been taken'])
+ expect(mutation_response['namespaceFilter']).to be nil
+ end
+ end
+
+ context 'when namespace filter for the given namespace already exists' do
+ before do
+ create(:audit_events_streaming_http_namespace_filter,
+ external_audit_event_destination: create(:external_audit_event_destination, group: group),
+ namespace: namespace
+ )
+ end
+
+ it 'returns error' do
+ expect { subject }.not_to change { AuditEvents::Streaming::HTTP::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.camelize(:lower)}": other_namespace.full_path
+ }
+ end
+
+ it 'returns error' do
+ expect(::Gitlab::Audit::Auditor).not_to receive(:audit)
+
+ expect { subject }.not_to change { AuditEvents::Streaming::HTTP::NamespaceFilter.count }
+
+ expect(mutation_response).to include(
+ 'errors' => ['External audit event 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.camelize(:lower)}": 'invalid_path'
+ }
+ end
+
+ it 'returns error' do
+ expect(::Gitlab::Audit::Auditor).not_to receive(:audit)
+
+ expect { subject }.not_to change { AuditEvents::Streaming::HTTP::NamespaceFilter.count }
+
+ expect(graphql_errors).to include(a_hash_including('message' => "#{namespace_path} is invalid"))
+ 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 with one path' do
+ let_it_be(:namespace) { create(:group, parent: group) }
+ let_it_be(:other_namespace) { create(:group) }
+ let(:namespace_path) { "group_path" }
+ end
+ end
+
+ context 'when project_path is passed in params' do
+ it_behaves_like 'creation of namespace filters with one path' do
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:namespace) { project.project_namespace }
+ let_it_be(:other_namespace) { create(:project_namespace) }
+ let(:namespace_path) { "project_path" }
+ end
+ end
+
+ context 'when both group_path and project_path are passed in params' do
+ let_it_be(:namespace_group) { create(:group, parent: group) }
+ let_it_be(:namespace_project) { create(:project, group: group) }
+
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ projectPath: namespace_project.full_path,
+ groupPath: namespace_group.full_path
+ }
+ end
+
+ let(:error_message) { 'one and only one of groupPath or projectPath is required' }
+
+ it_behaves_like 'does not create namespace filter'
+ end
+
+ context 'when none of group_path and project_path is passed in params' do
+ let(:input) do
+ {
+ destinationId: destination.to_gid
+ }
+ end
+
+ let(:error_message) { 'one and only one of groupPath or projectPath is required' }
+
+ it_behaves_like 'does not create 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,
+ groupPath: namespace_group.full_path
+ }
+ end
+
+ let(:error_message) { ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR }
+
+ it_behaves_like 'does not create namespace filter'
+ end
+
+ context 'when current user is a group developer' do
+ before_all do
+ group.add_developer(current_user)
+ end
+
+ let_it_be(:namespace_group) { create(:group, parent: group) }
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ groupPath: namespace_group.full_path
+ }
+ end
+
+ let(:error_message) { ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR }
+
+ it_behaves_like 'does not create namespace filter'
+ end
+
+ context 'when current user is a group guest' do
+ before_all do
+ group.add_guest(current_user)
+ end
+
+ let_it_be(:namespace_group) { create(:group, parent: group) }
+ let(:input) do
+ {
+ destinationId: destination.to_gid,
+ groupPath: namespace_group.full_path
+ }
+ end
+
+ let(:error_message) { ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR }
+
+ it_behaves_like 'does not create 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,
+ groupPath: namespace_group.full_path
+ }
+ end
+
+ let(:error_message) { ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR }
+
+ it_behaves_like 'does not create namespace filter'
+ end
+end