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