diff --git a/doc/administration/audit_event_streaming/audit_event_types.md b/doc/administration/audit_event_streaming/audit_event_types.md
index eea570f65940c35cb96b958e3608a261e9357f16..3b2ae09846930001c2e36cebf2c3a8487fbcee8b 100644
--- a/doc/administration/audit_event_streaming/audit_event_types.md
+++ b/doc/administration/audit_event_streaming/audit_event_types.md
@@ -37,6 +37,7 @@ Audit event types belong to the following product categories.
| Name | Description | Saved to database | Streamed | Introduced in |
|:-----|:------------|:------------------|:---------|:--------------|
| [`amazon_s3_configuration_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132443) | Triggered when Amazon S3 configuration for audit events streaming is created| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.5](https://gitlab.com/gitlab-org/gitlab/-/issues/423229) |
+| [`amazon_s3_configuration_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133691) | Triggered when Amazon S3 configuration for audit events streaming is updated.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.5](https://gitlab.com/gitlab-org/gitlab/-/issues/423229) |
| [`audit_events_streaming_headers_create`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/92068) | Triggered when a streaming header for audit events is created| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.3](https://gitlab.com/gitlab-org/gitlab/-/issues/366350) |
| [`audit_events_streaming_headers_destroy`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/92068) | Triggered when a streaming header for audit events is deleted| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.3](https://gitlab.com/gitlab-org/gitlab/-/issues/366350) |
| [`audit_events_streaming_instance_headers_create`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125870) | Triggered when a streaming header for instance level external audit event destination is created| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.3](https://gitlab.com/gitlab-org/gitlab/-/issues/417433) |
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 3d0e9c113d71492871cbad7c72ae5a4d11a0ebaa..25fc7791cf533a48ace80abdba8c7e9172258663 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1298,6 +1298,30 @@ Input type: `AmazonS3ConfigurationCreateInput`
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+### `Mutation.amazonS3ConfigurationUpdate`
+
+Input type: `AmazonS3ConfigurationUpdateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `accessKeyXid` | [`String`](#string) | Access key ID of the Amazon S3 account. |
+| `awsRegion` | [`String`](#string) | AWS region where the bucket is created. |
+| `bucketName` | [`String`](#string) | Name of the bucket where the audit events would be logged. |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `id` | [`AuditEventsAmazonS3ConfigurationID!`](#auditeventsamazons3configurationid) | ID of the Amazon S3 configuration to update. |
+| `name` | [`String`](#string) | Destination name. |
+| `secretAccessKey` | [`String`](#string) | Secret access key of the Amazon S3 account. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `amazonS3Configuration` | [`AmazonS3ConfigurationType`](#amazons3configurationtype) | Updated Amazon S3 configuration. |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+
### `Mutation.approveDeployment`
Input type: `ApproveDeploymentInput`
@@ -29877,6 +29901,12 @@ A `AppSecFuzzingCoverageCorpusID` is a global ID. It is encoded as a string.
An example `AppSecFuzzingCoverageCorpusID` is: `"gid://gitlab/AppSec::Fuzzing::Coverage::Corpus/1"`.
+### `AuditEventsAmazonS3ConfigurationID`
+
+A `AuditEventsAmazonS3ConfigurationID` is a global ID. It is encoded as a string.
+
+An example `AuditEventsAmazonS3ConfigurationID` is: `"gid://gitlab/AuditEvents::AmazonS3Configuration/1"`.
+
### `AuditEventsExternalAuditEventDestinationID`
A `AuditEventsExternalAuditEventDestinationID` 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 f9c0ea716479c1b698f62775c819c722e44e5215..f086bc24430ef1ae6782c4506a5c7b2a5ab41c2c 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -124,6 +124,7 @@ module MutationType
mount_mutation ::Mutations::AuditEvents::GoogleCloudLoggingConfigurations::Destroy
mount_mutation ::Mutations::AuditEvents::GoogleCloudLoggingConfigurations::Update
mount_mutation ::Mutations::AuditEvents::AmazonS3Configurations::Create
+ mount_mutation ::Mutations::AuditEvents::AmazonS3Configurations::Update
mount_mutation ::Mutations::AuditEvents::Instance::GoogleCloudLoggingConfigurations::Create
mount_mutation ::Mutations::Forecasting::BuildForecast, alpha: { milestone: '16.0' }
mount_mutation ::Mutations::AuditEvents::Streaming::InstanceHeaders::Create
diff --git a/ee/app/graphql/mutations/audit_events/amazon_s3_configurations/update.rb b/ee/app/graphql/mutations/audit_events/amazon_s3_configurations/update.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8a19466e71a0d3aed1ccbffd48a1f0aba0e4f15e
--- /dev/null
+++ b/ee/app/graphql/mutations/audit_events/amazon_s3_configurations/update.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AuditEvents
+ module AmazonS3Configurations
+ class Update < Base
+ graphql_name 'AmazonS3ConfigurationUpdate'
+
+ include ::Audit::Changes
+
+ UPDATE_EVENT_NAME = 'amazon_s3_configuration_updated'
+ AUDIT_EVENT_COLUMNS = [:access_key_xid, :secret_access_key, :bucket_name, :aws_region, :name].freeze
+
+ authorize :admin_external_audit_events
+
+ argument :id, ::Types::GlobalIDType[::AuditEvents::AmazonS3Configuration],
+ required: true,
+ description: 'ID of the Amazon S3 configuration to update.'
+
+ argument :name, GraphQL::Types::String,
+ required: false,
+ description: 'Destination name.'
+
+ argument :access_key_xid, GraphQL::Types::String,
+ required: false,
+ description: 'Access key ID of the Amazon S3 account.'
+
+ argument :secret_access_key, GraphQL::Types::String,
+ required: false,
+ description: 'Secret access key of the Amazon S3 account.'
+
+ argument :bucket_name, GraphQL::Types::String,
+ required: false,
+ description: 'Name of the bucket where the audit events would be logged.'
+
+ argument :aws_region, GraphQL::Types::String,
+ required: false,
+ description: 'AWS region where the bucket is created.'
+
+ field :amazon_s3_configuration, ::Types::AuditEvents::AmazonS3ConfigurationType,
+ null: true,
+ description: 'Updated Amazon S3 configuration.'
+
+ def resolve(id:, access_key_xid: nil, secret_access_key: nil, bucket_name: nil, aws_region: nil, name: nil)
+ config = authorized_find!(id)
+ config_attributes = {
+ access_key_xid: access_key_xid,
+ secret_access_key: secret_access_key,
+ bucket_name: bucket_name,
+ aws_region: aws_region,
+ name: name
+ }.compact
+
+ if config.update(config_attributes)
+ audit_update(config)
+ { amazon_s3_configuration: config, errors: [] }
+ else
+ { amazon_s3_configuration: nil, errors: Array(config.errors) }
+ end
+ end
+
+ private
+
+ def audit_update(config)
+ AUDIT_EVENT_COLUMNS.each do |column|
+ audit_changes(
+ column,
+ as: column.to_s,
+ entity: config.group,
+ model: config,
+ event_type: UPDATE_EVENT_NAME
+ )
+ end
+ end
+
+ def find_object(config_gid)
+ GitlabSchema.object_from_id(config_gid, expected_type: ::AuditEvents::AmazonS3Configuration).sync
+ end
+ end
+ end
+ end
+end
diff --git a/ee/config/audit_events/types/amazon_s3_configuration_updated.yml b/ee/config/audit_events/types/amazon_s3_configuration_updated.yml
new file mode 100644
index 0000000000000000000000000000000000000000..512d07ab4e424dbf32c64355a5321733fb8d6c3c
--- /dev/null
+++ b/ee/config/audit_events/types/amazon_s3_configuration_updated.yml
@@ -0,0 +1,9 @@
+---
+name: amazon_s3_configuration_updated
+description: Triggered when Amazon S3 configuration for audit events streaming is updated.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/423229
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133691
+feature_category: audit_events
+milestone: '16.5'
+saved_to_database: true
+streamed: true
diff --git a/ee/spec/requests/api/graphql/mutations/audit_events/amazon_s3_configurations/update_spec.rb b/ee/spec/requests/api/graphql/mutations/audit_events/amazon_s3_configurations/update_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7e29514846c0d5c3cab8a2602addce3e0aa35ba4
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/audit_events/amazon_s3_configurations/update_spec.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Update Amazon S3 configuration', feature_category: :audit_events do
+ include GraphqlHelpers
+
+ let_it_be_with_reload(:config) { create(:amazon_s3_configuration) }
+ let_it_be(:group) { config.group }
+ # let_it_be(:owner) { create(:user) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:updated_access_key_xid) { 'AKIA1234RANDOM5678' }
+ let_it_be(:updated_secret_access_key) { 'TEST/SECRET/XYZ/PQR' }
+ let_it_be(:updated_bucket_name) { 'test-rspec-bucket' }
+ let_it_be(:updated_aws_region) { 'us-east-2' }
+ let_it_be(:updated_destination_name) { 'updated_destination_name' }
+ let_it_be(:config_gid) { global_id_of(config) }
+
+ let(:mutation) { graphql_mutation(:amazon_s3_configuration_update, input) }
+ let(:mutation_response) { graphql_mutation_response(:amazon_s3_configuration_update) }
+
+ let(:input) do
+ {
+ id: config_gid,
+ accessKeyXid: updated_access_key_xid,
+ secretAccessKey: updated_secret_access_key,
+ bucketName: updated_bucket_name,
+ awsRegion: updated_aws_region,
+ name: updated_destination_name
+ }
+ end
+
+ subject(:mutate) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ shared_examples 'a mutation that does not update the configuration' do
+ it 'does not update the configuration' do
+ expect { mutate }.not_to change { config.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 configuration' do
+ mutate
+
+ config.reload
+
+ expect(config.access_key_xid).to eq(updated_access_key_xid)
+ expect(config.secret_access_key).to eq(updated_secret_access_key)
+ expect(config.bucket_name).to eq(updated_bucket_name)
+ expect(config.aws_region).to eq(updated_aws_region)
+ expect(config.name).to eq(updated_destination_name)
+ end
+
+ it 'audits the update' do
+ Mutations::AuditEvents::AmazonS3Configurations::Update::AUDIT_EVENT_COLUMNS.each do |column|
+ message = if column == :secret_access_key
+ "Changed #{column}"
+ else
+ "Changed #{column} from #{config[column]} to #{input[column.to_s.camelize(:lower).to_sym]}"
+ end
+
+ expected_hash = {
+ name: Mutations::AuditEvents::AmazonS3Configurations::Update::UPDATE_EVENT_NAME,
+ author: current_user,
+ scope: group,
+ target: config,
+ message: message
+ }
+
+ expect(Gitlab::Audit::Auditor).to receive(:audit).once.ordered.with(hash_including(expected_hash))
+ end
+
+ subject
+ end
+
+ context 'when the fields are updated with existing values' do
+ let(:input) do
+ {
+ id: config_gid,
+ accessKeyXid: config.access_key_xid,
+ name: config.name
+ }
+ end
+
+ it 'does not audit the event' do
+ expect(Gitlab::Audit::Auditor).not_to receive(:audit)
+
+ subject
+ end
+ end
+
+ context 'when no fields are provided for update' do
+ let(:input) do
+ {
+ id: config_gid
+ }
+ end
+
+ it_behaves_like 'a mutation that does not update the configuration'
+ end
+
+ context 'when there is error while updating' do
+ before do
+ allow_next_instance_of(Mutations::AuditEvents::AmazonS3Configurations::Update) do |mutation|
+ allow(mutation).to receive(:authorized_find!).with(config_gid).and_return(config)
+ end
+
+ allow(config).to receive(:update).and_return(false)
+
+ errors = ActiveModel::Errors.new(config).tap { |e| e.add(:base, 'error message') }
+ allow(config).to receive(:errors).and_return(errors)
+ end
+
+ it 'does not update the configuration and returns the error' do
+ mutate
+
+ expect(mutation_response).to include(
+ 'amazonS3Configuration' => 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 configuration'
+ 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 configuration'
+ 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 configuration'
+ 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 configuration'
+ end
+end