munster 0.4.0 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86e72b8a849981c0e626054ed78660ba288628389933890d9d662daf2f22daa7
4
- data.tar.gz: 62ab2ab22e5805158eb7f9f2d6fd622d03b32db4c985ed20d07d6e21a4b30c89
3
+ metadata.gz: 7d3f84bd06c033ef585c3282701915793b981142351824cc7bba2f41c5bcde4b
4
+ data.tar.gz: 9e614a61681b3bed5667905ae378a357fcfa96d85c79b7d85c32473c2b763c4f
5
5
  SHA512:
6
- metadata.gz: 5436a8a198fac9c1febfbd351bb44435307d88d7d1f87f70f293359ae88e304aaaa6378c0a0c14f89b534344f088ce14bb3afd8fdc323fd99788559c56362013
7
- data.tar.gz: d2f7a667043d29b9bb46f5248963f6e02a6198f012f17ca5f9c9fdbc6c180d6ba0088273942e767b8feece8c1c985fba03183c5eb6a22377bce8853af70102df
6
+ metadata.gz: 616dc8e6585239309106444bb65de90d572e946e08479044459e5f8deb764d0d308d22da895152e3d7250345a81640f627ea72485daf17d6d51b03299d29f5c3
7
+ data.tar.gz: 369a9b8ce33c83e876836f652b57fc618a82e3028818531f6bc98d8732fad53cc7a926d6faf2ad8711c5e08ac3e0c9ec23de06a95b597087f86091dfb78e6240
data/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file.
3
3
 
4
4
  This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## 0.4.1
7
+
8
+ - Webhook processor now requires `active_job/railtie`, instead of `active_job`. It requires GlobalID to work and that get's required only with railtie.
9
+ - Adding a state transition from `error` to `received`. Proccessing job will be enqueued automatic after this transition was executed.
6
10
 
7
11
  ## 0.4.0
8
12
 
data/README.md CHANGED
@@ -30,6 +30,18 @@ Mount munster engine in your routes.
30
30
  mount Munster::Engine, at: "/webhooks"
