From e6f33020e78b4827f3fdf3ef63968dbb130a119d Mon Sep 17 00:00:00 2001
From: Harsimar Sandhu <hsandhu@gitlab.com>
Date: Wed, 7 Jun 2023 12:59:04 +0000
Subject: [PATCH] Google cloud logging streaming strategy

This commit adds streaming strategy to allow
streaming of audit events to google cloud
logging service

EE: true
Changelog: added
---
 .../google_cloud_logging_configuration.rb     |   8 ++
 .../external_destination_streamer.rb          |   3 +-
 ...ogle_cloud_logging_destination_strategy.rb |  49 +++++++++
 .../external_destination_streamer_spec.rb     |  30 ++++--
 ...cloud_logging_destination_strategy_spec.rb | 101 ++++++++++++++++++
 ...google_cloud_logging_configuration_spec.rb |  15 +++
 lib/google_cloud/authentication.rb            |  20 ++++
 lib/google_cloud/logging_service/logger.rb    |  41 +++++++
 spec/lib/google_cloud/authentication_spec.rb  |  53 +++++++++
 .../logging_service/logger_spec.rb            |  61 +++++++++++
 10 files changed, 372 insertions(+), 9 deletions(-)
 create mode 100644 ee/lib/audit_events/strategies/google_cloud_logging_destination_strategy.rb
 create mode 100644 ee/spec/lib/audit_events/strategies/google_cloud_logging_destination_strategy_spec.rb
 create mode 100644 lib/google_cloud/authentication.rb
 create mode 100644 lib/google_cloud/logging_service/logger.rb
 create mode 100644 spec/lib/google_cloud/authentication_spec.rb
 create mode 100644 spec/lib/google_cloud/logging_service/logger_spec.rb

diff --git a/ee/app/models/audit_events/google_cloud_logging_configuration.rb b/ee/app/models/audit_events/google_cloud_logging_configuration.rb
index 583e0bbd570e..947e03745eb5 100644
--- a/ee/app/models/audit_events/google_cloud_logging_configuration.rb
+++ b/ee/app/models/audit_events/google_cloud_logging_configuration.rb
@@ -42,6 +42,14 @@ class GoogleCloudLoggingConfiguration < ApplicationRecord
 
     validate :root_level_group?
 
+    def allowed_to_stream?(*)
+      true
+    end
+
+    def full_log_path
+      "projects/#{google_project_id_name}/logs/#{log_id_name}"
+    end
+
     private
 
     def root_level_group?
diff --git a/ee/lib/audit_events/external_destination_streamer.rb b/ee/lib/audit_events/external_destination_streamer.rb
index 7a5094abd955..26af0a5f46c3 100644
--- a/ee/lib/audit_events/external_destination_streamer.rb
+++ b/ee/lib/audit_events/external_destination_streamer.rb
@@ -6,7 +6,8 @@ class ExternalDestinationStreamer
 
     STRATEGIES = [
       AuditEvents::Strategies::GroupExternalDestinationStrategy,
-      AuditEvents::Strategies::InstanceExternalDestinationStrategy
+      AuditEvents::Strategies::InstanceExternalDestinationStrategy,
+      AuditEvents::Strategies::GoogleCloudLoggingDestinationStrategy
     ].freeze
 
     def initialize(event_name, audit_event)
diff --git a/ee/lib/audit_events/strategies/google_cloud_logging_destination_strategy.rb b/ee/lib/audit_events/strategies/google_cloud_logging_destination_strategy.rb
new file mode 100644
index 000000000000..56d9874a2e57
--- /dev/null
+++ b/ee/lib/audit_events/strategies/google_cloud_logging_destination_strategy.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module AuditEvents
+  module Strategies
+    class GoogleCloudLoggingDestinationStrategy < ExternalDestinationStrategy
+      def initialize(audit_operation, audit_event)
+        @logger = GoogleCloud::LoggingService::Logger.new
+
+        super(audit_operation, audit_event)
+      end
+
+      def streamable?
+        group = audit_event.root_group_entity
+        return false if group.nil?
+        return false unless group.licensed_feature_available?(:external_audit_events)
+
+        group.google_cloud_logging_configurations.exists?
+      end
+
+      private
+
+      def destinations
+        group = audit_event.root_group_entity
+        group.present? ? group.google_cloud_logging_configurations.to_a : []
+      end
+
+      def track_and_stream(destination)
+        track_audit_event_count
+
+        @logger.log(destination.client_email, destination.private_key, json_payload(destination))
+      end
+
+      def json_payload(destination)
+        { 'entries' => [log_entry(destination)] }.to_json
+      end
+
+      def log_entry(destination)
+        {
+          'logName' => destination.full_log_path,
+          'resource' => {
+            'type' => 'global'
+          },
+          'severity' => 'INFO',
+          'jsonPayload' => ::Gitlab::Json.parse(request_body)
+        }
+      end
+    end
+  end
+end
diff --git a/ee/spec/lib/audit_events/external_destination_streamer_spec.rb b/ee/spec/lib/audit_events/external_destination_streamer_spec.rb
index f2c8847f34aa..b1f641a73d5d 100644
--- a/ee/spec/lib/audit_events/external_destination_streamer_spec.rb
+++ b/ee/spec/lib/audit_events/external_destination_streamer_spec.rb
@@ -5,6 +5,10 @@
 RSpec.describe AuditEvents::ExternalDestinationStreamer, feature_category: :audit_events do
   before do
     stub_licensed_features(external_audit_events: true)
