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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +89 -0
- data/LICENSE +21 -0
- data/README.md +249 -0
- data/SECURITY.md +33 -0
- data/app/controllers/active_storage/multi_node_disk/blobs_controller.rb +166 -0
- data/config/routes.rb +10 -0
- data/lib/active_storage/multi_node_disk/audit.rb +15 -0
- data/lib/active_storage/multi_node_disk/audit_log.rb +48 -0
- data/lib/active_storage/multi_node_disk/configuration.rb +217 -0
- data/lib/active_storage/multi_node_disk/configuration_error.rb +8 -0
- data/lib/active_storage/multi_node_disk/delete_job.rb +27 -0
- data/lib/active_storage/multi_node_disk/engine.rb +24 -0
- data/lib/active_storage/multi_node_disk/fan_out.rb +76 -0
- data/lib/active_storage/multi_node_disk/file_error.rb +36 -0
- data/lib/active_storage/multi_node_disk/file_sink.rb +38 -0
- data/lib/active_storage/multi_node_disk/mismatch.rb +15 -0
- data/lib/active_storage/multi_node_disk/net_http_client.rb +31 -0
- data/lib/active_storage/multi_node_disk/node_addresses.rb +50 -0
- data/lib/active_storage/multi_node_disk/not_here.rb +15 -0
- data/lib/active_storage/multi_node_disk/requester.rb +50 -0
- data/lib/active_storage/multi_node_disk/response.rb +16 -0
- data/lib/active_storage/multi_node_disk/test_helper.rb +97 -0
- data/lib/active_storage/multi_node_disk/unavailable.rb +16 -0
- data/lib/active_storage/multi_node_disk/version.rb +7 -0
- data/lib/active_storage/multi_node_disk.rb +172 -0
- data/lib/active_storage/service/multi_node_disk_service.rb +322 -0
- data/lib/activestorage-multi_node_disk.rb +4 -0
- data/lib/generators/active_storage/multi_node_disk/install_generator.rb +47 -0
- data/lib/generators/active_storage/multi_node_disk/templates/create_audits.rb.tt +33 -0
- data/lib/generators/active_storage/multi_node_disk/templates/initializer.rb.tt +26 -0
- 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
|
+
[](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
|