angarium 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +65 -0
  3. data/MIT-LICENSE +22 -0
  4. data/README.md +910 -0
  5. data/Rakefile +13 -0
  6. data/SECURITY.md +51 -0
  7. data/app/assets/config/angarium_manifest.js +1 -0
  8. data/app/assets/stylesheets/angarium/application.css +15 -0
  9. data/app/controllers/angarium/api/attempts_controller.rb +12 -0
  10. data/app/controllers/angarium/api/base_controller.rb +133 -0
  11. data/app/controllers/angarium/api/deliveries_controller.rb +29 -0
  12. data/app/controllers/angarium/api/endpoints_controller.rb +117 -0
  13. data/app/controllers/angarium/api/not_authorized.rb +7 -0
  14. data/app/controllers/angarium/api/unpermitted_parameter.rb +15 -0
  15. data/app/helpers/angarium/application_helper.rb +4 -0
  16. data/app/jobs/angarium/application_job.rb +5 -0
  17. data/app/jobs/angarium/deliver_job.rb +11 -0
  18. data/app/models/angarium/application_record.rb +17 -0
  19. data/app/models/angarium/delivery.rb +254 -0
  20. data/app/models/angarium/delivery_attempt.rb +14 -0
  21. data/app/models/angarium/endpoint.rb +217 -0
  22. data/app/models/angarium/event.rb +7 -0
  23. data/app/policies/angarium/api/policy.rb +78 -0
  24. data/app/validators/angarium/endpoint_url_validator.rb +20 -0
  25. data/app/views/layouts/angarium/application.html.erb +15 -0
  26. data/config/routes.rb +22 -0
  27. data/db/angarium_migrate/20260704000001_create_angarium_endpoints.rb +22 -0
  28. data/db/angarium_migrate/20260704000002_create_angarium_events.rb +9 -0
  29. data/db/angarium_migrate/20260704000003_create_angarium_deliveries.rb +13 -0
  30. data/db/angarium_migrate/20260704000004_create_angarium_delivery_attempts.rb +12 -0
  31. data/lib/angarium/address_policy.rb +82 -0
  32. data/lib/angarium/client.rb +53 -0
  33. data/lib/angarium/configuration.rb +66 -0
  34. data/lib/angarium/dispatch.rb +23 -0
  35. data/lib/angarium/engine.rb +5 -0
  36. data/lib/angarium/event_matcher.rb +17 -0
  37. data/lib/angarium/signature.rb +57 -0
  38. data/lib/angarium/version.rb +3 -0
  39. data/lib/angarium.rb +44 -0
  40. data/lib/generators/angarium/install/install_generator.rb +57 -0
  41. data/lib/generators/angarium/install/templates/initializer.rb +77 -0
  42. data/lib/generators/angarium/migrations/migrations_generator.rb +39 -0
  43. data/lib/generators/angarium/policy/policy_generator.rb +40 -0
  44. data/lib/generators/angarium/policy/templates/policy.rb +33 -0
  45. data/lib/tasks/angarium_tasks.rake +15 -0
  46. metadata +137 -0