+
+    allow_next_instance_of(::GoogleCloud::Authentication) do |instance|
+      allow(instance).to receive(:generate_access_token).and_return("sample-token")
+    end
   end
 
   describe '#stream_to_destinations' do
@@ -25,10 +29,11 @@
       before do
         create(:external_audit_event_destination, group: group)
         create_list(:instance_external_audit_event_destination, 2)
+        create(:google_cloud_logging_configuration, group: group)
       end
 
-      it 'makes two HTTP calls' do
-        expect(Gitlab::HTTP).to receive(:post).thrice
+      it 'makes correct number of HTTP calls' do
+        expect(Gitlab::HTTP).to receive(:post).exactly(4).times
 
         subject
       end
@@ -45,27 +50,36 @@
       it { is_expected.to be_falsey }
     end
 
+    context 'when all of them are streamable' do
+      before do
+        create(:external_audit_event_destination, group: group)
+        create(:instance_external_audit_event_destination)
+        create(:google_cloud_logging_configuration, group: group)
+      end
+
+      it { is_expected.to be_truthy }
+    end
+
     context 'when atleast one of them is streamable' do
-      context 'when all of them are streamable' do
+      context 'when only group external destination is streamable' do
         before do
           create(:external_audit_event_destination, group: group)
-          create(:instance_external_audit_event_destination)
         end
 
         it { is_expected.to be_truthy }
       end
 
-      context 'when group is streamable but instance is not' do
+      context 'when only instance destination is streamable' do
         before do
-          create(:external_audit_event_destination, group: group)
+          create(:instance_external_audit_event_destination)
         end
 
         it { is_expected.to be_truthy }
       end
 
-      context 'when instance is streamable but group is not' do
+      context 'when only google cloud logging destination is streamable' do
         before do
-          create(:instance_external_audit_event_destination)
+          create(:google_cloud_logging_configuration, group: group)
         end
 
         it { is_expected.to be_truthy }
