From 08c371351f3f571702a03152ad5e90e7a023c29d Mon Sep 17 00:00:00 2001 From: Hitesh Raghuvanshi Date: Mon, 8 Apr 2024 12:52:32 +0530 Subject: [PATCH 1/2] Added update api for group audit event destinations Added GraphQL Update api for updating top-level group audit event streaming consolidated destinations Changelog: added EE: true --- doc/administration/audit_event_types.md | 1 + doc/api/graphql/reference/index.md | 33 ++++ ee/app/graphql/ee/types/mutation_type.rb | 2 + .../base.rb | 2 + .../create.rb | 2 - .../update.rb | 77 ++++++++ ...roup_audit_event_streaming_destination.yml | 9 + .../update_spec.rb | 177 ++++++++++++++++++ 8 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/update.rb create mode 100644 ee/config/audit_events/types/updated_group_audit_event_streaming_destination.yml create mode 100644 ee/spec/requests/api/graphql/mutations/audit_events/group/audit_event_streaming_destinations/update_spec.rb diff --git a/doc/administration/audit_event_types.md b/doc/administration/audit_event_types.md index 83cda27a0b9f28..48ce1fb6e76f2a 100644 --- a/doc/administration/audit_event_types.md +++ b/doc/administration/audit_event_types.md @@ -77,6 +77,7 @@ Audit event types belong to the following product categories. | [`instance_google_cloud_logging_configuration_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131790) | Triggered when instance level Google Cloud Logging configuration is updated.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.5](https://gitlab.com/gitlab-org/gitlab/-/issues/423039) | Instance | | [`update_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74632) | Event triggered when an external audit event destination is updated| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [14.6](https://gitlab.com/gitlab-org/gitlab/-/issues/344664) | Group | | [`update_instance_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125846) | Event triggered when an instance level external audit event destination is updated| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.2](https://gitlab.com/gitlab-org/gitlab/-/issues/404730) | Instance | +| [`updated_group_audit_event_streaming_destination`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148388) | Event triggered when an external audit event destination for a top-level group is updated.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.11](https://gitlab.com/gitlab-org/gitlab/-/issues/436610) | Group | ### Build artifacts diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 54f0655e7e8534..7e044c0a0e727b 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -4746,6 +4746,33 @@ Input type: `GroupAuditEventStreamingDestinationsCreateInput` | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `externalAuditEventDestination` | [`GroupAuditEventStreamingDestination`](#groupauditeventstreamingdestination) | Destination created. | +### `Mutation.groupAuditEventStreamingDestinationsUpdate` + +DETAILS: +**Introduced** in GitLab 16.11. +**Status**: Experiment. + +Input type: `GroupAuditEventStreamingDestinationsUpdateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `category` | [`String`](#string) | Destination category. | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `config` | [`JSON`](#json) | Destination config. | +| `id` | [`AuditEventsGroupExternalStreamingDestinationID!`](#auditeventsgroupexternalstreamingdestinationid) | ID of external audit event destination to update. | +| `name` | [`String`](#string) | Destination name. | +| `secretToken` | [`String`](#string) | Secret token. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `externalAuditEventDestination` | [`GroupAuditEventStreamingDestination`](#groupauditeventstreamingdestination) | Updated destination. | + ### `Mutation.groupMemberBulkUpdate` Input type: `GroupMemberBulkUpdateInput` @@ -33855,6 +33882,12 @@ A `AuditEventsGoogleCloudLoggingConfigurationID` is a global ID. It is encoded a An example `AuditEventsGoogleCloudLoggingConfigurationID` is: `"gid://gitlab/AuditEvents::GoogleCloudLoggingConfiguration/1"`. +### `AuditEventsGroupExternalStreamingDestinationID` + +A `AuditEventsGroupExternalStreamingDestinationID` is a global ID. It is encoded as a string. + +An example `AuditEventsGroupExternalStreamingDestinationID` is: `"gid://gitlab/AuditEvents::Group::ExternalStreamingDestination/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 0c0df5c73056b4..a0e24509ae5be5 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -165,6 +165,8 @@ module MutationType mount_mutation ::Mutations::ApprovalProjectRules::Delete, alpha: { milestone: '16.10' } mount_mutation ::Mutations::AuditEvents::Group::AuditEventStreamingDestinations::Create, alpha: { milestone: '16.11' } + mount_mutation ::Mutations::AuditEvents::Group::AuditEventStreamingDestinations::Update, + alpha: { milestone: '16.11' } prepend(Types::DeprecatedMutations) end diff --git a/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/base.rb b/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/base.rb index 05593b46d6590e..ca0f9aafb67641 100644 --- a/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/base.rb +++ b/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/base.rb @@ -5,6 +5,8 @@ module AuditEvents module Group module AuditEventStreamingDestinations class Base < BaseMutation + authorize :admin_external_audit_events + private def audit(destination, action:) diff --git a/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/create.rb b/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/create.rb index 06aecb419d41a0..36e1a25aa743cc 100644 --- a/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/create.rb +++ b/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/create.rb @@ -7,8 +7,6 @@ module AuditEventStreamingDestinations class Create < Base graphql_name 'GroupAuditEventStreamingDestinationsCreate' - authorize :admin_external_audit_events - argument :config, GraphQL::Types::JSON, # rubocop:disable Graphql/JSONType -- Different type of destinations will have different configs required: true, description: 'Destination config.' diff --git a/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/update.rb b/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/update.rb new file mode 100644 index 00000000000000..f780d3ef255448 --- /dev/null +++ b/ee/app/graphql/mutations/audit_events/group/audit_event_streaming_destinations/update.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Mutations + module AuditEvents + module Group + module AuditEventStreamingDestinations + class Update < Base + graphql_name 'GroupAuditEventStreamingDestinationsUpdate' + + include ::Audit::Changes + + UPDATE_EVENT_NAME = 'updated_group_audit_event_streaming_destination' + AUDIT_EVENT_COLUMNS = [:config, :name, :category, :secret_token].freeze + + argument :id, ::Types::GlobalIDType[::AuditEvents::Group::ExternalStreamingDestination], + required: true, + description: 'ID of external audit event destination to update.' + + argument :config, GraphQL::Types::JSON, # rubocop:disable Graphql/JSONType -- Different type of destinations will have different configs + required: false, + description: 'Destination config.' + + argument :name, GraphQL::Types::String, + required: false, + description: 'Destination name.' + + argument :category, GraphQL::Types::String, + required: false, + description: 'Destination category.' + + argument :secret_token, GraphQL::Types::String, + required: false, + description: 'Secret token.' + + field :external_audit_event_destination, ::Types::AuditEvents::Group::StreamingDestinationType, + null: true, + description: 'Updated destination.' + + def resolve(id:, config: nil, name: nil, category: nil, secret_token: nil) + destination = authorized_find!(id: id) + + destination_attributes = { + config: config, + name: name, + category: category, + secret_token: secret_token + }.compact + + if destination.update(destination_attributes) + audit_update(destination) + { + external_audit_event_destination: destination, + errors: [] + } + else + { external_audit_event_destination: nil, errors: Array(destination.errors) } + end + end + + private + + def audit_update(destination) + AUDIT_EVENT_COLUMNS.each do |column| + audit_changes( + column, + as: column.to_s, + entity: destination.group, + model: destination, + event_type: UPDATE_EVENT_NAME + ) + end + end + end + end + end + end +end diff --git a/ee/config/audit_events/types/updated_group_audit_event_streaming_destination.yml b/ee/config/audit_events/types/updated_group_audit_event_streaming_destination.yml new file mode 100644 index 00000000000000..195cd914b95253 --- /dev/null +++ b/ee/config/audit_events/types/updated_group_audit_event_streaming_destination.yml @@ -0,0 +1,9 @@ +name: updated_group_audit_event_streaming_destination +description: Event triggered when an external audit event destination for a top-level group is updated. +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/436610 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148388 +feature_category: audit_events +milestone: "16.11" +saved_to_database: true +streamed: true +scope: [Group] diff --git a/ee/spec/requests/api/graphql/mutations/audit_events/group/audit_event_streaming_destinations/update_spec.rb b/ee/spec/requests/api/graphql/mutations/audit_events/group/audit_event_streaming_destinations/update_spec.rb new file mode 100644 index 00000000000000..f9a3d76a6668bc --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/audit_events/group/audit_event_streaming_destinations/update_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Update group level external audit event streaming destination', feature_category: :audit_events do + include GraphqlHelpers + + let_it_be_with_reload(:destination) { create(:audit_events_group_external_streaming_destination) } + let_it_be(:group) { destination.group } + let_it_be(:current_user) { create(:user) } + let_it_be(:updated_config) do + { + "accessKeyXid" => 'AKIA1234RANDOM5678', + "bucketName" => 'test-rspec-bucket', + "awsRegion" => 'us-east-2' + } + end + + let_it_be(:updated_secret_token) { 'TEST/SECRET/XYZ/PQR' } + let_it_be(:updated_category) { 'aws' } + let_it_be(:updated_destination_name) { 'updated_destination_name' } + let_it_be(:destination_gid) { global_id_of(destination) } + + let(:mutation) { graphql_mutation(:group_audit_event_streaming_destinations_update, input) } + let(:mutation_response) { graphql_mutation_response(:group_audit_event_streaming_destinations_update) } + + let(:input) do + { + id: destination_gid, + config: updated_config, + name: updated_destination_name, + category: updated_category, + secret_token: updated_secret_token + } + end + + subject(:mutate) { post_graphql_mutation(mutation, current_user: current_user) } + + shared_examples 'a mutation that does not update the destination' do + it 'does not update the destination' do + expect { mutate }.not_to change { destination.reload.attributes } + end + + it 'does not create audit event' do + expect { mutate }.not_to change { AuditEvent.count } + 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 + + it 'updates the destination' do + mutate + + destination.reload + + expect(destination.config).to eq(updated_config) + expect(destination.name).to eq(updated_destination_name) + expect(destination.category).to eq(updated_category) + expect(destination.secret_token).to eq(updated_secret_token) + end + + it 'audits the update' do + Mutations::AuditEvents::Group::AuditEventStreamingDestinations::Update::AUDIT_EVENT_COLUMNS.each do |column| + message = if column == :secret_token + "Changed #{column}" + else + "Changed #{column} from #{destination[column]} to #{input[column.to_s.camelize(:lower).to_sym]}" + end + + expected_hash = { + name: Mutations::AuditEvents::Group::AuditEventStreamingDestinations::Update::UPDATE_EVENT_NAME, + author: current_user, + scope: group, + target: destination, + message: message + } + + expect(Gitlab::Audit::Auditor).to receive(:audit).once.ordered.with(hash_including(expected_hash)) + end + + mutate + end + + context 'when the fields are updated with existing values' do + let(:input) do + { + id: destination_gid, + config: destination.config, + name: destination.name + } + end + + it 'does not audit the event' do + expect(Gitlab::Audit::Auditor).not_to receive(:audit) + + mutate + end + end + + context 'when no fields are provided for update' do + let(:input) do + { + id: destination_gid + } + end + + it_behaves_like 'a mutation that does not update the destination' + end + + context 'when there is error while updating' do + before do + allow_next_instance_of(Mutations::AuditEvents::Group::AuditEventStreamingDestinations::Update) do |mutation| + allow(mutation).to receive(:authorized_find!).with(id: destination_gid).and_return(destination) + end + + allow(destination).to receive(:update).and_return(false) + + errors = ActiveModel::Errors.new(destination).tap { |e| e.add(:base, 'error message') } + allow(destination).to receive(:errors).and_return(errors) + end + + it 'does not update the destination and returns the error' do + mutate + + expect(mutation_response).to include( + 'externalAuditEventDestination' => nil, + 'errors' => ['error message'] + ) + end + end + end + + context 'when current user is a group maintainer' do + before_all do + group.add_maintainer(current_user) + end + + it_behaves_like 'a mutation on an unauthorized resource' + it_behaves_like 'a mutation that does not update the destination' + end + + context 'when current user is a group developer' do + before_all do + group.add_developer(current_user) + end + + it_behaves_like 'a mutation on an unauthorized resource' + it_behaves_like 'a mutation that does not update the destination' + end + + context 'when current user is a group guest' do + before_all do + group.add_guest(current_user) + end + + it_behaves_like 'a mutation on an unauthorized resource' + it_behaves_like 'a mutation that does not update the destination' + end + end + + context 'when feature is unlicensed' do + before do + stub_licensed_features(external_audit_events: false) + end + + it_behaves_like 'a mutation on an unauthorized resource' + it_behaves_like 'a mutation that does not update the destination' + end +end -- GitLab From 5e4c4459a352854b47475ab6afa548b8b0136338 Mon Sep 17 00:00:00 2001 From: Hitesh Raghuvanshi Date: Thu, 11 Apr 2024 12:44:52 +0530 Subject: [PATCH 2/2] Updated graphql documentation --- doc/api/graphql/reference/index.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index e9ee4d4e901a20..51c5d5d2064143 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -4783,6 +4783,33 @@ Input type: `GroupAuditEventStreamingDestinationsDeleteInput` | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.groupAuditEventStreamingDestinationsUpdate` + +DETAILS: +**Introduced** in GitLab 16.11. +**Status**: Experiment. + +Input type: `GroupAuditEventStreamingDestinationsUpdateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `category` | [`String`](#string) | Destination category. | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `config` | [`JSON`](#json) | Destination config. | +| `id` | [`AuditEventsGroupExternalStreamingDestinationID!`](#auditeventsgroupexternalstreamingdestinationid) | ID of external audit event destination to update. | +| `name` | [`String`](#string) | Destination name. | +| `secretToken` | [`String`](#string) | Secret token. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `externalAuditEventDestination` | [`GroupAuditEventStreamingDestination`](#groupauditeventstreamingdestination) | Updated destination. | + ### `Mutation.groupMemberBulkUpdate` Input type: `GroupMemberBulkUpdateInput` -- GitLab