@@ -0,0 +1,12 @@
1
+ class CreateAngariumDeliveryAttempts < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :angarium_delivery_attempts, id: Angarium.primary_key_type do |t|
4
+ t.references :delivery, null: false, type: Angarium.primary_key_type, foreign_key: {to_table: :angarium_deliveries}
5
+ t.integer :response_code
6
+ t.text :response_body
7
+ t.string :error
8
+ t.float :duration
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,82 @@
1
+ require "ipaddr"
2
+ require "resolv"
3
+
4
+ module Angarium
5
+ # Central SSRF policy. Decides whether a destination IP is permitted for a
6
+ # given endpoint, composing three controls:
7
+ # 1. endpoint.allowed_networks (CIDR list) -> if present, ONLY those are allowed
8
+ # 2. endpoint.allow_private_network -> bypass the private-IP denylist
9
+ # 3. Angarium.config.block_private_ips -> default denylist of private ranges
10
+ module AddressPolicy
11
+ module_function
12
+
13
+ # Is a single IP (String or IPAddr) permitted for this endpoint?
14
+ #
15
+ # Two independent gates, both must pass:
16
+ # Gate A (private denylist): private/loopback/link-local addresses are
17
+ # blocked unless endpoint.allow_private_network is set. An allowlist entry
18
+ # does NOT by itself unlock a private address.
19
+ # Gate B (allowlist): when endpoint.allowed_networks is non-empty, the
20
+ # address must fall within one of those CIDRs.
21
+ def ip_allowed?(ip, endpoint)
22
+ ip = to_ipaddr(ip)
23
+ return false unless ip
24
+
25
+ if private?(ip) && Angarium.config.block_private_ips
26
+ return false unless endpoint.allow_private_network
27
+ end
28
+
29
+ allowlist = Array(endpoint.allowed_networks).reject(&:blank?)
30
+ unless allowlist.empty?
31
+ return false unless allowlist.any? { |cidr| cidr_include?(cidr, ip) }
32
+ end
33
+
34
+ true
35
+ end
36
+
37
+ # Resolve host and return true only if EVERY resolved address is allowed.
38
+ # Unresolvable hosts return [] and this returns true (can't prove disallowed);
39
+ # callers that need strictness at connect time check each resolved IP instead.
40
+ def host_permitted_for_validation?(host, endpoint)
41
+ resolve(host).all? { |ip| ip_allowed?(ip, endpoint) }
42
+ end
43
+
44
+ def resolve(host)
45
+ literal = to_ipaddr(host)
46
+ return [literal] if literal
47
+
48
+ Resolv.getaddresses(host).filter_map { |a| to_ipaddr(a) }
49
+ rescue
50
+ []
51
+ end
52
+
53
+ def private?(ip)
54
+ ip = normalize(ip)
55
+ return true if ip.to_i.zero? # 0.0.0.0 / :: (unspecified -> localhost on Linux)
56
+
57
+ ip.loopback? || ip.private? || ip.link_local? ||
58
+ (ip.respond_to?(:unique_local?) && ip.unique_local?)
59
+ end
60
+
61
+ # Collapse IPv4-mapped IPv6 (::ffff:x.x.x.x) to native IPv4 so the predicates
62
+ # above see the real address; leave other addresses untouched.
63
+ def normalize(ip)
64
+ ip.respond_to?(:native) ? ip.native : ip
65
+ rescue
66
+ ip
67
+ end
68
+
69
+ def cidr_include?(cidr, ip)
70
+ IPAddr.new(cidr.to_s).include?(ip)
71
+ rescue IPAddr::InvalidAddressError
72
+ false
73
+ end
74
+
75
+ def to_ipaddr(value)
76
+ ip = value.is_a?(IPAddr) ? value : IPAddr.new(value.to_s)
77
+ normalize(ip)
78
+ rescue IPAddr::InvalidAddressError
79
+ nil
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,53 @@
1
+ require "httpx"
2
+
3
+ module Angarium
4
+ # Thin wrapper over HTTPX. Returns a plain result struct so callers/tests
5
+ # never touch HTTPX response objects directly. Note: HTTPX does NOT follow
6
+ # redirects unless the :follow_redirects plugin is enabled, so we intentionally
7
+ # leave it disabled so a 3xx to an internal URL can't bypass SSRF checks.
8
+ class Client
9
+ Result = Struct.new(:success, :code, :body, :error, :duration, :headers) do
10
+ def success? = success
11
+ end
12
+
13
+ def post(url, body:, headers:, addresses: nil)
14
+ conn = self.class.connection
15
+ conn = conn.with(addresses: addresses) if addresses && !addresses.empty?
16
+
17
+ started = monotonic
18
+ response = conn.post(url, body: body, headers: headers)
19
+ duration = monotonic - started
20
+
21
+ if response.is_a?(HTTPX::ErrorResponse)
22
+ return Result.new(success: false,
23
+ error: "#{response.error.class}: #{response.error.message}",
24
+ duration: duration,
25
+ headers: {})
26
+ end
27
+
28
+ max = Angarium.config.max_response_body_bytes
29
+ body = response.body.to_s
30
+ body = body.byteslice(0, max) if max
31
+
32
+ Result.new(
33
+ success: (200..299).cover?(response.status),
34
+ code: response.status,
35
+ body: body,
36
+ duration: duration,
37
+ headers: response.headers.to_h.transform_keys(&:downcase)
38
+ )
39
+ end
40
+
41
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+
43
+ def self.connection
44
+ HTTPX.with(
45
+ headers: {"user-agent" => Angarium.config.user_agent, "content-type" => "application/json"},
46
+ timeout: {
47
+ connect_timeout: Angarium.config.open_timeout,
48
+ read_timeout: Angarium.config.http_timeout
49
+ }
50
+ )
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,66 @@
1
+ module Angarium
2
+ class Configuration
3
+ attr_accessor :job_queue, :http_timeout, :open_timeout, :user_agent,
4
+ :retry_schedule, :block_private_ips,
5
+ :primary_key_type, :database, :connects_to, :max_response_body_bytes,
6
+ :auto_disable_endpoint_after, :respect_retry_after,
7
+ :max_retry_after, :retry_jitter, :signing_secret_grace_period,
8
+ :delivery_attempt_retention, :delivering_timeout,
9
+ :on_delivery_exhausted, :on_endpoint_deactivated, :on_endpoint_verified,
10
+ :parent_controller, :current_user, :policy_class
11
+
12
+ def initialize
13
+ @job_queue = :default
14
+ @http_timeout = 10
15
+ @open_timeout = 5
16
+ @user_agent = "Angarium/#{Angarium::VERSION}"
17
+ # Follows the Standard Webhooks recommendation of a multi-day schedule with
18
+ # exponential backoff (delays between retries; the first delivery is
19
+ # immediate). Spans ~10 days; jitter is added per attempt (see
20
+ # config.retry_jitter).
21
+ @retry_schedule = [
22
+ 5.seconds, 5.minutes, 30.minutes, 2.hours, 5.hours,
23
+ 10.hours, 14.hours, 20.hours, 24.hours, 36.hours, 48.hours, 72.hours
24
+ ]
25
+ @block_private_ips = true
26
+ @primary_key_type = nil
27
+ # Multi-database: the database (a key from config/database.yml) that
28
+ # Angarium's tables live in. Drives both the connection and where the
29
+ # migrations generator installs migrations (db/<database>_migrate).
30
+ # nil (default) keeps Angarium on the app's primary connection.
31
+ @database = nil
32
+ # Advanced multi-database: a hash passed straight to Rails' connects_to for
33
+ # custom roles/shards, e.g. { database: { writing: :angarium, reading: :angarium } }.
34
+ # Takes precedence over @database for the connection.
35
+ @connects_to = nil
36
+ @max_response_body_bytes = 65_536
37
+ @auto_disable_endpoint_after = nil
38
+ @respect_retry_after = true
39
+ @max_retry_after = 3600
40
+ @retry_jitter = 0.15
41
+ @signing_secret_grace_period = 24.hours
42
+ @delivery_attempt_retention = nil
43
+ @delivering_timeout = 15.minutes
44
+ @on_delivery_exhausted = nil # ->(delivery) { ... }
45
+ @on_endpoint_deactivated = nil # ->(endpoint, reason) { ... } reason: :consecutive_failures | :gone
46
+ @on_endpoint_verified = nil # ->(endpoint) { ... } fired when an unverified endpoint is verified
47
+
48
+ # --- Headless JSON API (only used if you mount Angarium::Engine) ---------
49
+ # Base controller the API inherits from, so your app's authentication
50
+ # (Devise/Rodauth/etc.) applies to Angarium's endpoints too.
51
+ @parent_controller = "ApplicationController"
52
+ # Resolves the current user from the controller (your current-user convention).
53
+ @current_user = ->(controller) { controller.current_user }
54
+ # Authorization: scope, create-owner, and per-action permissions, all in one
55
+ # class. Subclass Angarium::Api::Policy to customize any of them.
56
+ @policy_class = "Angarium::Api::Policy"
57
+ end
58
+
59
+ # The database Angarium's migrations belong in, for the migrations generator.
60
+ # Prefers the explicit @database, else the writing role from a connects_to
61
+ # hash. nil => the app's primary db/migrate.
62
+ def migrations_database
63
+ database || connects_to&.dig(:database, :writing)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,23 @@
1
+ module Angarium
2
+ module Dispatch
3
+ module_function
4
+
5
+ def call(event_name, payload, owner:)
6
+ notify_payload = {event: event_name, event_id: nil, deliveries: 0}
7
+ ActiveSupport::Notifications.instrument("dispatch.angarium", notify_payload) do
8
+ endpoints = Endpoint.enabled.where(owner: owner).select do |endpoint|
9
+ endpoint.subscribed_to?(event_name)
10
+ end
11
+ next nil if endpoints.empty?
12
+
13
+ Event.transaction do
14
+ event = Event.create!(name: event_name, payload: payload)
15
+ endpoints.each { |endpoint| event.deliveries.create!(endpoint: endpoint) }
16
+ notify_payload[:event_id] = event.id
17
+ notify_payload[:deliveries] = endpoints.size
18
+ event
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ module Angarium
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Angarium
4
+ end
5
+ end
@@ -0,0 +1,17 @@
1
+ module Angarium
2
+ module EventMatcher
3
+ module_function
4
+
5
+ # pattern: "*" (all), "prefix.*" (prefix), or an exact event name
6
+ def match?(pattern, event_name)
7
+ return true if pattern == "*"
8
+
9
+ if pattern.end_with?(".*")
10
+ prefix = pattern[0..-3] # strip ".*"
11
+ event_name == prefix || event_name.start_with?("#{prefix}.")
12
+ else
13
+ pattern == event_name
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,57 @@
1
+ require "openssl"
2
+ require "base64"
3
+
4
+ module Angarium
5
+ module Signature
6
+ module_function
7
+
8
+ # secret may be a String or Array of secrets (dual-secret rotation grace);
9
+ # produces one `v1,<base64>` token per secret, space-delimited.
10
+ def sign(payload:, id:, timestamp:, secret:)
11
+ Array(secret).map { |s| "v1,#{signature_for(s, id, timestamp, payload)}" }.join(" ")
12
+ end
13
+
14
+ # Verify a Standard Webhooks signature. Pass the fields explicitly, or pass a
15
+ # Rails `request:` and Angarium pulls the raw body and webhook-* headers for
16
+ # you, so a receiver is a one-liner:
17
+ #
18
+ # Angarium::Signature.verify(request:, secret: endpoint.signing_secret)
19
+ #
20
+ def verify(secret:, request: nil, payload: nil, id: nil, timestamp: nil, signature: nil,
21
+ tolerance: 300, now: Time.now.to_i)
22
+ if request
23
+ payload ||= request.raw_post
24
+ id ||= request.headers["webhook-id"]
25
+ timestamp ||= request.headers["webhook-timestamp"]
26
+ signature ||= request.headers["webhook-signature"]
27
+ end
28
+
29
+ return false unless timestamp.to_s.match?(/\A\d+\z/)
30
+ return false if (now - timestamp.to_i).abs > tolerance
31
+
32
+ expected = signature_for(secret, id, timestamp.to_i, payload)
33
+ parse(signature).any? { |sig| secure_compare(expected, sig) }
34
+ end
35
+
36
+ def signature_for(secret, id, timestamp, payload)
37
+ key = Base64.decode64(secret.to_s.delete_prefix("whsec_"))
38
+ digest = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{payload}")
39
+ Base64.strict_encode64(digest)
40
+ end
41
+
42
+ # webhook-signature is space-delimited `v1,<base64>` tokens; keep the base64
43
+ # payload of each v1 token.
44
+ def parse(header)
45
+ header.to_s.split(" ").filter_map { |token|
46
+ version, sig = token.split(",", 2)
47
+ sig if version == "v1" && sig && !sig.empty?
48
+ }
49
+ end
50
+
51
+ def secure_compare(a, b)
52
+ ActiveSupport::SecurityUtils.secure_compare(a, b)
53
+ rescue ArgumentError
54
+ false
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,3 @@
1
+ module Angarium
2
+ VERSION = "0.1.0"
3
+ end
data/lib/angarium.rb ADDED
@@ -0,0 +1,44 @@
1
+ require "angarium/version"
2
+ require "angarium/engine"
3
+ require "angarium/configuration"
4
+ require "angarium/event_matcher"
5
+ require "angarium/signature"
6
+ require "angarium/address_policy"
7
+ require "angarium/dispatch"
8
+ require "angarium/client"
9
+
10
+ module Angarium
11
+ class << self
12
+ def config
13
+ @config ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield config
18
+ end
19
+
20
+ # Primary key type for Angarium's own tables. Explicit config wins; otherwise
21
+ # respect the app's global generators setting; otherwise Rails' default (bigint).
22
+ def primary_key_type
23
+ config.primary_key_type ||
24
+ Rails.application.config.generators.options.dig(:active_record, :primary_key_type) ||
25
+ :bigint
26
+ end
27
+
28
+ def dispatch(event_name, payload, owner:)
29
+ Dispatch.call(event_name, payload, owner: owner)
30
+ end
31
+
32
+ # Invoke a configured notification callback (e.g. on_delivery_exhausted,
33
+ # on_endpoint_deactivated). A callback raising must never break the delivery
34
+ # pipeline, so errors are logged and swallowed.
35
+ def notify(callback_name, *args)
36
+ callback = config.public_send(callback_name)
37
+ return unless callback
38
+
39
+ callback.call(*args)
40
+ rescue => e
41
+ Rails.logger.error { "[Angarium] #{callback_name} callback raised: #{e.class}: #{e.message}" }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,57 @@
1
+ require "rails/generators/base"
2
+
3
+ module Angarium
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates the Angarium initializer and installs its migrations. Pass " \
9
+ "--database=NAME to run Angarium in its own database (multi-db)."
10
+
11
+ class_option :database, type: :string, aliases: "-d", default: nil, banner: "NAME",
12
+ desc: "Place Angarium in its own database: sets config.database and installs " \
13
+ "migrations into db/NAME_migrate instead of db/migrate"
14
+
15
+ def copy_initializer
16
+ template "initializer.rb", "config/initializers/angarium.rb"
17
+ end
18
+
19
+ # With --database, record config.database in the initializer so a later
20
+ # `angarium:migrations` run (e.g. after a gem upgrade) still targets the
21
+ # right place without the flag.
22
+ def set_database_config
23
+ return unless database
24
+
25
+ gsub_file "config/initializers/angarium.rb", /^\s*#?\s*config\.database\s*=.*$/,
26
+ %( config.database = :#{database})
27
+ end
28
+
29
+ # The single migration path: delegate to the migrations generator, which
30
+ # installs into db/NAME_migrate (multi-db) or db/migrate (primary).
31
+ def install_migrations
32
+ invoke "angarium:migrations", [], database: database
33
+ end
34
+
35
+ def print_next_steps
36
+ if database
37
+ say <<~MSG
38
+ Add the '#{database}' database to config/database.yml (per environment), e.g.:
39
+
40
+ #{database}:
41
+ <<: *default
42
+ database: myapp_#{database}
43
+ migrations_paths: db/#{database}_migrate
44
+
45
+ then run: bin/rails db:migrate:#{database}
46
+ MSG
47
+ else
48
+ say "\nNext: run bin/rails db:migrate"
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def database = options[:database]
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,77 @@
1
+ Angarium.configure do |config|
2
+ # ActiveJob queue used for webhook deliveries.
3
+ # config.job_queue = :default
4
+
5
+ # HTTP read timeout (seconds) per delivery attempt.
6
+ # config.http_timeout = 10
7
+
8
+ # TCP connect timeout (seconds) per delivery attempt.
9
+ # config.open_timeout = 5
10
+
11
+ # User-Agent header sent with each delivery.
12
+ # config.user_agent = "Angarium/#{Angarium::VERSION}"
13
+
14
+ # Backoff schedule between retries. Length = number of retries.
15
+ # Delays between retries (the first delivery is immediate). Standard Webhooks
16
+ # recommended default spans ~10 days; jitter is added per attempt.
17
+ # config.retry_schedule = [5.seconds, 5.minutes, 30.minutes, 2.hours, 5.hours,
18
+ # 10.hours, 14.hours, 20.hours, 24.hours, 36.hours, 48.hours, 72.hours]
19
+
20
+ # Reject endpoint URLs that resolve to private/loopback addresses (SSRF guard).
21
+ # Per-endpoint overrides: endpoint.allow_private_network and endpoint.allowed_networks.
22
+ # config.block_private_ips = true
23
+
24
+ # Primary key type for Angarium's own tables.
25
+ # config.primary_key_type = nil # nil = use the app's default (bigint unless overridden); set :uuid etc. to force
26
+
27
+ # Multi-database: keep Angarium's tables in their own database. Set this to a
28
+ # database name from config/database.yml and Angarium routes all its models and
29
+ # migrations there. `bin/rails g angarium:install --database=NAME` sets this for
30
+ # you; after a gem upgrade, `bin/rails g angarium:migrations` reads it so new
31
+ # migrations still land in db/NAME_migrate. nil (default) uses the primary connection.
32
+ # config.database = :angarium
33
+ #
34
+ # Advanced: for custom roles/shards, pass a hash straight to Rails' connects_to.
35
+ # It wins over config.database for the connection (set config.database too so the
36
+ # migrations generator knows where to install).
37
+ # config.connects_to = { database: { writing: :angarium, reading: :angarium } }
38
+
39
+ # Truncate the stored response body to this many bytes. nil = store the full body.
40
+ # config.max_response_body_bytes = 65_536
41
+
42
+ # Auto-disable an endpoint after this many consecutive failed deliveries. nil = never.
43
+ # config.auto_disable_endpoint_after = nil
44
+
45
+ # Honor a receiver's Retry-After header (seconds or HTTP-date) for the next attempt.
46
+ # config.respect_retry_after = true
47
+
48
+ # Cap (seconds) applied to a honored Retry-After value. nil = uncapped.
49
+ # config.max_retry_after = 3600
50
+
51
+ # Fraction of additive positive jitter applied to each backoff delay.
52
+ # config.retry_jitter = 0.15
53
+
54
+ # Grace window during which a rotated endpoint's previous signing secret stays
55
+ # valid (deliveries are signed with both), so receivers can roll over with no downtime.
56
+ # config.signing_secret_grace_period = 24.hours
57
+
58
+ # Notification callbacks fire on terminal delivery events so you can alert
59
+ # consumers out of band (email, Slack, PagerDuty). A raised callback is logged
60
+ # and swallowed, so it never breaks delivery.
61
+ # config.on_delivery_exhausted = ->(delivery) { } # retry schedule exhausted
62
+ # config.on_endpoint_deactivated = ->(endpoint, reason) { } # reason: :consecutive_failures | :gone (HTTP 410)
63
+ # config.on_endpoint_verified = ->(endpoint) { } # an `unverified` endpoint passed its first delivery
64
+
65
+ # --- Headless JSON API (only used if you `mount Angarium::Engine`) -----------
66
+ # Base controller the API inherits from, so your app's authentication applies.
67
+ # config.parent_controller = "ApplicationController"
68
+
69
+ # Resolve the current user from the controller (your current-user convention).
70
+ # config.current_user = ->(controller) { controller.current_user }
71
+
72
+ # Authorization, all in one class: #scope(relation) (what a user may see),
73
+ # #owner (who a new endpoint belongs to), and #<action>? predicates. Generate a
74
+ # starting point with `bin/rails g angarium:policy`, then override only what you
75
+ # need. Defaults are permissive and single-owner (you manage your own endpoints).
76
+ # config.policy_class = "WebhookEndpointPolicy"
77
+ end
@@ -0,0 +1,39 @@
1
+ require "rails/generators/base"
2
+
3
+ module Angarium
4
+ module Generators
5
+ # Installs (and, after a gem upgrade, refreshes) Angarium's engine migrations.
6
+ # Angarium keeps its migrations in db/angarium_migrate rather than the
7
+ # conventional db/migrate, so Rails never auto-appends them onto the host's
8
+ # primary connection nor generates a generic install:migrations task: this
9
+ # generator is the single install path. It is multi-database aware: it reads
10
+ # config.database (set at install time) so a host that forgets the flag on a
11
+ # later run still gets new migrations in the right place. Uses Rails' native
12
+ # ActiveRecord::Migration.copy, so re-runs are idempotent (already-installed
13
+ # migrations are skipped).
14
+ class MigrationsGenerator < Rails::Generators::Base
15
+ desc "Installs Angarium's migrations. Multi-db setups (config.database or " \
16
+ "--database) get them in db/NAME_migrate; otherwise the primary db/migrate."
17
+
18
+ class_option :database, type: :string, aliases: "-d", default: nil, banner: "NAME",
19
+ desc: "Install into db/NAME_migrate for this database " \
20
+ "(defaults to config.database / connects_to)"
21
+
22
+ def install_migrations
23
+ database = options[:database].presence || Angarium.config.migrations_database
24
+ # The primary connection always migrates from db/migrate; only a separate
25
+ # database gets its own db/<name>_migrate path.
26
+ dir = (database.nil? || database.to_s == "primary") ? "db/migrate" : "db/#{database}_migrate"
27
+ copied = ActiveRecord::Migration.copy(
28
+ File.join(destination_root, dir),
29
+ {"angarium" => Angarium::Engine.root.join("db/angarium_migrate").to_s}
30
+ )
31
+ if copied.any?
32
+ say_status :installed, "#{copied.size} Angarium migration(s) into #{dir}", :green
33
+ else
34
+ say_status :identical, "Angarium migrations already present in #{dir}", :blue
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ require "rails/generators/base"
2
+
3
+ module Angarium
4
+ module Generators
5
+ class PolicyGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates an Angarium API policy (a subclass of Angarium::Api::Policy)."
9
+
10
+ argument :policy_name, type: :string, default: "WebhookEndpointPolicy",
11
+ banner: "NAME", desc: "Policy class name (default: WebhookEndpointPolicy)"
12
+
13
+ def create_policy
14
+ template "policy.rb", File.join("app/policies", "#{policy_name.underscore}.rb")
15
+ end
16
+
17
+ # Point config.policy_class at the generated class, uncommenting (or
18
+ # replacing) the line in the initializer so it takes effect immediately.
19
+ def enable_policy
20
+ initializer = "config/initializers/angarium.rb"
21
+
22
+ unless File.exist?(File.join(destination_root, initializer))
23
+ say_status :skip, %(#{initializer} not found; set config.policy_class = "#{class_name}" yourself), :yellow
24
+ return
25
+ end
26
+
27
+ line = %r{^\s*#?\s*config\.policy_class\s*=.*$}
28
+ if File.read(File.join(destination_root, initializer)).match?(line)
29
+ gsub_file initializer, line, %( config.policy_class = "#{class_name}")
30
+ else
31
+ say_status :skip, %(add config.policy_class = "#{class_name}" to #{initializer}), :yellow
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def class_name = policy_name.camelize
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,33 @@
1
+ # Authorization for Angarium's JSON API. Enable it with
2
+ # config.policy_class = "<%= class_name %>"
3
+ # in config/initializers/angarium.rb.
4
+ #
5
+ # The policy runs in the controller's context, so `current_user`, `params`, and
6
+ # `controller` are available. Override only what you need; the inherited defaults
7
+ # are permissive and single-owner (a user sees and manages their own endpoints).
8
+ class <%= class_name %> < Angarium::Api::Policy
9
+ # Narrow the base relation to the endpoints this user may see and act on.
10
+ # def scope(relation)
11
+ # relation.where(owner: current_user)
12
+ # end
13
+
14
+ # Owner for a newly-created endpoint. Override to create on behalf of another
15
+ # owner (read a param), then gate who may do so in #create?.
16
+ # def owner
17
+ # current_user
18
+ # end
19
+
20
+ # SSRF controls, gated independently (default off). allow_private_network
21
+ # relaxes the private-IP block (dangerous, operators only); allowed_networks
22
+ # only restricts delivery to a CIDR allowlist (safe to expose more widely).
23
+ # def permit_allow_private_network? = current_user.admin?
24
+ # def permit_allowed_networks? = true
25
+
26
+ # Per-action permissions. rotate_secret?/pause?/enable?/ping?/redeliver? all
27
+ # default to update?.
28
+ # def index? = true
29
+ # def show? = true
30
+ # def create? = true
31
+ # def update? = true
32
+ # def destroy? = true
33
+ end
@@ -0,0 +1,15 @@
1
+ namespace :angarium do
2
+ desc "Prune delivery attempts older than Angarium.config.delivery_attempt_retention"
3
+ task prune: :environment do
4
+ retention = Angarium.config.delivery_attempt_retention
5
+ abort "Set Angarium.config.delivery_attempt_retention (e.g. 90.days) before pruning." unless retention
6
+ count = Angarium::DeliveryAttempt.prune(older_than: retention)
7
+ puts "Angarium: pruned #{count} delivery attempt(s) older than #{retention.inspect}."
8
+ end
9
+
10
+ desc "Requeue deliveries stuck in the delivering state past Angarium.config.delivering_timeout"
11
+ task reap: :environment do
12
+ count = Angarium::Delivery.reap_stalled
13
+ puts "Angarium: requeued #{count} stalled deliver(y/ies)."
14
+ end
15
+ end