diff --git a/ee/spec/lib/audit_events/strategies/google_cloud_logging_destination_strategy_spec.rb b/ee/spec/lib/audit_events/strategies/google_cloud_logging_destination_strategy_spec.rb
new file mode 100644
index 000000000000..e686e4e87eef
--- /dev/null
+++ b/ee/spec/lib/audit_events/strategies/google_cloud_logging_destination_strategy_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AuditEvents::Strategies::GoogleCloudLoggingDestinationStrategy, feature_category: :audit_events do
+  let(:group) { build(:group) }
+  let(:event) { build(:audit_event, :group_event, target_group: group) }
+
+  let_it_be(:event_type) { 'audit_operation' }
+  let_it_be(:request_body) { { key: "value" }.to_json }
+
+  describe '#streamable?' do
+    subject { described_class.new(event_type, event).streamable? }
+
+    context 'when feature is not licensed' do
+      it { is_expected.to be_falsey }
+    end
+
+    context 'when feature is licensed' do
+      before do
+        stub_licensed_features(external_audit_events: true)
+      end
+
+      context 'when event group is nil' do
+        let_it_be(:event) { build(:audit_event) }
+
+        it { is_expected.to be_falsey }
+      end
+
+      context 'when group google cloud logging configurations does not exist' do
+        it { is_expected.to be_falsey }
+      end
+
+      context 'when group google cloud logging configurations exist' do
+        before do
+          create(:google_cloud_logging_configuration, group: group)
+        end
+
+        it { is_expected.to be_truthy }
+      end
+    end
+  end
+
+  describe '#destinations' do
+    subject { described_class.new(event_type, event).send(:destinations) }
+
+    context 'when event group is nil' do
+      let_it_be(:event) { build(:audit_event) }
+
+      it 'returns empty array' do
+        expect(subject).to eq([])
+      end
+    end
+
+    context 'when group google cloud logging configurations exist' do
+      it 'returns all the destinations' do
+        destination1 = create(:google_cloud_logging_configuration, group: group)
+        destination2 = create(:google_cloud_logging_configuration, group: group)
+
+        expect(subject).to match_array([destination1, destination2])
+      end
+    end
+  end
+
+  describe '#track_and_stream' do
+    let(:instance) { described_class.new(event_type, event) }
+    let!(:destination) { create(:google_cloud_logging_configuration, group: group) }
+
+    subject(:track_and_stream) { instance.send(:track_and_stream, destination) }
+
+    context 'when a google cloud logging configuration exists' do
+      let(:expected_log_entry) do
+        [{ entries: {
+          'logName' => destination.full_log_path,
+          'resource' => {
+            'type' => 'global'
+          },
+          'severity' => 'INFO',
+          'jsonPayload' => ::Gitlab::Json.parse(request_body)
+        } }.to_json]
+      end
+
+      before do
+        allow_next_instance_of(GoogleCloud::LoggingService::Logger) do |instance|
+          allow(instance).to receive(:log).and_return(nil)
+        end
+        allow(instance).to receive(:request_body).and_return(request_body)
+      end
+
+      it 'tracks audit event count and calls logger' do
+        expect(instance).to receive(:track_audit_event_count)
+
+        allow_next_instance_of(GoogleCloud::LoggingService::Logger) do |logger|
+          expect(logger).to receive(:log).with(destination.client_email, destination.private_key, expected_log_entry)
+        end
+
+        track_and_stream
+      end
+    end
+  end
+end
diff --git a/ee/spec/models/audit_events/google_cloud_logging_configuration_spec.rb b/ee/spec/models/audit_events/google_cloud_logging_configuration_spec.rb
index 7bb9c9622c4f..e0179413dee7 100644
--- a/ee/spec/models/audit_events/google_cloud_logging_configuration_spec.rb
+++ b/ee/spec/models/audit_events/google_cloud_logging_configuration_spec.rb
@@ -84,6 +84,21 @@
     end
   end
 
+  describe '#allowed_to_stream?' do
+    it 'always returns true' do
+      expect(google_cloud_logging_config.allowed_to_stream?).to eq(true)
+    end
+  end
+
+  describe '#full_log_path' do
+    it 'returns the full log path for the google project' do
+      google_cloud_logging_config.google_project_id_name = "test-project"
+      google_cloud_logging_config.log_id_name = "test-log"
+
+      expect(google_cloud_logging_config.full_log_path).to eq("projects/test-project/logs/test-log")
+    end
+  end
+
   it_behaves_like 'includes Limitable concern' do
     subject { build(:google_cloud_logging_configuration, group: create(:group)) }
   end
