diff --git a/app/controllers/members/mailgun/permanent_failures_controller.rb b/app/controllers/members/mailgun/permanent_failures_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..685faa3469411e56181c6e23f2002cd05c6763f5 --- /dev/null +++ b/app/controllers/members/mailgun/permanent_failures_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Members + module Mailgun + class PermanentFailuresController < ApplicationController + respond_to :json + + skip_before_action :authenticate_user! + skip_before_action :verify_authenticity_token + + before_action :ensure_feature_enabled! + before_action :authenticate_signature! + before_action :validate_invite_email! + + feature_category :authentication_and_authorization + + def create + webhook_processor.execute + + head :ok + end + + private + + def ensure_feature_enabled! + render_406 unless Gitlab::CurrentSettings.mailgun_events_enabled? + end + + def authenticate_signature! + access_denied! unless valid_signature? + end + + def valid_signature? + return false if Gitlab::CurrentSettings.mailgun_signing_key.blank? + + # per this guide: https://documentation.mailgun.com/en/latest/user_manual.html#webhooks + digest = OpenSSL::Digest.new('SHA256') + data = [params.dig(:signature, :timestamp), params.dig(:signature, :token)].join + + hmac_digest = OpenSSL::HMAC.hexdigest(digest, Gitlab::CurrentSettings.mailgun_signing_key, data) + + ActiveSupport::SecurityUtils.secure_compare(params.dig(:signature, :signature), hmac_digest) + end + + def validate_invite_email! + # permanent_failures webhook does not provide a way to filter failures, so we'll get them all on this endpoint + # and we only care about our invite_emails + render_406 unless payload[:tags]&.include?(::Members::Mailgun::INVITE_EMAIL_TAG) + end + + def webhook_processor + ::Members::Mailgun::ProcessWebhookService.new(payload) + end + + def payload + @payload ||= params.permit!['event-data'] + end + + def render_406 + # failure to stop retries per https://documentation.mailgun.com/en/latest/user_manual.html#webhooks + head :not_acceptable + end + end + end +end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index d18700658455ab35ec6e72fbeb0d3b40b384771f..738794a94e78fd6b189d6b866f670026b680b895 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -150,10 +150,10 @@ def invite_email_subject end def invite_email_headers - if Gitlab.dev_env_or_com? + if Gitlab::CurrentSettings.mailgun_events_enabled? { - 'X-Mailgun-Tag' => 'invite_email', - 'X-Mailgun-Variables' => { 'invite_token' => @token }.to_json + 'X-Mailgun-Tag' => ::Members::Mailgun::INVITE_EMAIL_TAG, + 'X-Mailgun-Variables' => { ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY => @token }.to_json } else {} diff --git a/app/services/members/mailgun.rb b/app/services/members/mailgun.rb new file mode 100644 index 0000000000000000000000000000000000000000..43fb5a14ef1794353f66c5ca52c84d9945a1aac9 --- /dev/null +++ b/app/services/members/mailgun.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Members + module Mailgun + INVITE_EMAIL_TAG = 'invite_email' + INVITE_EMAIL_TOKEN_KEY = :invite_token + end +end diff --git a/app/services/members/mailgun/process_webhook_service.rb b/app/services/members/mailgun/process_webhook_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..e359a83ad42344ae2da282397082dd70f16718cf --- /dev/null +++ b/app/services/members/mailgun/process_webhook_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Members + module Mailgun + class ProcessWebhookService + ProcessWebhookServiceError = Class.new(StandardError) + + def initialize(payload) + @payload = payload + end + + def execute + @member = Member.find_by_invite_token(invite_token) + update_member_and_log if member + rescue ProcessWebhookServiceError => e + Gitlab::ErrorTracking.track_exception(e) + end + + private + + attr_reader :payload, :member + + def update_member_and_log + log_update_event if member.update(invite_email_success: false) + end + + def log_update_event + Gitlab::AppLogger.info "UPDATED MEMBER INVITE_EMAIL_SUCCESS: member_id: #{member.id}" + end + + def invite_token + # may want to validate schema in some way using ::JSONSchemer.schema(SCHEMA_PATH).valid?(message) if this + # gets more complex + payload.dig('user-variables', ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY) || + raise(ProcessWebhookServiceError, "Failed to receive #{::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY} in user-variables: #{payload}") + end + end + end +end diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml index 6204f7df5dcc3444da7364da50d5cd84eb3421fb..40b4d5cac6d39b04a2fee32d343650b025f10ca7 100644 --- a/app/views/admin/application_settings/_mailgun.html.haml +++ b/app/views/admin/application_settings/_mailgun.html.haml @@ -1,5 +1,3 @@ -- return unless Feature.enabled?(:mailgun_events_receiver) - - expanded = integration_expanded?('mailgun_') %section.settings.as-mailgun.no-animate#js-mailgun-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/config/feature_flags/development/mailgun_events_receiver.yml b/config/feature_flags/development/mailgun_events_receiver.yml deleted file mode 100644 index 119d8d34f210e8a49396f45394d8dff60eff685f..0000000000000000000000000000000000000000 --- a/config/feature_flags/development/mailgun_events_receiver.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: mailgun_events_receiver -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64249 -rollout_issue_url: -milestone: '14.1' -type: development -group: group::expansion -default_enabled: false diff --git a/config/routes.rb b/config/routes.rb index a4404f9d3a8578ece3917acd195cdf90be6280ef..ff979d7da103f4cd083b50a871fbc91ede8c748c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -221,6 +221,7 @@ draw :snippets draw :profile + draw :members # Product analytics collector match '/collector/i', to: ProductAnalytics::CollectorApp.new, via: :all diff --git a/config/routes/members.rb b/config/routes/members.rb new file mode 100644 index 0000000000000000000000000000000000000000..e84f0987171ceaf1b58fb928e7294454fde8f7e7 --- /dev/null +++ b/config/routes/members.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +namespace :members do + namespace :mailgun do + resources :permanent_failures, only: [:create] + end +end diff --git a/db/migrate/20210719192928_add_invite_email_success_to_member.rb b/db/migrate/20210719192928_add_invite_email_success_to_member.rb new file mode 100644 index 0000000000000000000000000000000000000000..40feb13a564ad45f4f31280d69e56cb5b04a1de0 --- /dev/null +++ b/db/migrate/20210719192928_add_invite_email_success_to_member.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddInviteEmailSuccessToMember < ActiveRecord::Migration[6.1] + def up + unless column_exists?(:members, :invite_email_success) + add_column :members, :invite_email_success, :boolean, null: false, default: true + end + end + + def down + remove_column :members, :invite_email_success + end +end diff --git a/db/schema_migrations/20210719192928 b/db/schema_migrations/20210719192928 new file mode 100644 index 0000000000000000000000000000000000000000..b15de2220ed0b4b4635962cb478ac89bbfcd3f63 --- /dev/null +++ b/db/schema_migrations/20210719192928 @@ -0,0 +1 @@ +eed403573697ac7f454ce47d6e4ab3561a10a62177caaaea40d5d70953068175 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index cadaa69a87d4717673e6747f9de3d0dcb87c6c9e..3f9d97d214f4ca068a2bdb9b151b2b744e891ee2 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -14708,6 +14708,7 @@ CREATE TABLE members ( expires_at date, ldap boolean DEFAULT false NOT NULL, override boolean DEFAULT false NOT NULL, + invite_email_success boolean DEFAULT true NOT NULL, state smallint DEFAULT 0 ); diff --git a/doc/administration/integration/mailgun.md b/doc/administration/integration/mailgun.md new file mode 100644 index 0000000000000000000000000000000000000000..6486cc9de045f45d9c914f7dc13ad733e5104b08 --- /dev/null +++ b/doc/administration/integration/mailgun.md @@ -0,0 +1,41 @@ +--- +stage: Growth +group: Expansion +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 +type: reference, howto +--- + +# Mailgun and GitLab **(FREE SELF)** + +When you use Mailgun to send emails for your GitLab instance and [Mailgun](https://www.mailgun.com/) +integration is enabled and configured in GitLab, you can receive their webhook for +permanent invite email failures. To set up the integration, you must: + +1. [Configure your Mailgun domain](#configure-your-mailgun-domain). +1. [Enable Mailgun integration](#enable-mailgun-integration). + +After completing the integration, Mailgun `permanent_failure` webhooks are sent to your GitLab instance. + +## Configure your Mailgun domain + +Before you can enable Mailgun in GitLab, set up your own Mailgun permanent failure endpoint to receive the webhooks. + +Using the [Mailgun webhook guide](https://www.mailgun.com/blog/a-guide-to-using-mailguns-webhooks/): + +1. Add a webhook with the **Event type** set to **Permanent Failure**. +1. Fill in the URL of your instance and include the `/-/members/mailgun/permanent_failures` path. + - Example: `https://myinstance.gitlab.com/-/members/mailgun/permanent_failures` + +## Enable Mailgun integration + +After configuring your Mailgun domain for the permanent failures endpoint, +you're ready to enable the Mailgun integration: + +1. Sign in to GitLab as an [Administrator](../../user/permissions.md) user. +1. On the top bar, select **Menu >** **{admin}** **Admin**. +1. In the left sidebar, go to **Settings > General** and expand the **Mailgun** section. +1. Select the **Enable Mailgun** check box. +1. Enter the Mailgun HTTP webhook signing key as described in + [the Mailgun documentation](https://documentation.mailgun.com/en/latest/user_manual.html#webhooks) and + shown in the [API security](https://app.mailgun.com/app/account/security/api_keys) section for your Mailgun account. +1. Select **Save changes**. diff --git a/doc/api/settings.md b/doc/api/settings.md index e3366cf176cfa923c0cab263d36538d2ace40c62..671a9c008fcae6785f6563824a4564abb1dd811a 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -328,7 +328,7 @@ listed in the descriptions of the relevant settings. | `issues_create_limit` | integer | no | Max number of issue creation requests per minute per user. Disabled by default.| | `keep_latest_artifact` | boolean | no | Prevent the deletion of the artifacts from the most recent successful jobs, regardless of the expiry time. Enabled by default. | | `local_markdown_version` | integer | no | Increase this value when any cached Markdown should be invalidated. | -| `mailgun_signing_key` | string | no | The Mailgun HTTP webhook signing key for receiving events from webhook | +| `mailgun_signing_key` | string | no | The Mailgun HTTP webhook signing key for receiving events from webhook. | | `mailgun_events_enabled` | boolean | no | Enable Mailgun event receiver. | | `maintenance_mode_message` | string | no | **(PREMIUM)** Message displayed when instance is in maintenance mode. | | `maintenance_mode` | boolean | no | **(PREMIUM)** When instance is in maintenance mode, non-administrative users can sign in with read-only access and make read-only API requests. | diff --git a/doc/user/admin_area/settings/index.md b/doc/user/admin_area/settings/index.md index 6f8aa6a2e041ebb95d98b4fd256a1f6073ca4076..92b8cd0300967892d6a68eabd2346ee2b183e6b5 100644 --- a/doc/user/admin_area/settings/index.md +++ b/doc/user/admin_area/settings/index.md @@ -39,6 +39,7 @@ To access the default page for Admin Area settings: | ------ | ----------- | | [Elasticsearch](../../../integration/elasticsearch.md#enabling-advanced-search) | Elasticsearch integration. Elasticsearch AWS IAM. | | [Kroki](../../../administration/integration/kroki.md#enable-kroki-in-gitlab) | Allow rendering of diagrams in AsciiDoc and Markdown documents using [kroki.io](https://kroki.io). | +| [Mailgun](../../../administration/integration/mailgun.md) | Enable your GitLab instance to receive invite email bounce events from Mailgun, if it is your email provider. | | [PlantUML](../../../administration/integration/plantuml.md) | Allow rendering of PlantUML diagrams in documents. | | [Slack application](../../../user/project/integrations/gitlab_slack_application.md#configuration) **(FREE SAAS)** | Slack integration allows you to interact with GitLab via slash commands in a chat window. This option is only available on GitLab.com, though it may be [available for self-managed instances in the future](https://gitlab.com/gitlab-org/gitlab/-/issues/28164). | | [Third party offers](third_party_offers.md) | Control the display of third party offers. | diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index fe4c532060bae0a4cd3af35e3ff6cfe7e8c706e8..cc65e5753c0b10813eea74219b5e4b4732770c4d 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -269,10 +269,7 @@ end context 'Integrations page' do - let(:mailgun_events_receiver_enabled) { true } - before do - stub_feature_flags(mailgun_events_receiver: mailgun_events_receiver_enabled) visit general_admin_application_settings_path end @@ -286,26 +283,16 @@ expect(current_settings.hide_third_party_offers).to be true end - context 'when mailgun_events_receiver feature flag is enabled' do - it 'enabling Mailgun events', :aggregate_failures do - page.within('.as-mailgun') do - check 'Enable Mailgun event receiver' - fill_in 'Mailgun HTTP webhook signing key', with: 'MAILGUN_SIGNING_KEY' - click_button 'Save changes' - end - - expect(page).to have_content 'Application settings saved successfully' - expect(current_settings.mailgun_events_enabled).to be true - expect(current_settings.mailgun_signing_key).to eq 'MAILGUN_SIGNING_KEY' + it 'enabling Mailgun events', :aggregate_failures do + page.within('.as-mailgun') do + check 'Enable Mailgun event receiver' + fill_in 'Mailgun HTTP webhook signing key', with: 'MAILGUN_SIGNING_KEY' + click_button 'Save changes' end - end - - context 'when mailgun_events_receiver feature flag is disabled' do - let(:mailgun_events_receiver_enabled) { false } - it 'does not have mailgun' do - expect(page).not_to have_selector('.as-mailgun') - end + expect(page).to have_content 'Application settings saved successfully' + expect(current_settings.mailgun_events_enabled).to be true + expect(current_settings.mailgun_signing_key).to eq 'MAILGUN_SIGNING_KEY' end end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 77d126e012e316a072fe4461a636b94384877d3f..10162ade48bdb1582e2fe293baed58fe465d1b9d 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -167,6 +167,7 @@ ProjectMember: - expires_at - ldap - override +- invite_email_success User: - id - username diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index ae956adf563aa949871cb2b48c485d402c29ba3a..64fb10d1556a41ca17d07ea355334dfb25d44076 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -827,15 +827,15 @@ def invite_to_project(project, inviter:, user: nil) end end - context 'when on gitlab.com' do + context 'when mailgun events are enabled' do before do - allow(Gitlab).to receive(:dev_env_or_com?).and_return(true) + stub_application_setting(mailgun_events_enabled: true) end it 'has custom headers' do aggregate_failures do - expect(subject).to have_header('X-Mailgun-Tag', 'invite_email') - expect(subject).to have_header('X-Mailgun-Variables', { 'invite_token' => project_member.invite_token }.to_json) + expect(subject).to have_header('X-Mailgun-Tag', ::Members::Mailgun::INVITE_EMAIL_TAG) + expect(subject).to have_header('X-Mailgun-Variables', { ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY => project_member.invite_token }.to_json) end end end diff --git a/spec/requests/members/mailgun/permanent_failure_spec.rb b/spec/requests/members/mailgun/permanent_failure_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e47aedf8e9411bf3380b3de67d0fc2701f275daf --- /dev/null +++ b/spec/requests/members/mailgun/permanent_failure_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'receive a permanent failure' do + describe 'POST /members/mailgun/permanent_failures', :aggregate_failures do + let_it_be(:member) { create(:project_member, :invited) } + + let(:raw_invite_token) { member.raw_invite_token } + let(:mailgun_events) { true } + let(:mailgun_signing_key) { 'abc123' } + + subject(:post_request) { post members_mailgun_permanent_failures_path(standard_params) } + + before do + stub_application_setting(mailgun_events_enabled: mailgun_events, mailgun_signing_key: mailgun_signing_key) + end + + it 'marks the member invite email success as false' do + expect { post_request }.to change { member.reload.invite_email_success }.from(true).to(false) + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when the change to a member is not made' do + context 'with incorrect signing key' do + context 'with incorrect signing key' do + let(:mailgun_signing_key) { '_foobar_' } + + it 'does not change member status and responds as not_found' do + expect { post_request }.not_to change { member.reload.invite_email_success } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'with nil signing key' do + let(:mailgun_signing_key) { nil } + + it 'does not change member status and responds as not_found' do + expect { post_request }.not_to change { member.reload.invite_email_success } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when the feature is not enabled' do + let(:mailgun_events) { false } + + it 'does not change member status and responds as expected' do + expect { post_request }.not_to change { member.reload.invite_email_success } + + expect(response).to have_gitlab_http_status(:not_acceptable) + end + end + + context 'when it is not an invite email' do + before do + stub_const('::Members::Mailgun::INVITE_EMAIL_TAG', '_foobar_') + end + + it 'does not change member status and responds as expected' do + expect { post_request }.not_to change { member.reload.invite_email_success } + + expect(response).to have_gitlab_http_status(:not_acceptable) + end + end + end + + def standard_params + { + "signature": { + "timestamp": "1625056677", + "token": "eb944d0ace7227667a1b97d2d07276ae51d2b849ed2cfa68f3", + "signature": "9790cc6686eb70f0b1f869180d906870cdfd496d27fee81da0aa86b9e539e790" + }, + "event-data": { + "severity": "permanent", + "tags": ["invite_email"], + "timestamp": 1521233195.375624, + "storage": { + "url": "_anything_", + "key": "_anything_" + }, + "log-level": "error", + "id": "_anything_", + "campaigns": [], + "reason": "suppress-bounce", + "user-variables": { + "invite_token": raw_invite_token + }, + "flags": { + "is-routed": false, + "is-authenticated": true, + "is-system-test": false, + "is-test-mode": false + }, + "recipient-domain": "example.com", + "envelope": { + "sender": "bob@mg.gitlab.com", + "transport": "smtp", + "targets": "alice@example.com" + }, + "message": { + "headers": { + "to": "Alice <alice@example.com>", + "message-id": "20130503192659.13651.20287@mg.gitlab.com", + "from": "Bob <bob@mg.gitlab.com>", + "subject": "Test permanent_fail webhook" + }, + "attachments": [], + "size": 111 + }, + "recipient": "alice@example.com", + "event": "failed", + "delivery-status": { + "attempt-no": 1, + "message": "", + "code": 605, + "description": "Not delivering to previously bounced address", + "session-seconds": 0 + } + } + } + end + end +end diff --git a/spec/services/members/mailgun/process_webhook_service_spec.rb b/spec/services/members/mailgun/process_webhook_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d6a2118339519658cb81992e33cbee8be2796c46 --- /dev/null +++ b/spec/services/members/mailgun/process_webhook_service_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::Mailgun::ProcessWebhookService do + describe '#execute', :aggregate_failures do + let_it_be(:member) { create(:project_member, :invited) } + + let(:raw_invite_token) { member.raw_invite_token } + let(:payload) { { 'user-variables' => { ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY => raw_invite_token } } } + + subject(:service) { described_class.new(payload).execute } + + it 'marks the member invite email success as false' do + expect(Gitlab::AppLogger).to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/).and_call_original + + expect { service }.to change { member.reload.invite_email_success }.from(true).to(false) + end + + context 'when member can not be found' do + let(:raw_invite_token) { '_foobar_' } + + it 'does not change member status' do + expect(Gitlab::AppLogger).not_to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/) + + expect { service }.not_to change { member.reload.invite_email_success } + end + end + + context 'when invite token is not found in payload' do + let(:payload) { {} } + + it 'does not change member status and logs an error' do + expect(Gitlab::AppLogger).not_to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/) + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + an_instance_of(described_class::ProcessWebhookServiceError)) + + expect { service }.not_to change { member.reload.invite_email_success } + end + end + end +end