From 1ff58be5e18046fea7e1b5119739adb990f5b247 Mon Sep 17 00:00:00 2001 From: Hitesh Raghuvanshi Date: Fri, 26 Apr 2024 14:16:03 +0530 Subject: [PATCH 1/4] Adding create and delete apis for group namespace filters Added create and delete apis for namespace filters for group level external audit event destinations Changelog: added EE: true --- doc/administration/audit_event_types.md | 2 + doc/api/graphql/reference/index.md | 66 ++++++ ee/app/graphql/ee/types/mutation_type.rb | 4 + .../group/namespace_filters/base.rb | 31 +++ .../group/namespace_filters/create.rb | 74 ++++++ .../group/namespace_filters/delete.rb | 24 ++ .../group/namespace_filter_type.rb | 26 +++ .../group/streaming_destination_type.rb | 4 + .../group/namespace_filter_policy.rb | 9 + .../types/created_group_namespace_filter.yml | 9 + .../types/deleted_group_namespace_filter.yml | 9 + .../group/namespace_filter_type_spec.rb | 12 + .../group/streaming_destination_type_spec.rb | 2 +- .../group/namespace_filters/create_spec.rb | 214 ++++++++++++++++++ .../group/namespace_filters/delete_spec.rb | 81 +++++++ 15 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 ee/app/graphql/mutations/audit_events/group/namespace_filters/base.rb create mode 100644 ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb create mode 100644 ee/app/graphql/mutations/audit_events/group/namespace_filters/delete.rb create mode 100644 ee/app/graphql/types/audit_events/group/namespace_filter_type.rb create mode 100644 ee/app/policies/audit_events/group/namespace_filter_policy.rb create mode 100644 ee/config/audit_events/types/created_group_namespace_filter.yml create mode 100644 ee/config/audit_events/types/deleted_group_namespace_filter.yml create mode 100644 ee/spec/graphql/types/audit_events/group/namespace_filter_type_spec.rb create mode 100644 ee/spec/requests/api/graphql/audit_events/group/namespace_filters/create_spec.rb create mode 100644 ee/spec/requests/api/graphql/audit_events/group/namespace_filters/delete_spec.rb diff --git a/doc/administration/audit_event_types.md b/doc/administration/audit_event_types.md index 07ccf5186ba8af..4fe70de3294704 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 14ade2b0251e7b..aad1ea63935ff0 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1796,6 +1796,53 @@ 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. | +| `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` | [`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 +22382,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 +22408,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 +35159,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 19fee50a185df0..c572bd52d75f8e 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 00000000000000..5c5cdf2c4b5ba1 --- /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 00000000000000..492809ad6ce909 --- /dev/null +++ b/ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb @@ -0,0 +1,74 @@ +# 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 :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::Group::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::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(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 mutually_exclusive_args + [:group_path, :project_path] + 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 00000000000000..2c5fbab530edbb --- /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 00000000000000..0bb30217bff7b5 --- /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 9492bce8e2d9df..71d3daf10cb48d 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 00000000000000..25300b0409ba33 --- /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 00000000000000..ef4a72da0312ea --- /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 00000000000000..aca2b7d62d34d0 --- /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 00000000000000..c37c5fcd47c304 --- /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 1ff3b489050817..d0bef72c400c46 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/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 00000000000000..334fda1c031b85 --- /dev/null +++ b/ee/spec/requests/api/graphql/audit_events/group/namespace_filters/create_spec.rb @@ -0,0 +1,214 @@ +# 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 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 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: '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.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::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.camelize(:lower)}": '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} 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 + 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 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 00000000000000..91b5463efaf655 --- /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 -- GitLab From 1262f40781e748ea3d87395ff7519d99bafac20d Mon Sep 17 00:00:00 2001 From: Hitesh Raghuvanshi Date: Mon, 29 Apr 2024 16:25:21 +0530 Subject: [PATCH 2/4] Added namespace filter policy spec --- .../group/namespace_filter_policy_spec.rb | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 ee/spec/policies/audit_events/group/namespace_filter_policy_spec.rb 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 00000000000000..32c4a837aca4b6 --- /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 -- GitLab From 308754a96080b94d7fa9eba867062d0d83bcd7af Mon Sep 17 00:00:00 2001 From: Hitesh Raghuvanshi Date: Thu, 2 May 2024 17:52:10 +0530 Subject: [PATCH 3/4] Replaced namespace_path with group_path --- .../group/namespace_filters/create.rb | 44 +++++----------- .../group/namespace_filters/create_spec.rb | 51 +++++++------------ 2 files changed, 30 insertions(+), 65 deletions(-) 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 index 492809ad6ce909..68d60bb442d3c7 100644 --- a/ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb +++ b/ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb @@ -11,37 +11,22 @@ class Create < Base required: true, description: 'Destination ID.' - argument :group_path, GraphQL::Types::ID, + argument :namespace_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.' + description: 'Full path of the namespace.' field :namespace_filter, ::Types::AuditEvents::Group::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]) - + 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 @@ -52,20 +37,17 @@ def find_object(destination_id) expected_type: ::AuditEvents::Group::ExternalStreamingDestination) 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 namespace(namespace_path) + namespace = Routable.find_by_full_path(namespace_path) - def mutually_exclusive_args - [:group_path, :project_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 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 index 334fda1c031b85..ef212c5424671b 100644 --- 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 @@ -13,7 +13,7 @@ subject { post_graphql_mutation(mutation, current_user: current_user) } - shared_examples 'does not create namespace filter' do + shared_examples 'does not create any namespace filter' do it do expect(::Gitlab::Audit::Auditor).not_to receive(:audit) @@ -34,12 +34,12 @@ group.add_owner(current_user) end - shared_examples 'creation of namespace filters with one path' do + 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, - "#{namespace_path.camelize(:lower)}": namespace.full_path + namespacePath: namespace.full_path } end @@ -93,7 +93,7 @@ let(:input) do { destinationId: destination.to_gid, - "#{namespace_path.camelize(:lower)}": other_namespace.full_path + namespace_path: other_namespace.full_path } end @@ -113,7 +113,7 @@ let(:input) do { destinationId: destination.to_gid, - "#{namespace_path.camelize(:lower)}": 'invalid_path' + namespace_path: 'invalid_path' } end @@ -122,56 +122,39 @@ expect { subject }.not_to change { AuditEvents::Group::NamespaceFilter.count } - expect(graphql_errors).to include(a_hash_including('message' => "#{namespace_path} is invalid")) + 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 with one path' do + it_behaves_like 'creation of namespace filters' 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 + 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) } - 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) } - + context 'when namespace_path is invalid' do 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 + namespace_path: 'invalid_path' } end - let(:error_message) { 'one and only one of groupPath or projectPath is required' } + let(:error_message) { 'namespace_path should be of group or project only.' } - it_behaves_like 'does not create namespace filter' + it_behaves_like 'does not create any namespace filter' end end @@ -184,13 +167,13 @@ let(:input) do { destinationId: destination.to_gid, - groupPath: namespace_group.full_path + namespacePath: namespace_group.full_path } end let(:error_message) { ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR } - it_behaves_like 'does not create namespace filter' + it_behaves_like 'does not create any namespace filter' end end @@ -203,12 +186,12 @@ let(:input) do { destinationId: destination.to_gid, - groupPath: namespace_group.full_path + namespacePath: namespace_group.full_path } end let(:error_message) { ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR } - it_behaves_like 'does not create namespace filter' + it_behaves_like 'does not create any namespace filter' end end -- GitLab From ec8fe411b7c98d432a7b84a7891b85b6167efc74 Mon Sep 17 00:00:00 2001 From: Hitesh Raghuvanshi Date: Thu, 2 May 2024 17:58:43 +0530 Subject: [PATCH 4/4] Updated graphql docs and graphql type --- doc/api/graphql/reference/index.md | 3 +-- .../mutations/audit_events/group/namespace_filters/create.rb | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index aad1ea63935ff0..156d2ec6aa6638 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1810,8 +1810,7 @@ Input type: `AuditEventsGroupDestinationNamespaceFilterCreateInput` | ---- | ---- | ----------- | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `destinationId` | [`AuditEventsGroupExternalStreamingDestinationID!`](#auditeventsgroupexternalstreamingdestinationid) | Destination ID. | -| `groupPath` | [`ID`](#id) | Full path of the group. | -| `projectPath` | [`ID`](#id) | Full path of the project. | +| `namespacePath` | [`String`](#string) | Full path of the namespace(only project or group). | #### Fields 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 index 68d60bb442d3c7..d246435119c79d 100644 --- a/ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb +++ b/ee/app/graphql/mutations/audit_events/group/namespace_filters/create.rb @@ -11,9 +11,9 @@ class Create < Base required: true, description: 'Destination ID.' - argument :namespace_path, GraphQL::Types::ID, + argument :namespace_path, GraphQL::Types::String, required: false, - description: 'Full path of the namespace.' + description: 'Full path of the namespace(only project or group).' field :namespace_filter, ::Types::AuditEvents::Group::NamespaceFilterType, null: true, -- GitLab