diff --git a/lib/google_cloud/authentication.rb b/lib/google_cloud/authentication.rb
new file mode 100644
index 000000000000..68dd0bdcccb4
--- /dev/null
+++ b/lib/google_cloud/authentication.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+  class Authentication
+    def initialize(scope:)
+      @scope = scope
+    end
+
+    def generate_access_token(client_email, private_key)
+      credentials = Google::Auth::ServiceAccountCredentials.make_creds(
+        json_key_io: StringIO.new({ client_email: client_email, private_key: private_key }.to_json),
+        scope: @scope
+      )
+      credentials.fetch_access_token!["access_token"]
+    rescue StandardError => e
+      ::Gitlab::ErrorTracking.track_exception(e, client_email: client_email)
+      nil
+    end
+  end
+end
diff --git a/lib/google_cloud/logging_service/logger.rb b/lib/google_cloud/logging_service/logger.rb
new file mode 100644
index 000000000000..2c6dd6ea7326
--- /dev/null
+++ b/lib/google_cloud/logging_service/logger.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+  module LoggingService
+    class Logger
+      WRITE_URL = "https://logging.googleapis.com/v2/entries:write"
+      SCOPE = "https://www.googleapis.com/auth/logging.write"
+
+      def initialize
+        @auth = GoogleCloud::Authentication.new(scope: SCOPE)
+      end
+
+      def log(client_email, private_key, payload)
+        access_token = @auth.generate_access_token(client_email, private_key)
+
+        return unless access_token
+
+        headers = build_headers(access_token)
+
+        post(WRITE_URL, body: payload, headers: headers)
+      end
+
+      private
+
+      def build_headers(access_token)
+        { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
+      end
+
+      def post(url, body:, headers:)
+        Gitlab::HTTP.post(
+          url,
+          body: body,
+          headers: headers
+        )
+      rescue URI::InvalidURIError => e
+        Gitlab::ErrorTracking.log_exception(e)
+      rescue *Gitlab::HTTP::HTTP_ERRORS
+      end
+    end
+  end
+end
diff --git a/spec/lib/google_cloud/authentication_spec.rb b/spec/lib/google_cloud/authentication_spec.rb
new file mode 100644
index 000000000000..5c7f3e511523
--- /dev/null
+++ b/spec/lib/google_cloud/authentication_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GoogleCloud::Authentication, feature_category: :audit_events do
+  describe '#generate_access_token' do
+    let_it_be(:client_email) { 'test@example.com' }
+    let_it_be(:private_key) { 'private_key' }
+    let_it_be(:scope) { 'https://www.googleapis.com/auth/logging.write' }
+    let_it_be(:json_key_io) { StringIO.new({ client_email: client_email, private_key: private_key }.to_json) }
+
+    let(:service_account_credentials) { instance_double('Google::Auth::ServiceAccountCredentials') }
+
+    subject(:generate_access_token) do
+      described_class.new(scope: scope).generate_access_token(client_email, private_key)
+    end
+
+    before do
+      allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).with(json_key_io: json_key_io,
+        scope: scope).and_return(service_account_credentials)
+      allow(StringIO).to receive(:new).with({ client_email: client_email,
+                                              private_key: private_key }.to_json).and_return(json_key_io)
+    end
+
+    context 'when credentials are valid' do
+      before do
+        allow(service_account_credentials).to receive(:fetch_access_token!).and_return({ 'access_token' => 'token' })
+      end
+
+      it 'calls make_creds with correct parameters' do
+        expect(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).with(json_key_io: json_key_io,
+          scope: scope)
+
+        generate_access_token
+      end
+
+      it 'fetches access token' do
+        expect(generate_access_token).to eq('token')
+      end
+    end
+
+    context 'when an error occurs' do
+      before do
+        allow(service_account_credentials).to receive(:fetch_access_token!).and_raise(StandardError)
+      end
+
+      it 'handles the exception and returns nil' do
+        expect(Gitlab::ErrorTracking).to receive(:track_exception)
+        expect(generate_access_token).to be_nil
+      end
+    end
+  end
+end
diff --git a/spec/lib/google_cloud/logging_service/logger_spec.rb b/spec/lib/google_cloud/logging_service/logger_spec.rb
new file mode 100644
index 000000000000..31f8bb27ec54
--- /dev/null
+++ b/spec/lib/google_cloud/logging_service/logger_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GoogleCloud::LoggingService::Logger, feature_category: :audit_events do
+  let_it_be(:client_email) { 'test@example.com' }
+  let_it_be(:private_key) { 'private_key' }
+  let_it_be(:payload) { [{ logName: 'test-log' }.to_json] }
+  let_it_be(:access_token) { 'access_token' }
+  let_it_be(:expected_headers) do
+    { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
+  end
+
+  subject(:log) { described_class.new.log(client_email, private_key, payload) }
+
+  describe '#log' do
+    context 'when access token is available' do
+      before do
+        allow_next_instance_of(GoogleCloud::Authentication) do |instance|
+          allow(instance).to receive(:generate_access_token).with(client_email, private_key).and_return(access_token)
+        end
+      end
+
+      it 'generates access token and calls Gitlab::HTTP.post with correct parameters' do
+        expect(Gitlab::HTTP).to receive(:post).with(
+          described_class::WRITE_URL,
+          body: payload,
+          headers: expected_headers
+        )
+
+        log
+      end
+
+      context 'when URI::InvalidURIError is raised' do
+        before do
+          allow(Gitlab::HTTP).to receive(:post).and_raise(URI::InvalidURIError)
+        end
+
+        it 'logs the exception' do
+          expect(Gitlab::ErrorTracking).to receive(:log_exception)
+
+          log
+        end
+      end
+    end
+
+    context 'when access token is not available' do
+      let(:access_token) { nil }
+
+      it 'does not call Gitlab::HTTP.post' do
+        allow_next_instance_of(GoogleCloud::Authentication) do |instance|
+          allow(instance).to receive(:generate_access_token).with(client_email, private_key).and_return(access_token)
+        end
+
+        expect(Gitlab::HTTP).not_to receive(:post)
+
+        log
+      end
+    end
+  end
+end
-- 
GitLab