activestorage-multi_node_disk 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 (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/CONTRIBUTING.md +89 -0
  4. data/LICENSE +21 -0
  5. data/README.md +249 -0
  6. data/SECURITY.md +33 -0
  7. data/app/controllers/active_storage/multi_node_disk/blobs_controller.rb +166 -0
  8. data/config/routes.rb +10 -0
  9. data/lib/active_storage/multi_node_disk/audit.rb +15 -0
  10. data/lib/active_storage/multi_node_disk/audit_log.rb +48 -0
  11. data/lib/active_storage/multi_node_disk/configuration.rb +217 -0
  12. data/lib/active_storage/multi_node_disk/configuration_error.rb +8 -0
  13. data/lib/active_storage/multi_node_disk/delete_job.rb +27 -0
  14. data/lib/active_storage/multi_node_disk/engine.rb +24 -0
  15. data/lib/active_storage/multi_node_disk/fan_out.rb +76 -0
  16. data/lib/active_storage/multi_node_disk/file_error.rb +36 -0
  17. data/lib/active_storage/multi_node_disk/file_sink.rb +38 -0
  18. data/lib/active_storage/multi_node_disk/mismatch.rb +15 -0
  19. data/lib/active_storage/multi_node_disk/net_http_client.rb +31 -0
  20. data/lib/active_storage/multi_node_disk/node_addresses.rb +50 -0
  21. data/lib/active_storage/multi_node_disk/not_here.rb +15 -0
  22. data/lib/active_storage/multi_node_disk/requester.rb +50 -0
  23. data/lib/active_storage/multi_node_disk/response.rb +16 -0
  24. data/lib/active_storage/multi_node_disk/test_helper.rb +97 -0
  25. data/lib/active_storage/multi_node_disk/unavailable.rb +16 -0
  26. data/lib/active_storage/multi_node_disk/version.rb +7 -0
  27. data/lib/active_storage/multi_node_disk.rb +172 -0
  28. data/lib/active_storage/service/multi_node_disk_service.rb +322 -0
  29. data/lib/activestorage-multi_node_disk.rb +4 -0
  30. data/lib/generators/active_storage/multi_node_disk/install_generator.rb +47 -0
  31. data/lib/generators/active_storage/multi_node_disk/templates/create_audits.rb.tt +33 -0
  32. data/lib/generators/active_storage/multi_node_disk/templates/initializer.rb.tt +26 -0
  33. metadata +141 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d354671f0910267b48241ec1225870aa5ac97a5b3fd1e484f4f8fe7523a4be43
4
+ data.tar.gz: ccbe3a767a0cc540727d421e4779bf8014194ea7746d14731552872f54a0f96d
5
+ SHA512:
6
+ metadata.gz: 8bc81e5ecbbe8b81c8b0a9e1bee4c8112644e366fc0f68632d0a8ccf6cdf23745215b34374e202fcf9dd7cb7f107bce780ee7ba60a24da1a420bff4f4a5db507
7
+ data.tar.gz: 90c49f600de770e09b48ac0fe78bba4c6514a4cf8f72d018f6b2a223325b03ddb5651e14ad72635302cabd62357bf9e7ea932f810debea9e5e107d439367dee4
data/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ All notable changes to activestorage-multi_node_disk are listed here. The
4
+ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
+ the project uses [Semantic Versioning](https://semver.org/). Before 1.0, a
6
+ minor version may change public APIs; each entry says how.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-20
11
+
12
+ The first release.
13
+
14
+ ### Added
15
+
16
+ - `ActiveStorage::Service::MultiNodeDiskService` (`service: MultiNodeDisk`), a
17
+ Disk service for several nodes: uploads stay on the receiving node's disk;
18
+ reads, `exist?` and deletes ask the other nodes (`nodes:`) in parallel under
19
+ one deadline, and the first copy that matches the blob's size and checksum
20
+ wins. No migration, no metadata, no backfill.
21
+ - A node endpoint (`/rails/active_storage/multi_node_disk/blobs/:key`, `GET`,
22
+ `HEAD`, `DELETE`) in an isolated engine, mounted under Active Storage's
23
+ routes prefix ahead of the app's routes (`draw_routes`). It serves its own
24
+ disk only, so a hop never fans out.
25
+ - Request authentication: `auth` `:none` (default, with a boot warning in
26
+ production), `:token` (Bearer, `secure_compare`), `:ip` (the nodes' DNS
27
+ addresses with a 60-second cache, or IPs/CIDRs) and `[:ip, :token]`.
28
+ - Errors that subclass Active Storage's: `NotHere`, `Unavailable`,
29
+ `Mismatch`; `error_mapper` for host classes.
30
+ - Delete modes: `:sync` (raises `Unavailable` for a node that did not
31
+ confirm) and `:job` (`DeleteJob`, retried, idempotent; Active Job optional).
32
+ - Audit: one versioned JSON document per answered request and per remote
33
+ fetch or delete, to a JSON Lines file (default), a table, nowhere or a
34
+ callable; `audit_requester`, `audit_context`, `with_requester` and the
35
+ requester header; `audit.multi_node_disk` events.
36
+ - `bin/rails g active_storage:multi_node_disk:install [--audit]`: the
37
+ initializer and the audit table's migration (PostgreSQL, MySQL 8, Oracle,
38
+ SQLite; names within 30 characters; `table_name_prefix`).
39
+ - `ActiveStorage::MultiNodeDisk::TestHelper`: fake nodes answered by the real
40
+ endpoint in process.
41
+
42
+ [Unreleased]: https://github.com/clearstackio/activestorage-multi_node_disk/compare/v0.1.0...HEAD
43
+ [0.1.0]: https://github.com/clearstackio/activestorage-multi_node_disk/releases/tag/v0.1.0
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,89 @@
1
+ # Contributing
2
+
3
+ Thanks for working on activestorage-multi_node_disk. Read
4
+ [AGENTS.md](AGENTS.md) first: it is the source of truth for the gem's scope,
5
+ shape and rules, for people and coding agents alike.
6
+
7
+ Open an issue before a large change, so we can agree on the shape first.
8
+ Pull requests need a green CI and a CHANGELOG line for anything a host would
9
+ notice.
10
+
11
+ ## Setup
12
+
13
+ Tools are pinned in `mise.toml` (Ruby 4.0.7; the gem supports 3.3+).
14
+
15
+ ```sh
16
+ bin/setup # mise install, bundle install, rake
17
+ ```
18
+
19
+ ## Running the checks
20
+
21
+ ```sh
22
+ bundle exec rake # specs, StandardRB, RBS syntax
23
+ bundle exec rspec spec/requests # one folder
24
+ DATABASE_URL=postgres://user:pw@127.0.0.1/scratch bundle exec rspec # on PostgreSQL
25
+ DATABASE_URL=trilogy://root:pw@127.0.0.1:3306/scratch bundle exec rspec # on MySQL 8
26
+ ```
27
+
28
+ Without `DATABASE_URL` the dummy app (`spec/dummy`) uses SQLite. Use a scratch
29
+ database only: the suite creates and drops tables.
30
+
31
+ ## Gem conventions
32
+
33
+ Current RubyGems and Bundler practice, as applied here:
34
+
35
+ - **gemspec:** name, version from `lib/active_storage/multi_node_disk/version.rb`,
36
+ summary, description, homepage, MIT, `required_ruby_version >= 3.3`;
37
+ metadata `homepage_uri`, `source_code_uri`, `changelog_uri`,
38
+ `bug_tracker_uri`, `documentation_uri`, `rubygems_mfa_required` and
39
+ `allowed_push_host` (rubygems.org); `spec.files` from `git ls-files`
40
+ without specs, sigs, `bin/` and dot files.
41
+ - **Dependencies:** runtime only `activestorage`, `actionpack`, `railties`
42
+ (`>= 8.1, < 9`); development gems in the Gemfile. `Gemfile.lock` is
43
+ committed (Bundler's current guidance). Dependabot proposes updates weekly.
44
+ - **Code:** `lib/activestorage-multi_node_disk.rb` (Bundler's auto-require)
45
+ requires `active_storage/multi_node_disk`, which sets up
46
+ `Zeitwerk::Loader.for_gem_extension(ActiveStorage)`; the service file is
47
+ left to Active Storage's Configurator. `# frozen_string_literal: true`
48
+ everywhere; one concern per file; a header comment on every class; YARD on
49
+ public methods. RBS signatures in `sig/` (`rake rbs` checks the syntax; a
50
+ full type check needs Rails signatures).
51
+ - **Style:** StandardRB, targeting Ruby 3.3 (`.standard.yml`); no
52
+ `.rubocop.yml`.
53
+ - **Versions:** SemVer from 0.1.0; [Keep a Changelog](CHANGELOG.md): add a
54
+ line under `[Unreleased]` for anything a host would notice.
55
+ - **CI:** GitHub Actions (`.github/workflows/ci.yml`): `rake` on Ruby 3.3,
56
+ 3.4 and 4.0, then the suite on PostgreSQL 17 and MySQL 8. Actions are
57
+ pinned to commit SHAs. Oracle is not run in CI: the audit migration is
58
+ rendered and its names checked by the suite.
59
+ - **Security:** no secrets in the repo; see [SECURITY.md](SECURITY.md).
60
+
61
+ ## Releasing
62
+
63
+ Releases go to rubygems.org from GitHub Actions with
64
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing/) (no
65
+ API key) and a Sigstore attestation (`.github/workflows/release.yml`, in the
66
+ `rubygems` environment).
67
+
68
+ Before the first release, an owner of the gem adds a pending trusted
69
+ publisher on rubygems.org (Profile → Trusted publishers): gem
70
+ `activestorage-multi_node_disk`, repository owner `clearstackio`, repository
71
+ `activestorage-multi_node_disk`, workflow `release.yml`, environment
72
+ `rubygems`. After the first push it becomes the gem's trusted publisher.
73
+
74
+ 1. Update `VERSION` in `lib/active_storage/multi_node_disk/version.rb` and
75
+ run `bundle install` so `Gemfile.lock` follows.
76
+ 2. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version
77
+ and date, and update the compare links at the bottom.
78
+ 3. Commit, then tag and push: `git tag v0.1.0 && git push origin master v0.1.0`.
79
+
80
+ The workflow checks the tag matches `VERSION`, runs `rake`, then builds,
81
+ pushes and attests the gem.
82
+
83
+ ## Tests
84
+
85
+ Every behaviour gets a spec: the service against fake nodes
86
+ (`spec/active_storage/service`), the endpoint and routing (`spec/requests`),
87
+ configuration, audit and the generator (`spec/active_storage/multi_node_disk`,
88
+ `spec/generators`). Specs test behaviour, not layout. Multi-node behaviour
89
+ uses `ActiveStorage::MultiNodeDisk::TestHelper`, never the network.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ajaya Agrawalla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # activestorage-multi_node_disk
2
+
3
+ An Active Storage Disk service for Rails apps that run on several nodes, each with its own disk.
4
+
5
+ A file stays on the disk of the node that received it. Any node can read, check or delete it: the service looks on its own disk first, then asks the other nodes and keeps the first copy that matches the blob. Zero migration by default: nothing is recorded, so existing blobs work as they are.
6
+
7
+ [![Build Status](https://github.com/clearstackio/activestorage-multi_node_disk/actions/workflows/ci.yml/badge.svg)](https://github.com/clearstackio/activestorage-multi_node_disk/actions)
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application’s Gemfile:
12
+
13
+ ```ruby
14
+ gem "activestorage-multi_node_disk"
15
+ ```
16
+
17
+ ## Getting Started
18
+
19
+ Replace `service: Disk` with `service: MultiNodeDisk` and list the nodes:
20
+
21
+ ```yml
22
+ local:
23
+ service: MultiNodeDisk
24
+ root: <%= Rails.root.join("storage") %>
25
+ nodes: [app1.internal, app2.internal, app3.internal]
26
+ ```
27
+
28
+ Give the nodes a shared token and turn on token checks:
29
+
30
+ ```sh
31
+ bin/rails credentials:edit # multi_node_disk: { token: <the same random value on every node> }
32
+ ```
33
+
34
+ ```ruby
35
+ # config/initializers/active_storage_multi_node_disk.rb
36
+ ActiveStorage::MultiNodeDisk.configure do |config|
37
+ config.auth = :token
38
+ end
39
+ ```
40
+
41
+ That’s it. `bin/rails g active_storage:multi_node_disk:install` writes that initializer for you.
42
+
43
+ > **Warning:** the endpoint checks nothing by default (`auth = :none`): anyone who can reach a node can read and delete its files. Use `:token` (or `[:ip, :token]`) anywhere but a closed network. The gem logs a warning at boot in production while it is `:none`.
44
+
45
+ ## How It Works
46
+
47
+ - **Upload** - written to this node’s disk, exactly as Disk
48
+ - **Read** (`download`, `download_chunk`, `open`) - this disk first; else every other node is asked at once, under one deadline (`open_timeout + read_timeout`), and the first copy whose size and checksum match the blob wins. A copy that does not match is skipped
49
+ - **`exist?`** - this disk, else whether any node has the file (presence, as Disk)
50
+ - **Delete** - this disk, then every other node; a 404 counts as done
51
+ - **The endpoint** - each node answers other nodes at `/rails/active_storage/multi_node_disk/blobs/:key` (`GET`, `HEAD`, `DELETE`), from its own disk only. A request that arrives from another node never fans out again
52
+
53
+ A whole-file `download` is also checked against the blob’s size and checksum when the file is local, and `open` keeps Rails’ own check.
54
+
55
+ Local only, as Disk: `url` (use Active Storage’s proxy mode, which reads through `download`), `compose` and `delete_prefixed` (variants, which any node can regenerate). Keys with no blob row (variants) are never looked for elsewhere.
56
+
57
+ ### Cost
58
+
59
+ A local hit costs a `File.exist?` (plus one indexed blob lookup for a whole-file `download`). A miss on N nodes sends N - 1 requests, at most 8 at a time per call; only the node with the file sends it, the others answer 404 after a `File.exist?`. A delete always sends N - 1 requests.
60
+
61
+ ## Errors
62
+
63
+ The service raises subclasses of Active Storage’s own errors, so Rails keeps handling them:
64
+
65
+ Error | Superclass | When
66
+ --- | --- | ---
67
+ `ActiveStorage::MultiNodeDisk::NotHere` | `ActiveStorage::FileNotFoundError` | every node answered "not here"
68
+ `ActiveStorage::MultiNodeDisk::Unavailable` | `ActiveStorage::Error` | a node that may have it did not answer (retry)
69
+ `ActiveStorage::MultiNodeDisk::Mismatch` | `ActiveStorage::IntegrityError` | the only copies found do not match the blob
70
+
71
+ Each has `nodes`, `key` and `filename`. When no copy wins: any node failed → `Unavailable`; else a wrong copy → `Mismatch`; else `NotHere`.
72
+
73
+ ## Deletes
74
+
75
+ `delete_mode` decides how a delete reaches the other nodes; the local copy is always deleted at once.
76
+
77
+ - `:sync` (default) - inline, after which `Unavailable` names any node that did not confirm (as other services raise on a failed delete). Rails’ `purge` removes the rows first, so the rows are gone either way
78
+ - `:job` - `ActiveStorage::MultiNodeDisk::DeleteJob`, retried while a node does not confirm, idempotent. Needs Active Job (checked at boot)
79
+
80
+ Rails’ `purge_later` already runs `purge` in `ActiveStorage::PurgeJob`; with `:job`, that job deletes the local copy and queues `DeleteJob` for the rest.
81
+
82
+ ```ruby
83
+ config.delete_mode = :job
84
+ config.delete_job_queue = :maintenance # default: Active Storage's purge queue
85
+ config.delete_job_class = "FileDeleteJob" # a subclass, for other retries
86
+ ```
87
+
88
+ ## Request Authentication
89
+
90
+ `auth` | The endpoint needs
91
+ --- | ---
92
+ `:none` (default) | nothing
93
+ `:token` | `Authorization: Bearer <token>`; else 401 with `WWW-Authenticate: Bearer realm="Multi-node disk"`
94
+ `:ip` | the caller in `allowed_ips`; else 403
95
+ `[:ip, :token]` | both (the address is checked first)
96
+
97
+ Outgoing calls send the Bearer token only under `:token`.
98
+
99
+ `allowed_ips` defaults to every node’s addresses, resolved by DNS and resolved again every 60 seconds (a node that fails to resolve keeps its last addresses). Set it to IPs or CIDRs to override. The caller is the socket’s peer; `X-Forwarded-For` counts only when that peer is listed in `config.action_dispatch.trusted_proxies`, so set it to your proxies when one sits in front of the app.
100
+
101
+ The gem checks who is calling, never who may see a file: that is your app’s business, on its own pages.
102
+
103
+ ## Audit
104
+
105
+ Every answered request (served, refused, deleted) and every remote fetch or delete is one JSON document:
106
+
107
+ ```json
108
+ {"v":1,"at":"2026-10-17T12:00:00.000Z","action":"read","result":"served","status":200,"key":"abc","filename":"members.csv",
109
+ "byte_size":22,"node":"app2","from_node":"app1","remote_ip":"10.0.0.1","auth":["token"],"identity":"node","hop":true,
110
+ "duration_ms":3.1,"requester":{"id":42,"label":"hr@example.com","type":"hr_admin"},"requester_verified":true,"context":{"source_id":7}}
111
+ ```
112
+
113
+ `action` is `read`, `head`, `delete`, `fetch` or `remote_delete`; `result` is `served`, `deleted`, `not_found`, `mismatch`, `unauthorized`, `forbidden` or `error`. It never holds a token, file contents or a signed URL. The same document is instrumented as `audit.multi_node_disk`.
114
+
115
+ `audit` | Where
116
+ --- | ---
117
+ `:file` (default) | a JSON line appended to `audit_path` (`tmp/multi_node_disk/audit.log`), whole lines across threads and processes. Rotate it with logrotate (`copytruncate`), or rename it: the next write starts a new file
118
+ `:table` | a row in `multi_node_disk_audits` (`id`, `created_at`, `payload`); run `bin/rails g active_storage:multi_node_disk:install --audit` and migrate
119
+ `:none` | nowhere
120
+ a callable | `->(entry) { ... }`
121
+
122
+ A sink that fails is logged and never fails the request.
123
+
124
+ ### Who it is for
125
+
126
+ ```ruby
127
+ config.audit_requester = ->(request) { {id: Current.user&.id, label: Current.user&.email, type: "staff"} }
128
+ config.audit_context = ->(request, entry) { {record: Upload.find_by_blob_key(entry[:key])&.id} }
129
+ ```
130
+
131
+ Work that serves no request (a job) names the requester for a block:
132
+
133
+ ```ruby
134
+ ActiveStorage::MultiNodeDisk.with_requester(id: user.id, label: user.email) do
135
+ blob.open { |file| import(file) }
136
+ end
137
+ ```
138
+
139
+ The requester is sent to the node that answers (`X-Multi-Node-Disk-Requester`, JSON, at most 1 KB; larger is dropped) and recorded there; that node marks it `requester_verified: true` only when a check (`:token` or `:ip`) passed. Never put a secret in it.
140
+
141
+ ### The audit table
142
+
143
+ The generator writes a migration named `CreateActiveStorageMultiNodeDiskAudits`; nothing runs on its own. It is safe to run twice and reversible.
144
+
145
+ Database | `payload` | Tested
146
+ --- | --- | ---
147
+ PostgreSQL | `jsonb` | suite
148
+ MySQL 8 | `json` | suite
149
+ SQLite | `text` | suite
150
+ Oracle | CLOB (`text`) | migration rendered and names checked; not run
151
+
152
+ The only index is on `created_at` (`i_mnd_audits_created_at`), for pruning. Every name fits Oracle’s 30 characters. `table_name_prefix` (default `multi_node_disk_`) changes the table and index names.
153
+
154
+ ## Testing Your App
155
+
156
+ ```ruby
157
+ RSpec.configure { |config| config.include ActiveStorage::MultiNodeDisk::TestHelper }
158
+
159
+ it "reads a file another node holds" do
160
+ node_b = multi_node_disk_node("app2")
161
+ multi_node_disk_move(blob, to: node_b)
162
+
163
+ multi_node_disk_cluster("app2" => node_b, "app3" => :missing) do |requests|
164
+ expect(blob.download).to eq(bytes)
165
+ end
166
+ end
167
+ ```
168
+
169
+ A node in the cluster is a service (answered by the gem’s real endpoint, in process), `:missing`, `:wrong`, `:timeout` or `:down`. `requests` lists method, node, URL and header names, never values.
170
+
171
+ ## Advanced
172
+
173
+ Set hooks once, in an initializer. A hook is a value or anything that responds to `call`. The configuration is validated and frozen after boot.
174
+
175
+ Option | Default | Notes
176
+ --- | --- | ---
177
+ `nodes` | `nodes:` in storage.yml | `Array` or `-> Array`; this node may be in it
178
+ `current_node` | `Socket.gethostname` | `node:` in storage.yml wins
179
+ `url_for` | `->(node, key) { "https://#{node}/rails/active_storage/multi_node_disk/blobs/#{key}" }` | must return `https:`
180
+ `auth` | `:none` | per service in storage.yml too
181
+ `allowed_ips` | nodes’ DNS addresses | per service in storage.yml too
182
+ `token` | credentials `multi_node_disk.token` | what `:token` compares against
183
+ `token_for` | `->(node, blob, action) { token }` | the token sent; `blob` is nil for a delete after the rows went
184
+ `authenticate` | `->(token, request) { :node if token matches }` | returns an identity label, or nil to refuse
185
+ `http_client` | `ActiveStorage::MultiNodeDisk::NetHttpClient` | `call(method, url, headers:, node:, open_timeout:, read_timeout:)` → `Response(status, body, headers)`; called from several threads at once
186
+ `open_timeout`, `read_timeout` | 3, 20 | seconds; per service in storage.yml too
187
+ `hop_header` | `X-Multi-Node-Disk-Hop` | carries the calling node’s name
188
+ `requester_header` | `X-Multi-Node-Disk-Requester` |
189
+ `error_mapper` | `->(error) { error }` | raise your own classes (subclass the gem’s)
190
+ `delete_mode`, `delete_job_queue`, `delete_job_class` | `:sync`, purge queue, `DeleteJob` |
191
+ `audit`, `audit_path`, `audit_context`, `audit_requester` | `:file`, `tmp/multi_node_disk/audit.log`, nil, nil | `audit_context(request or nil, entry)`, `audit_requester(request or nil)`
192
+ `table_name_prefix` | `multi_node_disk_` |
193
+ `draw_routes` | `true` | mounts the endpoint under Active Storage’s routes prefix, ahead of your routes; independent of `config.active_storage.draw_routes`
194
+
195
+ Events (`ActiveSupport::Notifications`): `fetch`, `exist`, `delete`, `mismatch`, `serve`, `denied`, `audit` and `delete_abandoned`, each `.multi_node_disk`, plus Active Storage’s own `service_*.active_storage`.
196
+
197
+ ### Security
198
+
199
+ - Keep the token in credentials or your secrets store; `filter_parameters` does not cover headers, so keep `Authorization` out of request logs and APM
200
+ - Under `:token`, `url_for` must point at HTTPS (checked before a token is sent)
201
+ - See [SECURITY.md](SECURITY.md)
202
+
203
+ ### A full example
204
+
205
+ The host this gem was written for runs three nodes behind per-node proxies, with its own HTTP client, named API tokens and audit table:
206
+
207
+ ```ruby
208
+ ActiveStorage::MultiNodeDisk.configure do |config|
209
+ config.auth = :token
210
+ config.url_for = ->(node, key) { "https://#{App.public_host}#{App.prefix}#{ActiveStorage::MultiNodeDisk.mount_path}/blobs/#{key}" }
211
+ config.token_for = ->(node, blob, action) { NodeFiles.token_for(blob) }
212
+ config.authenticate = ->(token, request) { NodeFiles.caller_for(token) }
213
+ config.http_client = NodeFiles::Client.new # HTTPX, pinned to each node's IPv4 addresses
214
+ config.hop_header = "X-App-File-Hop"
215
+ config.error_mapper = ->(error) { NodeFiles.app_error(error) }
216
+ config.delete_mode = :job
217
+ config.delete_job_queue = :maintenance
218
+ config.audit = :table
219
+ config.audit_requester = ->(request) { NodeFiles.requester(request) }
220
+ config.audit_context = ->(request, entry) { NodeFiles.context(entry) }
221
+ end
222
+ ```
223
+
224
+ ## Requirements
225
+
226
+ Ruby | Rails | Databases (audit table)
227
+ --- | --- | ---
228
+ 3.3+ | 8.1 | PostgreSQL, MySQL 8, Oracle, SQLite
229
+
230
+ No runtime dependencies beyond Rails.
231
+
232
+ ## History
233
+
234
+ View the [changelog](CHANGELOG.md)
235
+
236
+ ## Contributing
237
+
238
+ Everyone is encouraged to help improve this project. Here are a few ways you can help:
239
+
240
+ - [Report bugs](https://github.com/clearstackio/activestorage-multi_node_disk/issues)
241
+ - Fix bugs and [submit pull requests](https://github.com/clearstackio/activestorage-multi_node_disk/pulls)
242
+ - Write, clarify, or fix documentation
243
+ - Suggest or add new features
244
+
245
+ To get started with development, see [CONTRIBUTING.md](CONTRIBUTING.md). Report security issues privately, as [SECURITY.md](SECURITY.md) explains.
246
+
247
+ ## License
248
+
249
+ MIT. See [LICENSE](LICENSE).
data/SECURITY.md ADDED
@@ -0,0 +1,33 @@
1
+ # Security policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Report it privately through
6
+ [GitHub's private vulnerability reporting](https://github.com/clearstackio/activestorage-multi_node_disk/security/advisories/new),
7
+ or email ajaya@clearstack.io. Please do not open a public issue.
8
+
9
+ We aim to acknowledge a report within three working days, and to publish a
10
+ fix and a GitHub security advisory once it is confirmed.
11
+
12
+ Include the activestorage-multi_node_disk version (or commit), how to
13
+ reproduce the problem, and what an attacker could read or delete.
14
+
15
+ ## Supported versions
16
+
17
+ activestorage-multi_node_disk is pre-1.0. Only the latest release is
18
+ supported.
19
+
20
+ ## The model
21
+
22
+ - The node endpoint checks **who is calling** (`auth`), never who may see a
23
+ file: that stays with the host app. The default, `:none`, checks nothing;
24
+ use `:token` (or `[:ip, :token]`) anywhere but a closed network.
25
+ - Tokens are compared in constant time, never logged and never part of an
26
+ event or audit entry. Under `:token`, node URLs must be HTTPS.
27
+ - `:ip` trusts `X-Forwarded-For` only from proxies the app lists in
28
+ `config.action_dispatch.trusted_proxies`; otherwise the socket's peer counts.
29
+ - A requester sent by another node is recorded as verified only after a check
30
+ passed. It must never carry a secret.
31
+ - Releases are published to rubygems.org from GitHub Actions with trusted
32
+ publishing (no long-lived API key) and carry a Sigstore attestation. Any
33
+ other push needs MFA (`rubygems_mfa_required`).
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module MultiNodeDisk
5
+ # One node's endpoint for the others: a file from this node's disk only
6
+ # (never asking further, so a hop cannot fan out), or its deletion.
7
+ #
8
+ # 1. The service's auth modes: :none checks nothing; :ip needs
9
+ # request.remote_ip in the allowlist (else 403); :token needs a Bearer
10
+ # token that config.authenticate turns into an identity (else 401 with
11
+ # a Bearer challenge). [:ip, :token] needs both, IP first.
12
+ # 2. Answer from this disk; every response names this node. Request
13
+ # auth only: who may see which file is the app's business, on its own
14
+ # pages.
15
+ # 3. Audit every answer (AuditLog), with the requester another node sent
16
+ # (trusted only once a check passed; unverified under :none).
17
+ class BlobsController < ActionController::API
18
+ include ActionController::HttpAuthentication::Token::ControllerMethods
19
+
20
+ REALM = "Multi-node disk"
21
+ BEARER = /\ABearer\s+\S/
22
+
23
+ before_action :name_this_node
24
+ before_action :authenticate_caller!
25
+ before_action :set_blob
26
+
27
+ # GET (the file) and HEAD (whether it is here).
28
+ def show
29
+ return not_here unless service&.local?(key)
30
+ return answer { head :ok } if request.head?
31
+
32
+ bytes = File.binread(service.path_for(key))
33
+ answer { send_data bytes, filename: @blob&.filename.to_s.presence || key, type: "application/octet-stream" }
34
+ rescue Errno::ENOENT, ActiveStorage::InvalidKeyError
35
+ not_here
36
+ end
37
+
38
+ def destroy
39
+ return not_here unless service
40
+
41
+ begin
42
+ File.delete(service.path_for(key))
43
+ rescue Errno::ENOENT
44
+ # Not here, or already deleted: nothing left on this node.
45
+ end
46
+ answer { head :no_content }
47
+ rescue ActiveStorage::InvalidKeyError
48
+ not_here
49
+ end
50
+
51
+ private
52
+
53
+ def key = params[:key].to_s
54
+
55
+ def name_this_node
56
+ @started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
57
+ response.headers[NODE_HEADER] = this_node
58
+ end
59
+
60
+ def auth_modes
61
+ @auth_modes ||= endpoint_service&.auth_modes || MultiNodeDisk::Configuration.auth_modes(MultiNodeDisk.config.auth)
62
+ end
63
+
64
+ def authenticate_caller!
65
+ modes = auth_modes
66
+ return deny(:forbidden) if modes.include?(:ip) && !allowed_ip?
67
+ return @checked = modes != [:none] unless modes.include?(:token)
68
+
69
+ if BEARER.match?(request.authorization.to_s)
70
+ @identity = authenticate_with_http_token { |token, _options| MultiNodeDisk.config.authenticate.call(token, request) }
71
+ end
72
+ return @checked = true if @identity
73
+
74
+ response.headers["WWW-Authenticate"] = %(Bearer realm="#{REALM}")
75
+ deny(:unauthorized)
76
+ end
77
+
78
+ # The socket's peer. Only when that peer is one of the proxies the app
79
+ # names in config.action_dispatch.trusted_proxies does request.remote_ip
80
+ # (X-Forwarded-For) count: Rails' own remote_ip would otherwise take a
81
+ # forwarded address any caller can write.
82
+ def allowed_ip?
83
+ caller_ip = IPAddr.new(caller_address.to_s)
84
+ caller_ip = caller_ip.native if caller_ip.ipv4_mapped?
85
+ endpoint_service.allowed_ips.any? { |allowed| allowed.include?(caller_ip) }
86
+ rescue IPAddr::Error
87
+ false
88
+ end
89
+
90
+ def caller_address
91
+ proxies = Rails.application.config.action_dispatch.trusted_proxies
92
+ peer = request.remote_addr.to_s
93
+ return peer if proxies.blank?
94
+
95
+ (Array(proxies).any? { |proxy| IPAddr.new(proxy.to_s).include?(peer) }) ? request.remote_ip : peer
96
+ rescue IPAddr::Error
97
+ peer
98
+ end
99
+
100
+ def set_blob
101
+ @blob = ActiveStorage::Blob.find_by(key: key)
102
+ end
103
+
104
+ def action = request.delete? ? :delete : :read
105
+
106
+ def audit_action
107
+ return :delete if request.delete?
108
+
109
+ request.head? ? :head : :read
110
+ end
111
+
112
+ # This node's MultiNodeDisk service (an in-process test may name
113
+ # another); nil for a blob of a different service.
114
+ def service
115
+ return @service if defined?(@service)
116
+
117
+ found = endpoint_service
118
+ @service = found if found && (@blob.nil? || @blob.service_name == found.name.to_s)
119
+ end
120
+
121
+ def endpoint_service
122
+ found = request.env[SERVICE_ENV_KEY] || MultiNodeDisk.service
123
+ found if found.is_a?(ActiveStorage::Service::MultiNodeDiskService)
124
+ end
125
+
126
+ def this_node
127
+ endpoint_service&.node || MultiNodeDisk.config.resolve(:current_node).to_s
128
+ end
129
+
130
+ def not_here
131
+ answer(:not_found) { render json: {message: "not here", node: this_node}, status: :not_found }
132
+ end
133
+
134
+ def answer(result = nil)
135
+ yield
136
+ response.headers[NODE_HEADER] = this_node
137
+ MultiNodeDisk.instrument(:serve, key: key, node: this_node, action: action, identity: @identity, status: response.status)
138
+ audit(result || (request.delete? ? :deleted : :served))
139
+ end
140
+
141
+ def deny(status)
142
+ MultiNodeDisk.instrument(:denied, key: key, node: this_node, action: action, status: Rack::Utils.status_code(status))
143
+ head status
144
+ audit(status)
145
+ end
146
+
147
+ def audit(result)
148
+ hop = request.headers[MultiNodeDisk.config.hop_header].presence
149
+ sent = MultiNodeDisk::Requester.decode(request.headers[MultiNodeDisk.config.requester_header])
150
+ trusted = @checked == true
151
+ entry = {
152
+ action: audit_action, result: result, status: response.status, key: key,
153
+ filename: @blob&.filename&.to_s, byte_size: @blob&.byte_size,
154
+ node: this_node, from_node: hop, remote_ip: caller_address, auth: auth_modes,
155
+ identity: @identity&.to_s, hop: !hop.nil?, duration_ms: duration_ms,
156
+ requester_verified: (trusted if sent)
157
+ }
158
+ MultiNodeDisk::AuditLog.record(entry, request: request, requester: sent || MultiNodeDisk.current_requester)
159
+ end
160
+
161
+ def duration_ms
162
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - (@started || 0)) * 1000).round(1)
163
+ end
164
+ end
165
+ end
166
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The engine's own routes, under its mount path (Engine). GET also answers
4
+ # HEAD. Nothing at the engine's root.
5
+ ActiveStorage::MultiNodeDisk::Engine.routes.draw do
6
+ scope format: false, constraints: {key: %r{[^/]+}} do
7
+ get "blobs/:key" => "blobs#show", :as => :blob
8
+ delete "blobs/:key" => "blobs#destroy"
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module MultiNodeDisk
5
+ # The :table audit sink: one row per entry, the whole entry in payload
6
+ # (JSON on PostgreSQL, a CLOB on Oracle). The table comes from the
7
+ # host's migration (`bin/rails g active_storage:multi_node_disk:install
8
+ # --audit`); the gem never creates it.
9
+ class Audit < ActiveRecord::Base
10
+ self.table_name = "#{MultiNodeDisk.config.table_name_prefix}audits"
11
+
12
+ attribute :payload, :json
13
+ end
14
+ end
15
+ end