sealed_id 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 41ea696b7074f6260ba635f6aa55a39afa9b5fc51b3a82a9464f3badd067465d
4
+ data.tar.gz: fbadd2d3167fef510767a7642d9c075bcae3f93b6301f494b785a3f42221806f
5
+ SHA512:
6
+ metadata.gz: 18fe921c99644a7d79404db0f9eff6db8cba01e23b6c34152bbe3899fd63412d7054d85d49208944d779e7ae683d4be14cdede7ccd5116192881a7f3cfcc6185
7
+ data.tar.gz: ab22c99e7cdc8505a8863fad79b4ed669767b4f229849909a9020090cd63aba37fa0817fa97afa5d52ee7a671c37381e4c819580c39a9384b10290c2ad651c6c
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Encrypt integer primary keys into unique, UUID-shaped tokens with AES-256, with no new column or migration.
6
+ - Refuse raw ids in `find` from requests, while integers, ActiveJob and GlobalID keep working.
7
+ - Make `where`, `find_by`, `exists?`, `delete`, `delete_by` and `destroy_by` understand tokens.
8
+ - Add `SealedId.model_for`, `locate` and `find` to resolve any token to its model and record.
9
+ - Add `decode_params` for foreign keys in forms, and a `legacy` option for ids issued by an earlier scheme.
10
+ - Read the secret from `SEALED_ID_SECRET`, and refuse short or repetitive secrets.
11
+ - Finder behavior derives from [acts_as_hashids](https://github.com/dtaniwaki/acts_as_hashids).
data/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2014 Daisuke Taniwaki
2
+ Copyright (c) 2026 Tamiru Hailu
3
+
4
+ MIT License
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining
7
+ a copy of this software and associated documentation files (the
8
+ "Software"), to deal in the Software without restriction, including
9
+ without limitation the rights to use, copy, modify, merge, publish,
10
+ distribute, sublicense, and/or sell copies of the Software, and to
11
+ permit persons to whom the Software is furnished to do so, subject to
12
+ the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be
15
+ included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
21
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
22
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
23
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # sealed_id
2
+
3
+ Unguessable, UUID-shaped public ids for ActiveRecord, with no new column or migration.
4
+
5
+ Your tables keep their integer primary keys. URLs get an encrypted token that can't be guessed, and raw ids from requests are refused.
6
+
7
+ ```ruby
8
+ class Foo < ApplicationRecord
9
+ sealed_id
10
+ end
11
+
12
+ foo = Foo.create
13
+ foo.id # => 1, still your integer primary key
14
+ foo.to_param # => "d42fc60c-c23f-84e2-8a9c-7a3cd44e450c"
15
+
16
+ Foo.find(foo.to_param) # => #<Foo id: 1>
17
+ Foo.find("1") # => ActiveRecord::RecordNotFound
18
+ Foo.find("garbage") # => ActiveRecord::RecordNotFound
19
+ Foo.find(1) # => #<Foo id: 1>
20
+ ```
21
+
22
+ Your routes and `find` calls work unchanged, and invalid ids become a normal 404.
23
+
24
+ ## Requirements
25
+
26
+ Ruby 2.7 or newer, and Rails or ActiveRecord 6.0 to 8.1. Primary keys must be integers up to 64 bits. Models with UUID or composite primary keys keep their normal behavior, so the macro can sit on `ApplicationRecord` alongside them.
27
+
28
+ ## Installation
29
+
30
+ Add the gem to your Gemfile:
31
+
32
+ ```ruby
33
+ gem "sealed_id", "~> 0.1"
34
+ ```
35
+
36
+ ### Set a secret
37
+
38
+ Tokens are derived from a secret of at least 32 bytes. **Changing the secret changes every public URL**, so choose one you won't need to rotate.
39
+
40
+ The simplest way is an environment variable, which the gem reads automatically. Generate a value with `bin/rails secret`, then set it wherever the app runs:
41
+
42
+ ```
43
+ SEALED_ID_SECRET=<your generated value>
44
+ ```
45
+
46
+ Environments with the same value produce the same tokens, so a link or database copy from one works in another. Machines that share the production value can also decode and forge production tokens, so give production its own value unless you need this.
47
+
48
+ The order of precedence is `secret:` on the model, then `SealedId.secret = ...` in an initializer, then `SEALED_ID_SECRET`. In Rails, `secret_key_base` is the fallback, which differs per environment and would change your links if rotated, so set a dedicated value in production. Outside Rails, there's no fallback.
49
+
50
+ ## Usage
51
+
52
+ Put the macro on one model, or on `ApplicationRecord` for all of them.
53
+
54
+ ```ruby
55
+ foo.sealed_id # the token
56
+ Foo.unseal(token) # the integer id, or nil if the token is invalid
57
+ Foo.with_sealed_ids(a, b) # a relation; raises SealedId::InvalidToken for an invalid token
58
+ ```
59
+
60
+ `find` also accepts several tokens and respects scopes, so `current_user.foos.find(token)` only finds that user's records.
61
+
62
+ ### Finding the model from a token
63
+
64
+ Every token records which model it belongs to, readable only with your secret:
65
+
66
+ ```ruby
67
+ SealedId.model_for(token) # => Foo, or nil if the token isn't valid
68
+ SealedId.locate(token) # => the record, or nil
69
+ SealedId.find(token) # => the record, or raises ActiveRecord::RecordNotFound
70
+ ```
71
+
72
+ `locate` and `find` search every model, so **always check that the caller may see the record they return**. For a request that should only reach one model, use `Foo.find(token)`, which refuses another model's token. With single-table inheritance, `model_for` returns the base class, and `locate` returns the actual subclass.
73
+
74
+ ### Options
75
+
76
+ ```ruby
77
+ sealed_id secret: ENV.fetch("FOO_SECRET"), # overrides SealedId.secret; may be callable
78
+ scope: "foos" # defaults to the base class name
79
+ ```
80
+
81
+ Tokens are tied to the `scope`, so a token for one model is never valid for another. Before renaming a model, set `scope` to its current class name, or its URLs will change. Each model needs its own scope: `SealedId.model_for` and the other lookups above raise `SealedId::Error` if two share one.
82
+
83
+ ### Decoding form params
84
+
85
+ Forms send tokens for foreign keys too, so controllers get a private `decode_params` method. Pass the model and your permitted params:
86
+
87
+ ```ruby
88
+ def student_params
89
+ decode_params(Student, params.require(:student).permit(:name, :program_id, course_ids: []))
90
+ end
91
+ ```
92
+
93
+ It handles `id`, `belongs_to` foreign keys (including custom `foreign_key:` names), `*_ids` and nested `*_attributes`, using your associations to choose the model for each key. Other keys and models without the macro are left alone, and blank values stay blank so forms can clear an association.
94
+
95
+ An invalid token raises `SealedId::InvalidToken`, which Rails returns as a 404. It never becomes `nil`, which would silently clear the association, and it never stays a string, which ActiveRecord would cast to the wrong integer. Raw ids are refused too, including integers in a JSON body.
96
+
97
+ Polymorphic keys raise `ArgumentError`, because the model can't be known safely. Check the type against an allow-list and call `Model.unseal` yourself.
98
+
99
+ ### Nested forms
100
+
101
+ `fields_for` works unchanged. It adds a hidden field holding the nested record's raw id, and `decode_params` keeps that one raw value, because nested attributes only ever match records already inside the parent's association. Other ids in nested attributes must still be tokens.
102
+
103
+ ### Keeping old ids working
104
+
105
+ If ids from an earlier scheme must survive, such as on printed documents or QR codes, pass a `legacy` decoder. Old ids keep resolving while every new link uses a sealed token. This example needs the `hashids` gem in your Gemfile:
106
+
107
+ ```ruby
108
+ class Student < ApplicationRecord
109
+ OLD_IDS = Hashids.new("Student", 10) # the settings the old ids were issued with
110
+
111
+ sealed_id legacy: lambda { |token|
112
+ numbers = OLD_IDS.decode(token)
113
+ numbers.first if numbers.one?
114
+ }
115
+ end
116
+ ```
117
+
118
+ The decoder takes a string of up to 64 characters and returns an integer id or `nil`. Raising `ArgumentError` also counts as invalid.
119
+
120
+ **Anything `legacy` accepts is only as hard to guess as the old scheme.** Use it only on models that need it, and remove it when the old ids no longer matter.
121
+
122
+ ### ActiveJob and GlobalID
123
+
124
+ These keep working automatically. GlobalID stores the raw id, and sealed_id reads it as a raw id only on that internal path.
125
+
126
+ To look up a raw id string you produced yourself, use `SealedId.with_raw_ids { Foo.find("3") }`. Never do this with ids from a request.
127
+
128
+ ## Security notes
129
+
130
+ - **Some queries still don't understand tokens.** `find`, `where`, `find_by`, `exists?`, `delete`, `delete_by` and `destroy_by` all handle tokens for the primary key and `belongs_to` foreign keys. `where.not`, conditions on joined tables (`where(foos: { id: token })`) and raw SQL don't: they cast the string to an integer and act on **a different record**, so `"7a3d54ce-..."` becomes 7. The same applies to `legacy` ids in any query other than `find`. Decode first with `Foo.unseal`, which returns `nil` for an invalid token.
131
+ - **This isn't access control.** Anyone holding a valid URL can use it, so always authorize access to the record you load.
132
+ - **Decoding isn't authorization.** `decode_params` confirms a token is valid, not that this user may link to that record. Check that separately.
133
+ - **`find` accepts integers.** A JSON body of `{"id": 3}` makes `params[:id]` an `Integer`, which `find` accepts because application code relies on it. Use `Foo.find(params[:id].to_s)` for ids from JSON. `decode_params` already refuses them.
134
+ - **Raw ids appear elsewhere.** `as_json` and `to_json` include `id`, while `dom_id`, `cache_key`, nested-form hidden fields and global ids all use the raw value. Remove them from anything public if the id itself is sensitive.
135
+
136
+ ## How it works
137
+
138
+ Each token is one AES-256 block holding the 64-bit id, a 32-bit tag naming the model and 32 zero bits, using a key derived from your secret with HMAC-SHA256, then written in UUID format. The mapping is deterministic, so a record always has the same token, with no storage needed.
139
+
140
+ **Tokens are unique by construction.** All models using the same secret share one key, AES is one-to-one for a given key, and no two records have the same id and tag, so two records can't get the same token. This is a mathematical guarantee rather than a low probability, so there's no need for a unique index or for retrying on a collision. The exception is a model with its own `secret:`, where a clash with another model is only astronomically unlikely, as with random UUIDs.
141
+
142
+ With a strong secret, tokens don't show ids, models, their order or how many records exist, knowing other id and token pairs doesn't help an attacker, and a guess is less likely to succeed than guessing a random UUIDv4. A changed token decrypts to random bits, which fail the tag and zero checks.
143
+
144
+ There are three things to know:
145
+
146
+ - **Tokens are shaped like UUIDs, but aren't valid under RFC 9562.** Their version and variant bits are random, because the full 128 bits hold the encrypted block. Databases and most parsers accept them, but a validator that checks for a specific UUID version will reject them.
147
+ - **Your secret decides everything.** The gem refuses short or obviously repetitive secrets, but it can't measure randomness, so generate yours with `bin/rails secret`.
148
+ - **This hasn't had an independent review.** AES is standard, but this gem's use of it hasn't been audited.
149
+
150
+ | | sealed_id | Random UUID column |
151
+ | --- | --- | --- |
152
+ | Migration and backfill | Not needed | Required |
153
+ | Works on existing records | Yes | After backfill |
154
+ | If the secret leaks | All tokens can be decoded and forged | Nothing to leak |
155
+ | Replace one record's token | Not possible | Update the column |
156
+ | Change the secret | Every URL changes | Not applicable |
157
+ | Query by token in SQL | No, decode it first | Yes |
158
+
159
+ If a leaked secret or being unable to revoke a token isn't acceptable for your data, use a random UUID column instead.
160
+
161
+ ## Development
162
+
163
+ ```
164
+ bundle install
165
+ bundle exec rake
166
+ ```
167
+
168
+ ### Publishing
169
+
170
+ A matching `v*.*.*` tag publishes the gem using trusted publishing (OIDC), so the repository needs no registry secrets. Register a trusted publisher on [RubyGems](https://rubygems.org/gems/sealed_id) with these values:
171
+
172
+ | Field | Value |
173
+ | --- | --- |
174
+ | Repository | `tamiru/sealed_id` |
175
+ | Workflow | `release.yml` |
176
+ | Environment | `release` |
177
+
178
+ ```bash
179
+ # Bump the version in lib/sealed_id/version.rb and update CHANGELOG.md, then
180
+ git tag v0.1.0
181
+ git push origin v0.1.0
182
+ ```
183
+
184
+ If a publish fails, re-run the failed jobs. A version already on RubyGems is skipped, so a partial release can be finished safely.
185
+
186
+ ## License
187
+
188
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,69 @@
1
+ require 'openssl'
2
+
3
+ module SealedId
4
+ # Encodes an integer id as a UUID-shaped token by encrypting a single AES block, which holds the 64-bit id, a 32-bit
5
+ # tag naming the model, and 32 zero bits.
6
+ #
7
+ # Every model shares one key, so tokens are unique by construction: AES is one-to-one for a given key, and no two
8
+ # records have the same id and tag. The tag also lets the server tell which model a token belongs to.
9
+ #
10
+ # AES is a pseudorandom permutation, so without the key every token looks like 128 random bits, and knowing other id
11
+ # and token pairs doesn't help. A guess hits an existing record with probability records / 2^128. The tag and zero
12
+ # bits authenticate the block: a changed token decrypts to random bits, which pass for a given model with probability
13
+ # 2^-64, and even then only count if that id exists.
14
+ #
15
+ # ECB is safe here because only one block is ever encrypted, and a repeatable token for each id is the point.
16
+ class Codec
17
+ MIN_SECRET_BYTES = 32
18
+ MIN_DISTINCT_BYTES = 8
19
+ MAX_ID = (2**64) - 1
20
+ # Lowercase only, so each id has exactly one valid token.
21
+ FORMAT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/.freeze
22
+
23
+ attr_reader :tag
24
+
25
+ def initialize(secret, scope)
26
+ check_secret(secret.to_s)
27
+ @key = OpenSSL::HMAC.digest('SHA256', secret.to_s, 'sealed_id/v1/aes')
28
+ @tag = OpenSSL::Digest::SHA256.digest(scope.to_s).unpack1('N')
29
+ end
30
+
31
+ # Returns the token for an id, or nil if the id can't be encoded.
32
+ def encode(id)
33
+ return unless id.is_a?(Integer) && id.between?(0, MAX_ID)
34
+
35
+ hex = aes(:encrypt, [id, tag, 0].pack('Q>NN')).unpack1('H*')
36
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
37
+ end
38
+
39
+ # Returns the id for a token issued to this model with this key, or nil for anything else.
40
+ def decode(token)
41
+ return unless token.is_a?(String) && token.valid_encoding? && FORMAT.match?(token)
42
+
43
+ id, token_tag, check = aes(:decrypt, [token.delete('-')].pack('H*')).unpack('Q>NN')
44
+ id if token_tag == tag && check.zero?
45
+ end
46
+
47
+ private
48
+
49
+ # A cipher object holds state, so each call builds its own to stay thread safe.
50
+ def aes(direction, block)
51
+ cipher = OpenSSL::Cipher.new('aes-256-ecb').public_send(direction)
52
+ cipher.key = @key
53
+ cipher.padding = 0
54
+ cipher.update(block) + cipher.final
55
+ end
56
+
57
+ # Can't measure randomness, but does catch placeholders such as "x" * 32 before tokens are issued with them.
58
+ def check_secret(secret)
59
+ if secret.bytesize < MIN_SECRET_BYTES
60
+ raise ArgumentError, "sealed_id needs a secret of at least #{MIN_SECRET_BYTES} bytes. " \
61
+ 'Set SealedId.secret, or pass secret: to the model.'
62
+ end
63
+ return if secret.bytes.uniq.size >= MIN_DISTINCT_BYTES
64
+
65
+ raise ArgumentError, 'sealed_id needs a random secret, but this one repeats too few characters. ' \
66
+ 'Generate one with `bin/rails secret`.'
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,195 @@
1
+ require_relative 'codec'
2
+
3
+ module SealedId
4
+ module Core
5
+ extend ActiveSupport::Concern
6
+
7
+ # Longer than any token, and bounds what reaches a legacy decoder, whose cost may grow with input length.
8
+ MAX_TOKEN_LENGTH = 64
9
+
10
+ # The public token for this record, or nil if it has no integer id yet.
11
+ def sealed_id
12
+ key = id
13
+ self.class.sealed_id_codec.encode(key) if key.is_a?(Integer)
14
+ end
15
+
16
+ def to_param
17
+ sealed_id || super
18
+ end
19
+
20
+ module FinderMethods
21
+ def find(*args, &block)
22
+ return (respond_to?(:detect) ? self : all).detect(&block) if block
23
+ return super unless respond_to?(:sealed_primary_key?) && sealed_primary_key?
24
+
25
+ ids = args.flatten
26
+ args.size > 1 || args.first.is_a?(Array) ? find_many_sealed(ids) : find_one_sealed(ids.first)
27
+ end
28
+
29
+ private
30
+
31
+ def find_one_sealed(id)
32
+ key = sealed_id_to_primary_key(id)
33
+ record = where(primary_key => key).take if key
34
+ record || raise_sealed_not_found([sealed_error_label(id, key)], 0, 1)
35
+ end
36
+
37
+ def find_many_sealed(ids)
38
+ keys = ids.map { |id| sealed_id_to_primary_key(id) }
39
+ wanted = keys.uniq
40
+ scope = where(primary_key => wanted.compact)
41
+ records = scope.to_a
42
+
43
+ if records.size != wanted.size
44
+ labels = ids.zip(keys).map { |id, key| sealed_error_label(id, key) }.uniq
45
+ raise_sealed_not_found(labels, records.size, wanted.size)
46
+ end
47
+
48
+ scope.order_values.any? ? records : records.index_by(&:id).values_at(*wanted)
49
+ end
50
+
51
+ # Never echoes a raw primary key, and bounds whatever the caller sent so it can't bloat logs.
52
+ def sealed_error_label(id, key)
53
+ (key && sealed_id_codec.encode(key)) || id.to_s.truncate(64)
54
+ end
55
+
56
+ def raise_sealed_not_found(ids, result_size, expected_size)
57
+ if ids.size == 1
58
+ error = "Couldn't find #{name} with '#{primary_key}'=#{ids.first.inspect}"
59
+ else
60
+ error = "Couldn't find all #{name.pluralize} with '#{primary_key}': "
61
+ error += "(#{ids.map(&:inspect).join(', ')}) (found #{result_size} results, but was looking for #{expected_size})"
62
+ end
63
+
64
+ raise ActiveRecord::RecordNotFound.new(error, name, primary_key, ids)
65
+ end
66
+ end
67
+
68
+ # Makes queries understand tokens. ActiveRecord would otherwise cast "7a3d54ce-..." to 7 and act on a different
69
+ # record, which is how Foo.delete(token) would remove the wrong row.
70
+ module RelationMethods
71
+ include FinderMethods
72
+
73
+ # Everything built on +where+ passes through here, including delete_by, destroy_by and Foo.delete.
74
+ def where!(opts, *rest)
75
+ opts = klass.sealed_conditions(opts) if opts.is_a?(Hash) && klass.respond_to?(:sealed_conditions)
76
+ super
77
+ end
78
+
79
+ def exists?(conditions = :none)
80
+ return super unless klass.respond_to?(:sealed_conditions)
81
+
82
+ conditions = { primary_key => conditions } if conditions.is_a?(String) && Codec::FORMAT.match?(conditions)
83
+ super(klass.sealed_conditions(conditions))
84
+ end
85
+ end
86
+
87
+ module ClassMethods
88
+ include FinderMethods
89
+
90
+ # Matches no record, for a token that isn't valid.
91
+ NO_RECORD = -1
92
+
93
+ # The class-level find_by has its own fast path, which skips +where+.
94
+ def find_by(arg, *args)
95
+ super(sealed_conditions(arg), *args)
96
+ end
97
+
98
+ # Replaces tokens in query conditions with the primary keys they stand for. A token is never right for an integer
99
+ # column, so this can't change what a correct query means.
100
+ def sealed_conditions(conditions)
101
+ return conditions unless conditions.is_a?(Hash)
102
+
103
+ conditions.to_h { |column, value| [column, sealed_condition_value(column.to_s, value)] }
104
+ end
105
+
106
+ # Building a codec derives keys, so keep the last one. It is keyed by secret because a callable secret may vary,
107
+ # and the pair is swapped in whole so concurrent readers never see a mismatch.
108
+ def sealed_id_codec
109
+ secret = sealed_id_secret
110
+ cached_secret, cached = @sealed_id_codec_cache
111
+ return cached if cached && cached_secret == secret
112
+
113
+ scope = sealed_id_options[:scope] || base_class.name
114
+ Codec.new(secret, scope).tap { |codec| @sealed_id_codec_cache = [secret, codec].freeze }
115
+ end
116
+
117
+ # Returns the primary key for a token, or nil if the token isn't valid for this model.
118
+ def unseal(token)
119
+ return unless token.is_a?(String) && token.valid_encoding? && token.length <= MAX_TOKEN_LENGTH
120
+
121
+ sealed_id_codec.decode(token) || unseal_legacy(token)
122
+ end
123
+
124
+ # A relation of the records with these tokens. Raises SealedId::InvalidToken if any token isn't valid.
125
+ def with_sealed_ids(*tokens)
126
+ tokens = tokens.flatten
127
+ keys = tokens.map { |token| unseal(token) }
128
+ raise InvalidToken, "Invalid sealed id: #{tokens.inspect.truncate(200)}" if keys.include?(nil)
129
+
130
+ where(primary_key => keys)
131
+ end
132
+
133
+ # Resolves a value given to +find+ to a primary key, or nil if it can't refer to a record.
134
+ #
135
+ # Numerics are trusted because application code and Rails itself pass them around. Strings may come from a URL,
136
+ # so they must be valid tokens, unless the caller opted in with SealedId.with_raw_ids.
137
+ def sealed_id_to_primary_key(value)
138
+ return value.to_i if value.is_a?(Numeric)
139
+ return unless value.is_a?(String) && value.valid_encoding?
140
+ return value.to_i if SealedId.raw_ids? && value.match?(/\A\d{1,20}\z/)
141
+
142
+ unseal(value)
143
+ end
144
+
145
+ # Only integer keys can be sealed. Others (UUIDs, composite keys) keep ActiveRecord's behavior, so the macro can
146
+ # sit on ApplicationRecord alongside such models.
147
+ def sealed_primary_key?
148
+ primary_key.is_a?(String) && type_for_attribute(primary_key).type == :integer
149
+ end
150
+
151
+ def has_many(*args, &block) # rubocop:disable Naming/PredicatePrefix
152
+ options = args.extract_options!
153
+ options[:extend] = Array(options[:extend]) + [RelationMethods]
154
+ super(*args, **options, &block)
155
+ end
156
+
157
+ def relation
158
+ r = super
159
+ r.extend RelationMethods
160
+ r
161
+ end
162
+
163
+ private
164
+
165
+ def sealed_condition_value(column, value)
166
+ return value.map { |item| sealed_condition_value(column, item) } if value.is_a?(Array)
167
+ return value unless value.is_a?(String) && Codec::FORMAT.match?(value)
168
+ return value unless type_for_attribute(column).type == :integer
169
+
170
+ target = column == primary_key ? self : sealed_foreign_key_target(column)
171
+ (target.respond_to?(:unseal) && target.unseal(value)) || NO_RECORD
172
+ end
173
+
174
+ def sealed_foreign_key_target(column)
175
+ reflection = reflect_on_all_associations(:belongs_to).find { |r| r.foreign_key.to_s == column }
176
+ reflection.klass if reflection && !reflection.polymorphic?
177
+ end
178
+
179
+ def unseal_legacy(token)
180
+ legacy = sealed_id_options[:legacy]
181
+ return if legacy.nil? || Codec::FORMAT.match?(token)
182
+
183
+ key = instance_exec(token, &legacy)
184
+ key if key.is_a?(Integer)
185
+ rescue ArgumentError
186
+ nil
187
+ end
188
+
189
+ def sealed_id_secret
190
+ secret = sealed_id_options[:secret] || SealedId.secret
191
+ secret.respond_to?(:call) ? instance_exec(&secret) : secret
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,93 @@
1
+ module SealedId
2
+ # Turns tokens in permitted params back into primary keys, using the model's associations to pick each key's model:
3
+ #
4
+ # decode_params(Student, params.expect(student: [:name, :program_id, { course_ids: [] }]))
5
+ #
6
+ # Handles +id+, belongs_to foreign keys, +*_ids+ and nested +*_attributes+. Blank values stay as they are, so a form
7
+ # can still clear an association. An invalid token raises SealedId::InvalidToken rather than becoming nil, which
8
+ # would silently clear the association, or staying a string, which ActiveRecord would cast to the wrong integer.
9
+ # Integers are invalid too: this is the request boundary, and a raw id in a JSON body must not get through. The one
10
+ # exception is the +id+ of a nested record, see raw_id?.
11
+ module DecodeParams
12
+ private
13
+
14
+ def decode_params(model, params)
15
+ Decoder.call(model, params)
16
+ end
17
+
18
+ module Decoder
19
+ module_function
20
+
21
+ def call(model, params, nested: false)
22
+ attributes = ActiveSupport::HashWithIndifferentAccess.new(params)
23
+ attributes.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |(key, value), decoded|
24
+ decoded[key] = decode_key(model, key.to_s, value, nested)
25
+ end
26
+ end
27
+
28
+ def decode_key(model, key, value, nested)
29
+ if key == 'id' then nested && raw_id?(value) ? value : decode_token(model, value)
30
+ elsif key.end_with?('_ids') then decode_many(model, key, value)
31
+ elsif key.end_with?('_id') then decode_foreign_key(model, key, value)
32
+ elsif key.end_with?('_attributes') then decode_nested(model, key, value)
33
+ else value
34
+ end
35
+ end
36
+
37
+ def decode_foreign_key(model, key, value)
38
+ reflection = model.reflect_on_all_associations(:belongs_to).find { |r| r.foreign_key.to_s == key }
39
+ return value unless reflection
40
+
41
+ if reflection.polymorphic?
42
+ raise ArgumentError, "decode_params can't tell which model #{model.name}##{key} refers to; decode it yourself"
43
+ end
44
+
45
+ decode_token(reflection.klass, value)
46
+ end
47
+
48
+ def decode_many(model, key, value)
49
+ reflection = model.reflect_on_association(key.delete_suffix('_ids').pluralize)
50
+ return value unless reflection
51
+
52
+ Array(value).map { |token| decode_token(reflection.klass, token) }
53
+ end
54
+
55
+ def decode_nested(model, key, value)
56
+ reflection = model.reflect_on_association(key.delete_suffix('_attributes'))
57
+ return value unless reflection
58
+
59
+ decode_nested_value(reflection.klass, value)
60
+ end
61
+
62
+ def decode_nested_value(klass, value)
63
+ case value
64
+ when Array then value.map { |attributes| call(klass, attributes, nested: true) }
65
+ when Hash
66
+ return call(klass, value, nested: true) unless indexed?(value)
67
+
68
+ value.transform_values { |attributes| call(klass, attributes, nested: true) }
69
+ else value
70
+ end
71
+ end
72
+
73
+ # Rails sends collections from forms as { "0" => {...}, "1" => {...} }.
74
+ def indexed?(hash)
75
+ hash.keys.all? { |key| key.to_s.match?(/\A\d+\z/) }
76
+ end
77
+
78
+ # fields_for emits the raw id of nested records. It is safe to keep, because nested attributes only ever match
79
+ # records already inside the parent's association, so it can't reach anyone else's.
80
+ def raw_id?(value)
81
+ value.to_s.match?(/\A\d+\z/)
82
+ end
83
+
84
+ def decode_token(klass, value)
85
+ return value if value.blank? && !value.is_a?(Numeric)
86
+ return value unless klass.respond_to?(:unseal)
87
+
88
+ klass.unseal(value) || raise(InvalidToken, "Invalid sealed id for #{klass.name}: #{value.inspect.truncate(100)}")
89
+ end
90
+ end
91
+ private_constant :Decoder
92
+ end
93
+ end
@@ -0,0 +1,13 @@
1
+ module SealedId
2
+ # GlobalID stores the raw primary key and hands it to +find+ as a string, so ActiveJob arguments and signed ids would
3
+ # otherwise be refused as invalid tokens. Those strings come from the application, not from a URL.
4
+ module GlobalIdLocator
5
+ def locate(*args)
6
+ SealedId.with_raw_ids { super }
7
+ end
8
+
9
+ def locate_many(*args)
10
+ SealedId.with_raw_ids { super }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ require_relative 'core'
2
+
3
+ module SealedId
4
+ module Model
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ # Options:
9
+ # secret: overrides SealedId.secret for this model. May be callable.
10
+ # scope: separates this model's tokens from other models'. Defaults to the base class name, so set it
11
+ # before renaming a model to keep its URLs working.
12
+ # legacy: a callable taking a string and returning an integer id or nil, so ids issued before adopting this
13
+ # gem keep working, such as those printed on documents. New tokens are still sealed. Anything it
14
+ # accepts is only as hard to guess as the old scheme, so use it only on models that need it.
15
+ def sealed_id(secret: nil, scope: nil, legacy: nil)
16
+ include SealedId::Core unless ancestors.include?(SealedId::Core)
17
+
18
+ options = { secret: secret, scope: scope, legacy: legacy }.freeze
19
+ define_singleton_method(:sealed_id_options) { options }
20
+ private_class_method :sealed_id_options
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ require 'sealed_id/model'
2
+
3
+ module SealedId
4
+ class Railtie < Rails::Railtie
5
+ # Only applies when SEALED_ID_SECRET isn't set. Runs before the app's config/initializers, so an initializer
6
+ # setting SealedId.secret takes precedence.
7
+ initializer 'sealed_id.default_secret' do
8
+ SealedId.secret ||= -> { Rails.application.secret_key_base }
9
+ end
10
+
11
+ initializer 'sealed_id.include_model' do
12
+ ActiveSupport.on_load(:active_record) { include SealedId::Model }
13
+ end
14
+
15
+ initializer 'sealed_id.include_controller' do
16
+ ActiveSupport.on_load(:action_controller) do
17
+ require 'sealed_id/decode_params'
18
+ include SealedId::DecodeParams
19
+
20
+ ActionDispatch::ExceptionWrapper.rescue_responses['SealedId::InvalidToken'] = :not_found
21
+ end
22
+ end
23
+
24
+ config.after_initialize { SealedId.install_global_id_support }
25
+ end
26
+ end
@@ -0,0 +1,3 @@
1
+ module SealedId
2
+ VERSION = '0.1.0'.freeze
3
+ end
data/lib/sealed_id.rb ADDED
@@ -0,0 +1,96 @@
1
+ require 'sealed_id/version'
2
+
3
+ module SealedId
4
+ class Error < StandardError; end
5
+ class InvalidToken < Error; end
6
+
7
+ RAW_IDS_KEY = :sealed_id_raw_ids
8
+ private_constant :RAW_IDS_KEY
9
+
10
+ class << self
11
+ attr_writer :secret
12
+
13
+ # The default secret for every model, from SealedId.secret= or else the SEALED_ID_SECRET environment variable.
14
+ # May be callable. At least 32 bytes.
15
+ def secret
16
+ return @secret if @secret
17
+
18
+ value = ENV.fetch('SEALED_ID_SECRET', nil)
19
+ value unless value.to_s.empty?
20
+ end
21
+
22
+ # Inside the block, +find+ reads digit-only strings as primary keys.
23
+ # Only use it for ids the application produced itself, never for ones taken from a request.
24
+ def with_raw_ids
25
+ previous = Thread.current[RAW_IDS_KEY]
26
+ Thread.current[RAW_IDS_KEY] = true
27
+ yield
28
+ ensure
29
+ Thread.current[RAW_IDS_KEY] = previous
30
+ end
31
+
32
+ def raw_ids?
33
+ Thread.current[RAW_IDS_KEY] == true
34
+ end
35
+
36
+ def install_global_id_support
37
+ return unless defined?(GlobalID::Locator)
38
+
39
+ require 'sealed_id/global_id_locator'
40
+ GlobalID::Locator.singleton_class.prepend(GlobalIdLocator)
41
+ end
42
+
43
+ # The model a token was issued for, or nil if it isn't a valid token.
44
+ def model_for(token)
45
+ models.find { |model| model.sealed_id_codec.decode(token) }
46
+ end
47
+
48
+ # The record a token stands for, whichever model it belongs to, or nil if there isn't one.
49
+ # This finds across every model, so always check the caller may see the record it returns.
50
+ def locate(token)
51
+ model = model_for(token)
52
+ model&.find_by(model.primary_key => model.sealed_id_codec.decode(token))
53
+ end
54
+
55
+ # Like +locate+, but raises ActiveRecord::RecordNotFound.
56
+ def find(token)
57
+ locate(token) || raise(ActiveRecord::RecordNotFound, 'No record for this sealed id')
58
+ end
59
+
60
+ # Every model using sealed_id. Raises if two share a tag, because their tokens could not be told apart.
61
+ def models
62
+ eager_load_application
63
+ found = ActiveRecord::Base.descendants.select { |model| sealed_root_model?(model) }
64
+ found.group_by { |model| model.sealed_id_codec.tag }.each_value do |same_tag|
65
+ next if same_tag.one?
66
+
67
+ raise Error,
68
+ "These models share a sealed_id scope, so their tokens would collide: #{same_tag.map(&:name).join(', ')}"
69
+ end
70
+ found
71
+ end
72
+
73
+ private
74
+
75
+ # Only named top-level models: subclasses share their base class's tokens through single-table inheritance.
76
+ def sealed_root_model?(model)
77
+ model.respond_to?(:sealed_id_codec) && !model.abstract_class? && model.base_class == model &&
78
+ model.name.to_s.safe_constantize.equal?(model)
79
+ end
80
+
81
+ # Models load on first use in development, so they wouldn't all be known yet.
82
+ def eager_load_application
83
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
84
+
85
+ Rails.application.eager_load! unless Rails.application.config.eager_load
86
+ end
87
+ end
88
+ end
89
+
90
+ if defined?(Rails::Railtie)
91
+ require 'sealed_id/railtie'
92
+ elsif defined?(ActiveRecord)
93
+ require 'sealed_id/model'
94
+ ActiveRecord::Base.include SealedId::Model
95
+ SealedId.install_global_id_support
96
+ end
metadata ADDED
@@ -0,0 +1,201 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sealed_id
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tamiru Hailu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.2'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '6.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.2'
32
+ - !ruby/object:Gem::Dependency
33
+ name: actionpack
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '6.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '8.2'
42
+ type: :development
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '6.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '8.2'
52
+ - !ruby/object:Gem::Dependency
53
+ name: globalid
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '1.0'
59
+ type: :development
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '1.0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: rake
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '12.0'
73
+ type: :development
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '12.0'
80
+ - !ruby/object:Gem::Dependency
81
+ name: rspec
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.10'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.10'
94
+ - !ruby/object:Gem::Dependency
95
+ name: rubocop
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '1.0'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '1.0'
108
+ - !ruby/object:Gem::Dependency
109
+ name: rubocop-rspec
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '2.0'
115
+ type: :development
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '2.0'
122
+ - !ruby/object:Gem::Dependency
123
+ name: simplecov
124
+ requirement: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0.21'
129
+ - - "<"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0.21'
139
+ - - "<"
140
+ - !ruby/object:Gem::Version
141
+ version: '1.0'
142
+ - !ruby/object:Gem::Dependency
143
+ name: sqlite3
144
+ requirement: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: '1.4'
149
+ type: :development
150
+ prerelease: false
151
+ version_requirements: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: '1.4'
156
+ description: Encrypts integer primary keys into UUID-shaped tokens for URLs, and refuses
157
+ raw ids from requests.
158
+ email:
159
+ - tamiruhailu@gmail.com
160
+ executables: []
161
+ extensions: []
162
+ extra_rdoc_files: []
163
+ files:
164
+ - CHANGELOG.md
165
+ - LICENSE
166
+ - README.md
167
+ - lib/sealed_id.rb
168
+ - lib/sealed_id/codec.rb
169
+ - lib/sealed_id/core.rb
170
+ - lib/sealed_id/decode_params.rb
171
+ - lib/sealed_id/global_id_locator.rb
172
+ - lib/sealed_id/model.rb
173
+ - lib/sealed_id/railtie.rb
174
+ - lib/sealed_id/version.rb
175
+ homepage: https://github.com/tamiru/sealed_id
176
+ licenses:
177
+ - MIT
178
+ metadata:
179
+ source_code_uri: https://github.com/tamiru/sealed_id
180
+ changelog_uri: https://github.com/tamiru/sealed_id/blob/main/CHANGELOG.md
181
+ bug_tracker_uri: https://github.com/tamiru/sealed_id/issues
182
+ allowed_push_host: https://rubygems.org
183
+ rubygems_mfa_required: 'true'
184
+ rdoc_options: []
185
+ require_paths:
186
+ - lib
187
+ required_ruby_version: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - ">="
190
+ - !ruby/object:Gem::Version
191
+ version: '2.7'
192
+ required_rubygems_version: !ruby/object:Gem::Requirement
193
+ requirements:
194
+ - - ">="
195
+ - !ruby/object:Gem::Version
196
+ version: '0'
197
+ requirements: []
198
+ rubygems_version: 4.0.20
199
+ specification_version: 4
200
+ summary: Unguessable, UUID-shaped public ids for ActiveRecord, with no migration.
201
+ test_files: []