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
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
9
+
10
+ # `rake standard` / `rake standard:fix` when the linter is in the bundle (dev).
11
+ # `bin/rails test` loads this Rakefile too, and the test-matrix bundles omit
12
+ # standard, so only require it when present. Release tasks live in rakelib/.
13
+ require "standard/rake" if Gem.loaded_specs.key?("standard")
data/SECURITY.md ADDED
@@ -0,0 +1,51 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ Angarium is pre-release. Security fixes are provided for the `0.1.x` series
6
+ during this phase. Once a stable line is released, this policy will be updated to
7
+ list the versions that receive security updates.
8
+
9
+ | Version | Supported |
10
+ | ------- | --------- |
11
+ | 0.1.x | ✅ |
12
+
13
+ ## Reporting a vulnerability
14
+
15
+ Please report security vulnerabilities **privately**. Do **not** open a public
16
+ issue, pull request, or discussion for a suspected vulnerability.
17
+
18
+ Use GitHub's private vulnerability reporting for the
19
+ [`radioactive-labs/angarium`](https://github.com/radioactive-labs/angarium)
20
+ repository: go to the **Security** tab and choose **Report a vulnerability**
21
+ (this opens a private draft advisory visible only to the maintainers).
22
+
23
+ We aim to acknowledge new reports within a few business days, and we'll keep you
24
+ informed as we investigate and prepare a fix. Please give us a reasonable window
25
+ to release a patch before any public disclosure.
26
+
27
+ ## Security surfaces Angarium hardens
28
+
29
+ Angarium is designed to send user-configured webhooks safely. The intended
30
+ security guarantees, and the surfaces you can hold us to, are:
31
+
32
+ - **SSRF protection.** Endpoint URLs are user-supplied, so delivery is guarded by
33
+ an address policy that blocks private/loopback/link-local addresses by default,
34
+ requires an explicit per-endpoint opt-in for private networks, and **fails
35
+ closed** on hosts it can't resolve.
36
+ - **Connect-time IP pinning.** Deliveries pin the connection to the validated
37
+ resolved address, closing the DNS-rebinding window between resolution and
38
+ connect (TLS SNI and certificate verification still use the original hostname).
39
+ - **Encrypted secrets at rest.** Endpoint signing secrets (current and
40
+ in-rotation previous) and `custom_headers` (which commonly carries a receiver
41
+ credential such as an Authorization bearer token) are encrypted at rest with
42
+ Active Record Encryption.
43
+ - **Standard Webhooks HMAC signing.** Requests are signed per the
44
+ [Standard Webhooks](https://www.standardwebhooks.com) spec (HMAC-SHA256 over
45
+ `{id}.{timestamp}.{body}`) with a timestamp tolerance enforced on verification
46
+ to resist replay.
47
+ - **Custom-header denylist.** Per-endpoint custom headers cannot override the
48
+ signature headers or dangerous transport headers (request-smuggling and
49
+ receiver-confusion surface).
50
+
51
+ If you find a gap in any of these guarantees, we especially want to hear about it.
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/angarium .css
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,12 @@
1
+ module Angarium
2
+ module Api
3
+ class AttemptsController < BaseController
4
+ # GET /deliveries/:delivery_id/attempts
5
+ def index
6
+ delivery = scoped_delivery(params[:delivery_id])
7
+ authorize!(delivery)
8
+ render_collection(:attempts, delivery.delivery_attempts.order(created_at: :desc)) { |a| attempt_json(a) }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,133 @@
1
+ module Angarium
2
+ module Api
3
+ # Base controller for the headless JSON API. Inherits from your app's
4
+ # controller (config.parent_controller, default "ApplicationController"), so
5
+ # your existing authentication applies here too.
6
+ class BaseController < Angarium.config.parent_controller.constantize
7
+ # These endpoints authenticate via your current-user convention (a session
8
+ # or token), not a form CSRF token. If we inherited a forgery-protecting
9
+ # base (ActionController::Base), don't reject API POSTs for a missing token.
10
+ protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery)
11
+
12
+ before_action :authenticate_angarium_user!
13
+
14
+ rescue_from ActiveRecord::RecordNotFound, with: :angarium_render_not_found
15
+ rescue_from ActiveRecord::RecordInvalid, with: :angarium_render_invalid
16
+ rescue_from Angarium::Api::NotAuthorized, with: :angarium_render_forbidden
17
+ rescue_from Angarium::Api::UnpermittedParameter, with: :angarium_render_unpermitted
18
+
19
+ # Resolved current user (via config.current_user). Public so policies can
20
+ # read it as `controller.angarium_current_user`.
21
+ def angarium_current_user
22
+ return @angarium_current_user if defined?(@angarium_current_user)
23
+
24
+ @angarium_current_user = Angarium.config.current_user.call(self)
25
+ end
26
+
27
+ private
28
+
29
+ def authenticate_angarium_user!
30
+ angarium_render_unauthorized unless angarium_current_user
31
+ end
32
+
33
+ # The policy for this request (config.policy_class), holding scope,
34
+ # create-owner, and per-action permissions.
35
+ def angarium_policy(record = nil)
36
+ Angarium.config.policy_class.to_s.constantize.new(self, record)
37
+ end
38
+
39
+ # The endpoints this user may see/act on: the policy narrows the base relation.
40
+ def endpoint_scope
41
+ angarium_policy.scope(Angarium::Endpoint.all)
42
+ end
43
+
44
+ # A delivery whose endpoint is within the caller's scope, or 404.
45
+ def scoped_delivery(id)
46
+ Angarium::Delivery.where(endpoint_id: endpoint_scope.select(:id)).find(id)
47
+ end
48
+
49
+ # Guard the current action with the policy's `<action>?` predicate.
50
+ def authorize!(record = nil)
51
+ raise Angarium::Api::NotAuthorized unless angarium_policy(record).public_send("#{action_name}?")
52
+ end
53
+
54
+ # Render a paginated list. Applies ?limit (default 50, max 200) and ?offset,
55
+ # and advertises the window with a `pagination` object so clients can page
56
+ # (there's more when offset + count < total).
57
+ def render_collection(key, relation, &serializer)
58
+ limit = params.fetch(:limit, 50).to_i.clamp(1, 200)
59
+ offset = [params.fetch(:offset, 0).to_i, 0].max
60
+ records = relation.limit(limit).offset(offset).to_a
61
+ render json: {
62
+ key => records.map(&serializer),
63
+ :pagination => {limit: limit, offset: offset, count: records.size, total: relation.count}
64
+ }
65
+ end
66
+
67
+ def render_error(status, message, **extra)
68
+ render json: {error: message, **extra}, status: status
69
+ end
70
+
71
+ def angarium_render_unauthorized = render_error(:unauthorized, "authentication required")
72
+ def angarium_render_forbidden = render_error(:forbidden, "not authorized")
73
+ def angarium_render_not_found = render_error(:not_found, "not found")
74
+
75
+ def angarium_render_invalid(error)
76
+ render_error(:unprocessable_entity, "validation failed", details: error.record.errors.full_messages)
77
+ end
78
+
79
+ def angarium_render_unpermitted(error)
80
+ render_error(:unprocessable_entity, "unpermitted parameter", details: [error.message])
81
+ end
82
+
83
+ # --- serializers ----------------------------------------------------------
84
+ # signing_secret and custom_headers are never echoed in responses (they're
85
+ # encrypted at rest and may carry credentials); the secret is revealed only
86
+ # on create and rotate_secret.
87
+
88
+ def endpoint_json(endpoint, include_secret: false)
89
+ json = {
90
+ id: endpoint.id,
91
+ name: endpoint.name,
92
+ url: endpoint.url,
93
+ status: endpoint.status,
94
+ subscribed_events: endpoint.subscribed_events,
95
+ allow_private_network: endpoint.allow_private_network,
96
+ allowed_networks: endpoint.allowed_networks,
97
+ consecutive_failures: endpoint.consecutive_failures,
98
+ status_changed_at: endpoint.status_changed_at&.iso8601,
99
+ created_at: endpoint.created_at.iso8601,
100
+ updated_at: endpoint.updated_at.iso8601
101
+ }
102
+ json[:signing_secret] = endpoint.signing_secret if include_secret
103
+ json
104
+ end
105
+
106
+ def delivery_json(delivery)
107
+ {
108
+ id: delivery.id,
109
+ endpoint_id: delivery.endpoint_id,
110
+ event: delivery.event.name,
111
+ state: delivery.state,
112
+ attempt_count: delivery.attempt_count,
113
+ next_attempt_at: delivery.next_attempt_at&.iso8601,
114
+ last_attempt_at: delivery.last_attempt_at&.iso8601,
115
+ created_at: delivery.created_at.iso8601,
116
+ updated_at: delivery.updated_at.iso8601
117
+ }
118
+ end
119
+
120
+ def attempt_json(attempt)
121
+ {
122
+ id: attempt.id,
123
+ delivery_id: attempt.delivery_id,
124
+ response_code: attempt.response_code,
125
+ response_body: attempt.response_body,
126
+ error: attempt.error,
127
+ duration: attempt.duration,
128
+ created_at: attempt.created_at.iso8601
129
+ }
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,29 @@
1
+ module Angarium
2
+ module Api
3
+ class DeliveriesController < BaseController
4
+ # GET /endpoints/:endpoint_id/deliveries
5
+ def index
6
+ endpoint = endpoint_scope.find(params[:endpoint_id])
7
+ authorize!(endpoint)
8
+ render_collection(:deliveries, endpoint.deliveries.includes(:event).order(created_at: :desc)) { |d| delivery_json(d) }
9
+ end
10
+
11
+ # GET /deliveries/:id
12
+ # Attempts are fetched separately (and paginated) via
13
+ # GET /deliveries/:id/attempts.
14
+ def show
15
+ delivery = scoped_delivery(params[:id])
16
+ authorize!(delivery)
17
+ render json: {delivery: delivery_json(delivery)}
18
+ end
19
+
20
+ # POST /deliveries/:id/redeliver
21
+ def redeliver
22
+ delivery = scoped_delivery(params[:id])
23
+ authorize!(delivery)
24
+ delivery.redeliver!
25
+ render json: {delivery: delivery_json(delivery)}, status: :accepted
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,117 @@
1
+ module Angarium
2
+ module Api
3
+ class EndpointsController < BaseController
4
+ before_action :set_endpoint, only: %i[show update destroy rotate_secret pause enable verify ping]
5
+
6
+ def index
7
+ authorize!
8
+ render_collection(:endpoints, endpoint_scope.order(created_at: :desc)) { |e| endpoint_json(e) }
9
+ end
10
+
11
+ def show
12
+ authorize!(@endpoint)
13
+ render json: {endpoint: endpoint_json(@endpoint)}
14
+ end
15
+
16
+ def create
17
+ # The owner comes from the policy's #owner (default: current_user), set
18
+ # before authorize! so policy #create? can gate the target owner.
19
+ endpoint = Angarium::Endpoint.new(endpoint_params)
20
+ endpoint.owner = angarium_policy.owner
21
+ endpoint.status = :unverified if angarium_policy.create_unverified?
22
+ authorize!(endpoint)
23
+ endpoint.save!
24
+ # The signing secret is revealed once, on creation.
25
+ render json: {endpoint: endpoint_json(endpoint, include_secret: true)}, status: :created
26
+ end
27
+
28
+ def update
29
+ authorize!(@endpoint)
30
+ @endpoint.update!(endpoint_params)
31
+ render json: {endpoint: endpoint_json(@endpoint)}
32
+ end
33
+
34
+ def destroy
35
+ authorize!(@endpoint)
36
+ @endpoint.destroy!
37
+ head :no_content
38
+ end
39
+
40
+ def rotate_secret
41
+ authorize!(@endpoint)
42
+ secret = @endpoint.rotate_secret!
43
+ render json: {endpoint: endpoint_json(@endpoint), signing_secret: secret}
44
+ end
45
+
46
+ def pause
47
+ authorize!(@endpoint)
48
+ @endpoint.pause!
49
+ render json: {endpoint: endpoint_json(@endpoint)}
50
+ end
51
+
52
+ def enable
53
+ authorize!(@endpoint)
54
+ @endpoint.enable!
55
+ render json: {endpoint: endpoint_json(@endpoint)}
56
+ end
57
+
58
+ def verify
59
+ authorize!(@endpoint)
60
+ @endpoint.verify!
61
+ render json: {endpoint: endpoint_json(@endpoint)}
62
+ end
63
+
64
+ def ping
65
+ authorize!(@endpoint)
66
+ delivery = @endpoint.ping!
67
+ render json: {delivery: delivery_json(delivery)}, status: :accepted
68
+ end
69
+
70
+ private
71
+
72
+ def set_endpoint
73
+ @endpoint = endpoint_scope.find(params[:id])
74
+ end
75
+
76
+ # SSRF-relevant controls, each gated by its own policy predicate. They are
77
+ # independent: allow_private_network relaxes the private-IP denylist
78
+ # (dangerous), while allowed_networks only restricts delivery to a CIDR
79
+ # allowlist. permit shape per attribute (scalar vs array).
80
+ NETWORK_CONTROLS = {
81
+ allow_private_network: :permit_allow_private_network?,
82
+ allowed_networks: :permit_allowed_networks?
83
+ }.freeze
84
+
85
+ def endpoint_params
86
+ policy = angarium_policy
87
+ permitted = [:name, :url, {subscribed_events: [], custom_headers: {}}]
88
+
89
+ NETWORK_CONTROLS.each do |attr, predicate|
90
+ if policy.public_send(predicate)
91
+ permitted << ((attr == :allowed_networks) ? {allowed_networks: []} : attr)
92
+ else
93
+ reject_network_control_change!(attr)
94
+ end
95
+ end
96
+
97
+ params.require(:endpoint).permit(*permitted)
98
+ end
99
+
100
+ # A request may not change a network control the policy doesn't permit.
101
+ # Attempting to (a submitted value that differs from the record's current
102
+ # value) is a 422 naming the attribute, so an escalation attempt fails
103
+ # loudly rather than being silently ignored. Echoing the current value is a
104
+ # no-op and allowed, so a client can round-trip the serialized endpoint.
105
+ def reject_network_control_change!(attr)
106
+ body = params.fetch(:endpoint, ActionController::Parameters.new)
107
+ return unless body.key?(attr)
108
+
109
+ current = (@endpoint || Angarium::Endpoint.new).public_send(attr)
110
+ submitted = body[attr]
111
+ submitted = Array(submitted).map(&:to_s) if attr == :allowed_networks
112
+
113
+ raise Angarium::Api::UnpermittedParameter, attr unless submitted == current
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,7 @@
1
+ module Angarium
2
+ module Api
3
+ # Raised by a controller when the configured policy denies an action.
4
+ # Rendered as 403 by Angarium::Api::BaseController.
5
+ class NotAuthorized < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ module Angarium
2
+ module Api
3
+ # Raised when a request tries to change an attribute the policy doesn't permit
4
+ # (e.g. a privileged SSRF control), so the caller fails loudly with a 422
5
+ # naming the attribute instead of the change being silently dropped.
6
+ class UnpermittedParameter < StandardError
7
+ attr_reader :attribute
8
+
9
+ def initialize(attribute)
10
+ @attribute = attribute
11
+ super("#{attribute} is not permitted")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,4 @@
1
+ module Angarium
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,5 @@
1
+ module Angarium
2
+ class ApplicationJob < ActiveJob::Base
3
+ queue_as { Angarium.config.job_queue }
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ module Angarium
2
+ class DeliverJob < ApplicationJob
3
+ def perform(delivery_id, force = false)
4
+ delivery = Delivery.find_by(id: delivery_id)
5
+ return unless delivery
6
+ return unless delivery.pending?
7
+
8
+ delivery.deliver!(force: force)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ module Angarium
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+
5
+ # Multi-database support: point every Angarium table at a separate database
6
+ # instead of the app's primary connection. The host sets either config.database
7
+ # (a database name; the common case) or config.connects_to (a raw hash for
8
+ # custom roles/shards, which wins if both are set). Read once here at class
9
+ # load: config/initializers run before the models are first used or
10
+ # eager-loaded, so the setting is in place by then.
11
+ if Angarium.config.connects_to
12
+ connects_to(**Angarium.config.connects_to)
13
+ elsif (db = Angarium.config.database)
14
+ connects_to database: {writing: db, reading: db}
15
+ end
16
+ end
17
+ end