31
31
  ```
32
32
 
33
+ Define a class for your first handler (let's call it `ExampleHandler`) and inherit it from `Munster::BaseHandler`. Place it somewhere where Rails autoloading can find it, and add it to your `munster.rb` config file:
34
+
35
+ ```ruby
36
+ config.active_handlers = {
37
+ "example" => "ExampleHandler"
38
+ }
39
+ ```
40
+
41
+ ## Example handlers
42
+
43
+ We provide a number of webhook handlers which demonstrate certain features of Munster. You will find them in `handler-examples`.
44
+
33
45
  ## Requirements
34
46
 
35
47
  This project depends on two dependencies:
@@ -0,0 +1,36 @@
1
+ # This is an example handler for Customer.io reporting webhooks. You
2
+ # can find more documentation here https://customer.io/docs/api/webhooks/#operation/reportingWebhook
3
+ class Webhooks::CustomerIoHandler < Munster::BaseHandler
4
+ def process(webhook)
5
+ json = JSON.parse(webhook.body, symbolize_names: true)
6
+ case json[:metric]
7
+ when "subscribed"
8
+ # ...
9
+ when "unsubscribed"
10
+ # ...
11
+ when "cio_subscription_preferences_changed"
12
+ # ...
13
+ end
14
+ end
15
+
16
+ def extract_event_id_from_request(action_dispatch_request)
17
+ action_dispatch_request.params.fetch(:event_id)
18
+ end
19
+
20
+ # Verify that request is actually comming from customer.io here
21
+ # @see https://customer.io/docs/api/webhooks/#section/Securely-Verifying-Requests
22
+ #
23
+ # - Should have "X-CIO-Signature", "X-CIO-Timestamp" headers.
24
+ # - Combine the version number, timestamp and body delimited by colons to form a string in the form v0:<timestamp>:<body>
25
+ # - Using HMAC-SHA256, hash the string using your webhook signing secret as the hash key.
26
+ # - Compare this value to the value of the X-CIO-Signature header sent with the request to confirm
27
+ def valid?(action_dispatch_request)
28
+ signing_key = Rails.application.secrets.customer_io_webhook_signing_key
29
+ xcio_signature = action_dispatch_request.headers["HTTP_X_CIO_SIGNATURE"]
30
+ xcio_timestamp = action_dispatch_request.headers["HTTP_X_CIO_TIMESTAMP"]
31
+ request_body = action_dispatch_request.body.read
32
+ string_to_sign = "v0:#{xcio_timestamp}:#{request_body}"
33
+ hmac = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
34
+ Rack::Utils.secure_compare(hmac, xcio_signature)
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # This is for Revolut V1 API for webhooks - https://developer.revolut.com/docs/business/webhooks-v-1-deprecated
2
+ class RevolutBusinessV1Handler < Munster::BaseHandler
3
+ def valid?(_)
4
+ # V1 of Revolut webhooks does not support signatures
5
+ true
6
+ end
7
+
8
+ def self.process(webhook)
9
+ parsed_payload = JSON.parse(webhook.body)
10
+ topic = parsed_payload.fetch("Topic")
11
+ case topic
12
+ when "tokens" # Account access revocation payload
13
+ # ...
14
+ when "draftpayments/transfers" # Draft payment transfer notification payload
15
+ # ...
16
+ else
17
+ # ...
18
+ end
19
+ end
20
+
21
+ def self.extract_event_id_from_request(action_dispatch_request)
22
+ # Since b-tree indices generally divide from the start of the string, place the highest
23
+ # entropy component at the start (the EventId)
24
+ key_components = %w[EventId Topic Version]
25
+ key_components.map do |key|
26
+ action_dispatch_request.params.fetch(key)
27
+ end.join("-")
28
+ end
29
+ end
@@ -0,0 +1,42 @@
1
+ # This is for Revolut V2 API for webhooks - https://developer.revolut.com/docs/business/webhooks-v-2
2
+ class RevolutBusinessV2Handler < Munster::BaseHandler
3
+ def valid?(request)
4
+ # 1 - Validate the timestamp of the request. Prevent replay attacks.
5
+ # "To validate the event, make sure that the Revolut-Request-Timestamp date-time is within a 5-minute time tolerance of the current universal time (UTC)".
6
+ # Their examples list `timestamp = '1683650202360'` as a sample value, so their timestamp is in millis - not in seconds
7
+ timestamp_str_from_headers = request.headers["HTTP_REVOLUT_REQUEST_TIMESTAMP"]
8
+ delta_t_seconds = (timestamp_str_from_headers / 1000) - Time.now.to_i
9
+ return false unless delta_t_seconds.abs < (5 * 60)
10
+
11
+ # 2 - Validate the signature
12
+ # https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/verify-the-payload-signature
13
+ string_to_sign = [
14
+ "v1",
15
+ timestamp_str_from_headers,
16
+ request.body.read
17
+ ].join(".")
18
+ computed_signature = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secrets.revolut_business_webhook_signing_key, string_to_sign)
19
+ # Note: "This means that in the period when multiple signing secrets remain valid, multiple signatures are sent."
20
+ # https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/manage-webhooks#rotate-a-webhook-signing-secret
21
+ # https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/about-webhooks#security
22
+ # An HTTP header may contain multiple values if it gets sent multiple times. But it does mean we need to test for multiple provided
23
+ # signatures in case of rotation.
24
+ provided_signatures = request.headers["HTTP_REVOLUT_SIGNATURE"].split(",")
25
+ # Use #select instead of `find` to compare all signatures even if only one matches - this to avoid timing leaks.
26
+ # Small effort but might be useful.
27
+ matches = provided_signatures.select do |provided_signature|
28
+ ActiveSupport::SecurityUtils.secure_compare(provided_signature, computed_signature)
29
+ end
30
+ matches.any?
31
+ end
32
+
33
+ def self.process(webhook)
34
+ Rails.logger.info { "Processing Revolut webhook #{webhook.body.inspect}" }
35
+ end
36
+
37
+ def self.extract_event_id_from_request(action_dispatch_request)
38
+ # The event ID is only available when you retrieve the failed webhooks, which is sad.
39
+ # We can divinate a synthetic ID though by taking a hash of the entire payload though.
40
+ Digest::SHA256.hexdigest(action_dispatch_request.body.read)
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This handler is an example for Starling Payments API,
4
+ # you can find the documentation here https://developer.starlingbank.com/payments/docs#account-and-address-structure-1
5
+ class StarlingPaymentsHandler < Munster::BaseHandler
6
+ # This method will be used to process webhook by async worker.
7
+ def process(received_webhook)
8
+ Rails.logger.info { received_webhook.body }
9
+ end
10
+
11
+ # Starling supplies signatures in the form SHA512(secret + request_body)
12
+ def valid?(action_dispatch_request)
13
+ supplied_signature = action_dispatch_request.headers.fetch("X-Hook-Signature")
14
+ supplied_digest_bytes = Base64.strict_decode64(supplied_signature)
15
+ sha512 = Digest::SHA2.new(512)
16
+ signing_secret = Rails.credentials.starling_payments_webhook_signing_secret
17
+ computed_digest_bytes = sha512.digest(signing_secret.b + action_dispatch_request.body.b)
18
+ ActiveSupport::SecurityUtils.secure_compare(computed_digest_bytes, supplied_digest_bytes)
19
+ end
20
+
21
+ # Some Starling webhooks do not provide a notification UID, but for those which do we can deduplicate
22
+ def extract_event_id_from_request(action_dispatch_request)
23
+ action_dispatch_request.params.fetch("notificationUid", SecureRandom.uuid)
24
+ end
25
+ end
@@ -16,11 +16,27 @@ module Munster
16
16
  webhook = Munster::ReceivedWebhook.new(request: action_dispatch_request, handler_event_id: handler_event_id, handler_module_name: handler_module_name)
17
17
  webhook.save!
18
18
 
19
- Munster.configuration.processing_job_class.perform_later(webhook)
19
+ enqueue(webhook)
20
20
  rescue ActiveRecord::RecordNotUnique # Webhook deduplicated
21
21
  Rails.logger.info { "#{inspect} Webhook #{handler_event_id} is a duplicate delivery and will not be stored." }
22
22
  end
23
23
 
24
+ # Enqueues the processing job to process webhook asynchronously. The job class could be configured.
25
+ #
26
+ # @param webhook [Munster::ReceivedWebhook]
27
+ # @return [void]
28
+ def enqueue(webhook)
29
+ # The configured job class can be a class name or a module, to support lazy loading
30
+ job_class_or_module_name = Munster.configuration.processing_job_class
31
+ job_class = if job_class_or_module_name.respond_to?(:perform_later)
32
+ job_class_or_module_name
33
+ else
34
+ job_class_or_module_name.constantize
35
+ end
36
+
37
+ job_class.perform_later(webhook)
38
+ end
39
+
24
40
  # This is the heart of your webhook processing. Override this method and define your processing inside of it.
25
41
  # The `received_webhook` will provide access to the `ReceivedWebhook` model, which contains the received
26
42
  # body of the webhook request, but also the full (as-full-as-possible) clone of the original ActionDispatch::Request
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_job" if defined?(Rails)
3
+ require "active_job/railtie"
4
4
 
5
5
  module Munster
6
6
  class ProcessingJob < ActiveJob::Base
@@ -15,6 +15,11 @@ module Munster
15
15
  s.permit_transition(:processing, :skipped)
16
16
  s.permit_transition(:processing, :processed)
17
17
  s.permit_transition(:processing, :error)
18
+ s.permit_transition(:error, :received)
19
+
20
+ s.after_committed_transition_to(:received) do |webhook|
21
+ webhook.handler.enqueue(webhook)
22
+ end
18
23
  end
19
24
 
20
25
  # Store the pertinent data from an ActionDispatch::Request into the webhook.
@@ -1,10 +1,15 @@
1
1
  Munster.configure do |config|
2
2
  # Active Handlers are defined as hash with key as a service_id and handler class that would handle webhook request.
3
+ # A Handler must respond to `.new` and return an object roughly matching `Munster::BaseHandler` in terms of interface.
4
+ # Use module names (strings) here to allow the handler modules to be lazy-loaded by Rails.
5
+ #
3
6
  # Example:
4
- # {:test => TestHandler, :inactive => InactiveHandler}
7
+ # {:test => "TestHandler", :inactive => "InactiveHandler"}
5
8
  config.active_handlers = {}
6
9
 
7
- # It's possible to overwrite default processing job to enahance it. As example if you want to add proper locking or retry mechanism.
10
+ # It's possible to overwrite default processing job to enahance it. As example if you want to add custom
11
+ # locking or retry mechanism. You want to inherit that job from Munster::ProcessingJob because the background
12
+ # job also manages the webhook state.
8
13
  #
9
14
  # Example:
10
15
  #
@@ -15,9 +20,9 @@ Munster.configure do |config|
15
20
  # end
16
21
  # end
17
22
  #
18
- # This is how you can change processing job:
23
+ # In the config a string with your job' class name can be used so that the job can be lazy-loaded by Rails:
19
24
  #
20
- # config.processing_job_class = WebhookProcessingJob
25
+ # config.processing_job_class = "WebhookProcessingJob"
21
26
 
22
27
  # We're using a common interface for error reporting provided by Rails, e.g Rails.error.report. In some cases
23
28
  # you want to enhance those errors with additional context. As example to provide a namespace:
@@ -25,4 +30,11 @@ Munster.configure do |config|
25
30
  # { appsignal: { namespace: "webhooks" } }
26
31
  #
27
32
  # config.error_context = { appsignal: { namespace: "webhooks" } }
33
+
34
+ # Incoming webhooks will be written into your DB without any prior validation. By default, Munster limits the
35
+ # request body size for webhooks to 512 KiB, so that it would not be too easy for an attacker to fill your
36
+ # database with junk. However, if you are receiving very large webhook payloads you might need to increase
37
+ # that limit (or make it even smaller for extra security)
38
+ #
39
+ # config.request_body_size_limit = 2.megabytes
28
40
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Munster
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: munster
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stanislav Katkov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-07-24 00:00:00.000000000 Z
11
+ date: 2024-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -170,6 +170,10 @@ files:
170
170
  - example/tmp/.keep
171
171
  - example/tmp/pids/.keep
172
172
  - example/vendor/.keep
173
+ - handler-examples/customer_io_handler.rb
174
+ - handler-examples/revolut_business_v1_handler.rb
175
+ - handler-examples/revolut_business_v2_handler.rb
176
+ - handler-examples/starling_payments_handler.rb
173
177
  - lib/munster.rb
174
178
  - lib/munster/base_handler.rb
175
179
  - lib/munster/controllers/receive_webhooks_controller.rb