active_typed_store 2.7.0 → 2.8.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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +43 -25
- data/lib/active_typed_store/store.rb +4 -13
- data/lib/active_typed_store/version.rb +1 -1
- metadata +9 -14
- data/lib/active_typed_store/encrypted_json.rb +0 -46
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50dfb0f6df80a8fc7ad44f7f5ea9ae9eda71b8257e27b85c9a6a253633bb654b
|
|
4
|
+
data.tar.gz: 2d2c7cc056e39e9b9e23a494ebe7c973f5a99030c370324a97f9ff23ba5fff35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f99b76c42b7ee43bbb5c6513ca259788994eac8111824d4988b3a187198e2d9e5d49747b528a46674c73813876a9a99ef73d540aa0cf5c8b3e325bfeec86a82
|
|
7
|
+
data.tar.gz: 924a6e45783e10176782d5989380022cdd84be33d1ad2809b9ee2acf2047ea7a0d9701ace6e6b3732ea55ace5c90543af3af9ff867dd55304e2d48ad06169555
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -143,31 +143,6 @@ class Parcel
|
|
|
143
143
|
end
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
-
### Encrypted store
|
|
147
|
-
|
|
148
|
-
`encrypted: true` keeps keys of the store readable and encrypts every value like Rails `encrypts`:
|
|
149
|
-
|
|
150
|
-
```ruby
|
|
151
|
-
class SupplierImportSetting < ActiveRecord::Base
|
|
152
|
-
typed_store :marketplace_credentials, encrypted: true do
|
|
153
|
-
attr :wb_token, :string
|
|
154
|
-
attr :ozon_token, :string
|
|
155
|
-
attr :ozon_client_id, :string
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
setting.update!(wb_token: "secret", ozon_client_id: "123")
|
|
160
|
-
# in the database: {"wb_token": "{\"p\":\"...\",\"h\":{...}}", "ozon_client_id": "{\"p\":\"...\",\"h\":{...}}"}
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
- Values are encrypted by `ActiveRecord::Encryption::EncryptedAttributeType` with the same scheme as `encrypts`:
|
|
164
|
-
[Active Record Encryption](https://guides.rubyonrails.org/active_record_encryption.html) must be configured,
|
|
165
|
-
`encrypts` options can be passed instead of `true`: `encrypted: { deterministic: true }`,
|
|
166
|
-
`encrypted: { previous: { key: "old key" } }`, `encrypted: { support_unencrypted_data: true }`.
|
|
167
|
-
- Any value type is supported: the value is JSON-encoded before encryption.
|
|
168
|
-
- Don't add `encrypts` for the store column or its accessors: it conflicts with typed_store or silently does nothing.
|
|
169
|
-
- Like with `encrypts`, decrypted values are in `changes`, `attributes` and the store hash.
|
|
170
|
-
|
|
171
146
|
### Hash safety
|
|
172
147
|
This gem assumes you're using a database that supports structured data types, such as `json` in `PostgreSQL` or `MySQL`, and leverages Rails' [store_accessor](https://edgeapi.rubyonrails.org/classes/ActiveRecord/Store.html) under the hood. However, there’s one caveat: JSON columns use a string-keyed hash and don’t support access via symbols. To avoid unexpected errors when accessing the hash, we raise an error if a symbol is used. You can disable this behavior by setting `config.hash_safety = false`.
|
|
173
148
|
|
|
@@ -191,6 +166,49 @@ record.params["price"] # 1
|
|
|
191
166
|
record.params[:price] # nil - isn't the expected behavior for most applications
|
|
192
167
|
```
|
|
193
168
|
|
|
169
|
+
### Encryption
|
|
170
|
+
|
|
171
|
+
`active_typed_store` does not encrypt, on purpose: Rails `encrypts` works on whole columns only,
|
|
172
|
+
and per-key encryption inside a JSON store is not a Rails pattern (Lockbox: "encrypt the column
|
|
173
|
+
rather than individual accessors"). Keep secrets (API tokens, credentials) out of `typed_store`
|
|
174
|
+
and use the standard Rails API on a separate `text` column:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
class AddCredentialsToModels < ActiveRecord::Migration[8.1]
|
|
178
|
+
def change
|
|
179
|
+
add_column :models, :credentials, :text, null: false, default: "{}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
class Model < ActiveRecord::Base
|
|
186
|
+
typed_store(:params) do
|
|
187
|
+
attr :task_id, :integer
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
attribute :credentials, :json
|
|
191
|
+
store_accessor :credentials, :api_token, :client_id
|
|
192
|
+
encrypts :credentials
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
m = Model.create!(api_token: "secret")
|
|
196
|
+
m.api_token # => "secret"
|
|
197
|
+
m.ciphertext_for(:credentials) # => "{\"p\":\"...\",\"h\":{...}}" - no plaintext in the database
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Why this exact shape (verified on Rails 8.1 and PostgreSQL):
|
|
201
|
+
|
|
202
|
+
- `text`, not `jsonb`: the column holds ciphertext, a JSON envelope in `jsonb` only misleads SQL readers.
|
|
203
|
+
- `null: false, default: "{}"`: `encrypts` passes the column default through, so an empty store
|
|
204
|
+
reads back as `{}` without marking the record dirty.
|
|
205
|
+
- `attribute :json` + `store_accessor`, not `store :credentials, coder: JSON`: `store` serializes
|
|
206
|
+
an empty hash as `NULL`, which a `NOT NULL` column rejects, and on a nullable column
|
|
207
|
+
Rails <= 8.1 marks the record dirty on every read.
|
|
208
|
+
|
|
209
|
+
`previous_changes` still holds the plaintext hash: filter `encrypted_attributes` in audit-log
|
|
210
|
+
libraries (`audited` does, `paper_trail` since 15.0).
|
|
211
|
+
|
|
194
212
|
### Benchmarks
|
|
195
213
|
compare `active_typed_store` with other gems
|
|
196
214
|
```ruby
|
|
@@ -10,25 +10,16 @@ module ActiveTypedStore
|
|
|
10
10
|
|
|
11
11
|
CUSTOM_JSON = CustomJson.new
|
|
12
12
|
|
|
13
|
-
def typed_store(store_attribute,
|
|
13
|
+
def typed_store(store_attribute, &)
|
|
14
14
|
attrs = Attrs.new(store_attribute)
|
|
15
|
-
attrs.instance_eval(&
|
|
16
|
-
|
|
17
|
-
json_type =
|
|
18
|
-
if encrypted
|
|
19
|
-
# not required on load: it loads ActiveRecord::Encryption
|
|
20
|
-
require_relative "encrypted_json"
|
|
21
|
-
EncryptedJson.new(encrypted == true ? {} : encrypted)
|
|
22
|
-
else
|
|
23
|
-
CUSTOM_JSON
|
|
24
|
-
end
|
|
15
|
+
attrs.instance_eval(&)
|
|
25
16
|
|
|
26
17
|
define_singleton_method(:inherited) do |subclass|
|
|
27
18
|
super(subclass)
|
|
28
|
-
subclass.define_attribute(store_attribute.name,
|
|
19
|
+
subclass.define_attribute(store_attribute.name, CUSTOM_JSON)
|
|
29
20
|
end
|
|
30
21
|
|
|
31
|
-
define_attribute store_attribute.name,
|
|
22
|
+
define_attribute store_attribute.name, CUSTOM_JSON
|
|
32
23
|
store_accessor store_attribute, attrs.fields
|
|
33
24
|
|
|
34
25
|
if ActiveTypedStore.config.hash_safety == :disallow_symbol_keys
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_typed_store
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ermolaev Andrey
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activemodel
|
|
@@ -16,43 +15,42 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
18
|
+
version: '0'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
25
|
+
version: '0'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
27
|
name: activerecord
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
30
29
|
requirements:
|
|
31
30
|
- - ">="
|
|
32
31
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
32
|
+
version: '0'
|
|
34
33
|
type: :runtime
|
|
35
34
|
prerelease: false
|
|
36
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
36
|
requirements:
|
|
38
37
|
- - ">="
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
39
|
+
version: '0'
|
|
41
40
|
- !ruby/object:Gem::Dependency
|
|
42
41
|
name: activesupport
|
|
43
42
|
requirement: !ruby/object:Gem::Requirement
|
|
44
43
|
requirements:
|
|
45
44
|
- - ">="
|
|
46
45
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: '
|
|
46
|
+
version: '0'
|
|
48
47
|
type: :runtime
|
|
49
48
|
prerelease: false
|
|
50
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
50
|
requirements:
|
|
52
51
|
- - ">="
|
|
53
52
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: '
|
|
55
|
-
description:
|
|
53
|
+
version: '0'
|
|
56
54
|
email:
|
|
57
55
|
- andruhafirst@yandex.ru
|
|
58
56
|
executables: []
|
|
@@ -72,7 +70,6 @@ files:
|
|
|
72
70
|
- lib/active_typed_store/attrs.rb
|
|
73
71
|
- lib/active_typed_store/configuration.rb
|
|
74
72
|
- lib/active_typed_store/disallow_symbol_keys.rb
|
|
75
|
-
- lib/active_typed_store/encrypted_json.rb
|
|
76
73
|
- lib/active_typed_store/store.rb
|
|
77
74
|
- lib/active_typed_store/version.rb
|
|
78
75
|
homepage: https://github.com/corp-gp/active_typed_store
|
|
@@ -80,7 +77,6 @@ licenses:
|
|
|
80
77
|
- MIT
|
|
81
78
|
metadata:
|
|
82
79
|
rubygems_mfa_required: 'true'
|
|
83
|
-
post_install_message:
|
|
84
80
|
rdoc_options: []
|
|
85
81
|
require_paths:
|
|
86
82
|
- lib
|
|
@@ -95,8 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
95
91
|
- !ruby/object:Gem::Version
|
|
96
92
|
version: '0'
|
|
97
93
|
requirements: []
|
|
98
|
-
rubygems_version: 3.
|
|
99
|
-
signing_key:
|
|
94
|
+
rubygems_version: 3.6.9
|
|
100
95
|
specification_version: 4
|
|
101
96
|
summary: Typed store for active records json columns (postgresql, mysql, sqlite)
|
|
102
97
|
test_files: []
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module ActiveTypedStore
|
|
4
|
-
module Store
|
|
5
|
-
# typed_store(:params, encrypted: true): keys are stored as is, every value is encrypted like Rails `encrypts`.
|
|
6
|
-
# Options are `encrypts` options: encrypted: { deterministic: true, previous: ..., support_unencrypted_data: true }
|
|
7
|
-
class EncryptedJson < CustomJson
|
|
8
|
-
# value is JSON-encoded before encryption to keep its type; not encrypted value (support_unencrypted_data)
|
|
9
|
-
# is returned as is, it may be not JSON
|
|
10
|
-
class ValueJson < ActiveRecord::Type::Json
|
|
11
|
-
def deserialize(value)
|
|
12
|
-
return value unless value.is_a?(::String)
|
|
13
|
-
|
|
14
|
-
ActiveSupport::JSON.decode(value)
|
|
15
|
-
rescue JSON::ParserError
|
|
16
|
-
value
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def initialize(scheme_options)
|
|
21
|
-
super()
|
|
22
|
-
@scheme_options = scheme_options
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def serialize(value) = super(value.is_a?(Hash) ? value.transform_values { value_type.serialize(_1) } : value)
|
|
26
|
-
|
|
27
|
-
def deserialize(value)
|
|
28
|
-
hash = super
|
|
29
|
-
hash.is_a?(Hash) ? hash.transform_values { value_type.deserialize(_1) } : hash
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# deserialize(serialize(value)) gives the same as plain json, but encrypts and decrypts every value
|
|
33
|
-
# on every assignment and dirty check
|
|
34
|
-
def cast(value) = CUSTOM_JSON.cast(value)
|
|
35
|
-
def changed_in_place?(raw_old_value, new_value) = deserialize(raw_old_value) != cast(new_value)
|
|
36
|
-
|
|
37
|
-
# built lazily like `encrypts` does: encryption config may be not loaded yet when the model is defined
|
|
38
|
-
private def value_type
|
|
39
|
-
@value_type ||= ActiveRecord::Encryption::EncryptedAttributeType.new(
|
|
40
|
-
scheme: ActiveRecord::Base.send(:scheme_for, **@scheme_options),
|
|
41
|
-
cast_type: ValueJson.new,
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|