From f83c885d9dac659e3d1f55744c5f5d602bb3dde1 Mon Sep 17 00:00:00 2001 From: Max Woolf Date: Wed, 27 Oct 2021 08:52:49 +0100 Subject: [PATCH] Enable audit event streaming Adds functionality to stream audit events to external destinations. Adds documentation. Feature is behind a default-off flag. Applying technical writing suggestions --- app/services/audit_event_service.rb | 5 + config/sidekiq_queues.yml | 2 + doc/administration/audit_event_streaming.md | 70 ++++++++++++ doc/administration/audit_events.md | 5 +- ee/app/models/ee/audit_event.rb | 12 +++ ee/app/services/ee/audit_event_service.rb | 7 ++ ee/app/workers/all_queues.yml | 9 ++ .../audit_event_streaming_worker.rb | 45 ++++++++ ee/lib/gitlab/audit/null_entity.rb | 3 + ee/spec/models/audit_event_spec.rb | 62 +++++++++++ .../audit_event_streaming_worker_spec.rb | 101 ++++++++++++++++++ 11 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 doc/administration/audit_event_streaming.md create mode 100644 ee/app/workers/audit_events/audit_event_streaming_worker.rb create mode 100644 ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index 558798c830d98e..563d4a924fcf96 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -119,6 +119,10 @@ def log_security_event_to_database event end + def stream_event_to_external_destinations(_event) + # Defined in EE + end + def log_authentication_event_to_database return unless Gitlab::Database.read_write? && authentication_event? @@ -130,6 +134,7 @@ def log_authentication_event_to_database def save_or_track(event) event.save! + stream_event_to_external_destinations(event) rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, audit_event_type: event.class.to_s) end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 1e43dc9d3c6b22..1a229f9367ed1d 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -39,6 +39,8 @@ - 1 - - approve_blocked_pending_approval_users - 1 +- - audit_events_audit_event_streaming + - 1 - - authorized_keys - 2 - - authorized_project_update diff --git a/doc/administration/audit_event_streaming.md b/doc/administration/audit_event_streaming.md new file mode 100644 index 00000000000000..cee6cddbbf0ab9 --- /dev/null +++ b/doc/administration/audit_event_streaming.md @@ -0,0 +1,70 @@ +--- +stage: Manage +group: Compliance +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +--- + +# Audit event streaming **(ULTIMATE)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/332747) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `ff_external_audit_events_namespace`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available per group, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `ff_external_audit_events_namespace`. On GitLab.com, this feature is not available. +You should not use this feature for production environments. + +Event streaming allows owners of top-level groups to set an HTTP endpoint to receive **all** audit events about the group, and its +subgroups and projects. + +Top-level group owners can manage their audit logs in third-party systems such as Splunk, using the Splunk +[HTTP Event Collector](https://docs.splunk.com/Documentation/Splunk/8.2.2/Data/UsetheHTTPEventCollector). Any service that can receive +structured JSON data can be used as the endpoint. + +NOTE: +GitLab can stream a single event more than once to the same destination. Use the `id` key in the payload to deduplicate incoming data. + +## Add a new event streaming destination + +WARNING: +Event streaming destinations will receive **all** audit event data, which could include sensitive information. Make sure you trust the destination endpoint. + +To enable event streaming, a group owner must add a new event streaming destination using the `externalAuditEventDestinationCreate` mutation +in the GraphQL API. + +```graphql +mutation { + externalAuditEventDestinationCreate(input: { destinationUrl: "https://mydomain.io/endpoint/ingest", groupPath: "my-group" } ) { + errors + externalAuditEventDestination { + destinationUrl + group { + name + } + } + } +} +``` + +Event streaming is enabled if: + +- The returned `errors` object is empty. +- The API responds with `200 OK`. + +## List currently enabled streaming destinations + +Group owners can view a list of event streaming destinations at any time using the `externalAuditEventDesinations` query type. + +```graphql +query { + group(fullPath: "my-group") { + id + externalAuditEventDestinations { + nodes { + destinationUrl + id + } + } + } +} +``` + +If the resulting list is empty, then audit event streaming is not enabled for that group. diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md index 477de80bd22443..9980403d6379ce 100644 --- a/doc/administration/audit_events.md +++ b/doc/administration/audit_events.md @@ -12,7 +12,10 @@ on a [paid plan](https://about.gitlab.com/pricing/). GitLab system administrators can also take advantage of the logs located on the file system. See [the logs system documentation](logs.md#audit_jsonlog) for more details. -You can generate an [Audit report](audit_reports.md) of audit events. +You can: + +- Generate an [audit report](audit_reports.md) of audit events. +- [Stream audit events](audit_event_streaming.md) to an external endpoint. ## Overview diff --git a/ee/app/models/ee/audit_event.rb b/ee/app/models/ee/audit_event.rb index 8c800d38974495..fa79e59260640c 100644 --- a/ee/app/models/ee/audit_event.rb +++ b/ee/app/models/ee/audit_event.rb @@ -54,6 +54,18 @@ def lazy_entity end end + def stream_to_external_destinations + return if entity.nil? + return unless ::Feature.enabled?(:ff_external_audit_events_namespace, entity) + return unless entity.licensed_feature_available?(:external_audit_events) + + AuditEvents::AuditEventStreamingWorker.perform_async(id) + end + + def entity_is_group_or_project? + %w(Group Project).include?(entity_type) + end + private def truncate_fields diff --git a/ee/app/services/ee/audit_event_service.rb b/ee/app/services/ee/audit_event_service.rb index cd3b36e973d57d..f240679dae8f33 100644 --- a/ee/app/services/ee/audit_event_service.rb +++ b/ee/app/services/ee/audit_event_service.rb @@ -229,6 +229,13 @@ def respond_to?(method, include_private = false) private + override :stream_event_to_external_destinations + def stream_event_to_external_destinations(event) + return if event.is_a?(AuthenticationEvent) + + event.stream_to_external_destinations if event.entity_is_group_or_project? + end + override :base_payload def base_payload super.tap do |payload| diff --git a/ee/app/workers/all_queues.yml b/ee/app/workers/all_queues.yml index e3852e1cec9d7c..0d530f0a92a002 100644 --- a/ee/app/workers/all_queues.yml +++ b/ee/app/workers/all_queues.yml @@ -885,6 +885,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: audit_events_audit_event_streaming + :worker_name: AuditEvents::AuditEventStreamingWorker + :feature_category: :audit_events + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: ci_batch_reset_minutes :worker_name: Ci::BatchResetMinutesWorker :feature_category: :continuous_integration diff --git a/ee/app/workers/audit_events/audit_event_streaming_worker.rb b/ee/app/workers/audit_events/audit_event_streaming_worker.rb new file mode 100644 index 00000000000000..8a20bf111cef0a --- /dev/null +++ b/ee/app/workers/audit_events/audit_event_streaming_worker.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module AuditEvents + class AuditEventStreamingWorker + include ApplicationWorker + + REQUEST_BODY_SIZE_LIMIT = 25.megabytes + + # Audit Events contains a unique ID so the ingesting system should + # attempt to deduplicate based on this to allow this job to be idempotent. + idempotent! + worker_has_external_dependencies! + data_consistency :always + feature_category :audit_events + + def perform(audit_event_id) + audit_event = AuditEvent.find(audit_event_id) + group = group_entity(audit_event) + + return if group.nil? # Do nothing if the event can't be resolved to a single group. + return unless ::Feature.enabled?(:ff_external_audit_events_namespace, group) + return unless group.licensed_feature_available?(:external_audit_events) + + group.external_audit_event_destinations.each do |destination| + Gitlab::HTTP.post(destination.destination_url, + body: Gitlab::Json::LimitedEncoder.encode(audit_event.as_json, limit: REQUEST_BODY_SIZE_LIMIT), use_read_total_timeout: true) + end + end + + private + + def group_entity(audit_event) + case audit_event.entity_type + when 'Group' + audit_event.entity + when 'Project' + # Project events should be sent to the root ancestor's streaming destinations + # Projects without a group root ancestor should be ignored. + audit_event.entity.group&.root_ancestor + else + nil + end + end + end +end diff --git a/ee/lib/gitlab/audit/null_entity.rb b/ee/lib/gitlab/audit/null_entity.rb index 4e21fbe599009e..3fcd3c1d58be13 100644 --- a/ee/lib/gitlab/audit/null_entity.rb +++ b/ee/lib/gitlab/audit/null_entity.rb @@ -3,6 +3,9 @@ module Gitlab module Audit class NullEntity + def nil? + true + end end end end diff --git a/ee/spec/models/audit_event_spec.rb b/ee/spec/models/audit_event_spec.rb index 5386f5c9682dbe..5a2a1b982a3537 100644 --- a/ee/spec/models/audit_event_spec.rb +++ b/ee/spec/models/audit_event_spec.rb @@ -78,6 +78,46 @@ end end + describe '#stream_to_external_destinations' do + let_it_be(:event) { create(:audit_event, :group_event) } + + context 'feature is licensed' do + before do + stub_licensed_features(external_audit_events: true) + end + + it 'enqueues one worker' do + expect(AuditEvents::AuditEventStreamingWorker).to receive(:perform_async).once + + event.stream_to_external_destinations + end + + context 'feature is disabled' do + before do + stub_feature_flags(ff_external_audit_events_namespace: false) + end + + it 'enqueues no workers' do + expect(AuditEvents::AuditEventStreamingWorker).not_to receive(:perform_async) + + event.stream_to_external_destinations + end + end + end + + context 'feature is unlicensed' do + before do + stub_licensed_features(external_audit_events: false) + end + + it 'enqueues no workers' do + expect(AuditEvents::AuditEventStreamingWorker).not_to receive(:perform_async) + + event.stream_to_external_destinations + end + end + end + describe '.by_entity' do let_it_be(:project_event_1) { create(:project_audit_event) } let_it_be(:project_event_2) { create(:project_audit_event) } @@ -285,4 +325,26 @@ end end end + + describe 'entity_is_group_or_project?' do + subject { event.entity_is_group_or_project? } + + context 'when entity is a Group' do + let_it_be(:event) { create(:group_audit_event) } + + it { is_expected.to be true } + end + + context 'when entity is a Project' do + let_it_be(:event) { create(:project_audit_event) } + + it { is_expected.to be true } + end + + context 'when entity is an Epic' do + let_it_be(:event) { create(:audit_event, target_type: 'Epic') } + + it { is_expected.to be false } + end + end end diff --git a/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb b/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb new file mode 100644 index 00000000000000..b912ff4905dd2e --- /dev/null +++ b/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AuditEvents::AuditEventStreamingWorker do + let(:worker) { described_class.new } + + before do + stub_licensed_features(external_audit_events: true) + end + + shared_examples 'a successful audit event stream' do + subject { worker.perform(event.id) } + + context 'when the group has no destinations' do + it 'makes no HTTP calls' do + expect(Gitlab::HTTP).not_to receive(:post) + + subject + end + end + + context 'when the group has a destination' do + before do + group.external_audit_event_destinations.create!(destination_url: 'http://example.com') + end + + it 'makes one HTTP call' do + expect(Gitlab::HTTP).to receive(:post).once + + subject + end + end + + context 'when the group has several destinations' do + before do + group.external_audit_event_destinations.create!(destination_url: 'http://example.com') + group.external_audit_event_destinations.create!(destination_url: 'http://example1.com') + group.external_audit_event_destinations.create!(destination_url: 'http://example2.com') + end + + it 'makes the correct number of HTTP calls' do + expect(Gitlab::HTTP).to receive(:post).exactly(3).times + + subject + end + + context 'when feature is disabled' do + before do + stub_feature_flags(ff_external_audit_events_namespace: false) + end + + it 'makes no HTTP calls' do + expect(Gitlab::HTTP).not_to receive(:post) + + subject + end + end + + context 'when feature is unlicensed' do + before do + stub_licensed_features(external_audit_events: false) + end + + it 'makes no HTTP calls' do + expect(Gitlab::HTTP).not_to receive(:post) + + subject + end + end + end + end + + describe "#perform" do + context 'when the entity type is a group' do + it_behaves_like 'a successful audit event stream' do + let_it_be(:event) { create(:audit_event, :group_event) } + + let(:group) { event.entity } + end + end + + context 'when the entity type is a project that belongs to a group' do + it_behaves_like 'a successful audit event stream' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:event) { create(:audit_event, :project_event, target_project: project) } + end + end + + context 'when the entity type is a project at a root namespace level' do + let_it_be(:event) { create(:audit_event, :project_event) } + + it 'makes no HTTP calls' do + expect(Gitlab::HTTP).not_to receive(:post) + + worker.perform(event.id) + end + end + end +end -- GitLab