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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +23 -0
- data/README.md +188 -0
- data/lib/sealed_id/codec.rb +69 -0
- data/lib/sealed_id/core.rb +195 -0
- data/lib/sealed_id/decode_params.rb +93 -0
- data/lib/sealed_id/global_id_locator.rb +13 -0
- data/lib/sealed_id/model.rb +24 -0
- data/lib/sealed_id/railtie.rb +26 -0
- data/lib/sealed_id/version.rb +3 -0
- data/lib/sealed_id.rb +96 -0
- metadata +201 -0
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
|
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: []
|