concerns_on_rails 1.21.0 → 1.21.1
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/CHANGELOG.md +7 -0
- data/lib/concerns_on_rails/encryption.rb +22 -0
- data/lib/concerns_on_rails/models/encryptable.rb +108 -38
- data/lib/concerns_on_rails/support/encryptor.rb +15 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87a3ceebf2b9eb0f8a00c9d6a8cc57d8f50c8b1ff698be887aef42da64eb08c3
|
|
4
|
+
data.tar.gz: 1a82dd3dbe363e65f3a0d91c66cb453d54415032def91e90a388663ce2e90dab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebfb6e59e582240aab7bc0fe18e64e48f1f291f482b38df2dac97c3cc843f5731857594724868484fee75b9269ccaca923f6d229d862b3ce4ddfdf49c3f9aa27
|
|
7
|
+
data.tar.gz: 53d1b47d39588d102a4b6527b43b1801a1f87c47c1da7a67b823fb2138160e0dc8e1de9ea6d3b16070a2a33ca247a88832556de01e512b8ac474a9b5de288b6e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.21.1 (2026-07-18)
|
|
4
|
+
|
|
5
|
+
Blind-index support for Models::Encryptable, making encrypted fields queryable by exact match — the follow-up promised in 1.21.0. 991 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Models::Encryptable**: `blind_index:` option for exact-match lookups on otherwise-unqueryable encrypted columns. Encryption stays non-deterministic by design, so opting in with `encryptable :email, blind_index: true` maintains a deterministic keyed HMAC of the value in a companion `<field>_bidx` column and generates `find_by_<field>` / `where_<field>` / `<field>_fingerprint` class methods. `where_<field>` accepts one value, several, or an array (multiple become an IN query) and returns a plain Relation, so it chains with scopes, `.or`, and `.merge` for joins (`Order.joins(:user).merge(User.where_email(...))`). The fingerprint HMAC key is domain-separated from the AES key (a labeled-HMAC subkey via `Support::Encryptor.blind_index`), so the two are cryptographically independent while both driven from the configured key. `blind_index: { column:, expression: }` overrides the companion column and supplies a normalization callable (e.g. `->(v) { v.to_s.downcase }`) applied symmetrically on write and query for case-insensitive lookups. The index is recomputed in `before_save` only when the plaintext changes, stores `nil` for a nil value, and never contains plaintext. Macro-time `ArgumentError` validation: the blind-index column must exist, a custom `column:` cannot combine with multiple fields, and `expression:` must be callable. Key resolution was extracted to `Encryption::Config#resolve_material` so encryption and blind indexing share one path. Deterministic equality leaks (identical values share a digest) — documented; use it only for lookup keys. Zero new runtime dependencies.
|
|
9
|
+
|
|
3
10
|
## 1.21.0 (2026-07-01)
|
|
4
11
|
|
|
5
12
|
A new model concern for transparent field-level encryption — the "encrypt SSN/DOB at rest" capability the sensitive-data toolkit (Maskable, Sanitizable, Tokenizable) was missing. Hand-rolled on stdlib OpenSSL so it behaves identically on Rails 5.0–8, rather than delegating to Rails 7.1+ native `encrypts`. 978 examples, 0 failures.
|
|
@@ -21,6 +21,10 @@ module ConcernsOnRails
|
|
|
21
21
|
DEFAULT_KDF_SALT = "concerns_on_rails/encryptable/v1".freeze
|
|
22
22
|
KDF_ITERATIONS = 65_536
|
|
23
23
|
|
|
24
|
+
# Sentinel returned by Config#resolve_material when no key is configured and
|
|
25
|
+
# on_missing_key is :passthrough — callers then store/read plaintext.
|
|
26
|
+
PASSTHROUGH = :__concerns_on_rails_passthrough__
|
|
27
|
+
|
|
24
28
|
# Base class so callers can `rescue ConcernsOnRails::Encryption::Error`.
|
|
25
29
|
class Error < StandardError; end
|
|
26
30
|
|
|
@@ -60,6 +64,24 @@ module ConcernsOnRails
|
|
|
60
64
|
def key?
|
|
61
65
|
!key_material.nil?
|
|
62
66
|
end
|
|
67
|
+
|
|
68
|
+
# Resolve the effective key material for a field: a per-field override
|
|
69
|
+
# (String or Proc) wins, else the global key. Returns PASSTHROUGH in the
|
|
70
|
+
# escape-hatch mode, or raises MissingKeyError. Shared by encryption and
|
|
71
|
+
# blind indexing so both derive from the same key.
|
|
72
|
+
def resolve_material(field_key = nil)
|
|
73
|
+
material = field_key.respond_to?(:call) ? field_key.call : field_key
|
|
74
|
+
material = material.to_s unless material.nil?
|
|
75
|
+
return material if material && !material.empty?
|
|
76
|
+
|
|
77
|
+
global = key_material
|
|
78
|
+
return global unless global.nil?
|
|
79
|
+
return PASSTHROUGH if on_missing_key == :passthrough
|
|
80
|
+
|
|
81
|
+
raise MissingKeyError,
|
|
82
|
+
"ConcernsOnRails::Models::Encryptable: no encryption key configured. Set " \
|
|
83
|
+
"ConcernsOnRails.configure_encryption { |c| c.key = ... } or pass key: to the macro."
|
|
84
|
+
end
|
|
63
85
|
end
|
|
64
86
|
end
|
|
65
87
|
end
|
|
@@ -19,8 +19,9 @@ module ConcernsOnRails
|
|
|
19
19
|
# class Patient < ApplicationRecord
|
|
20
20
|
# include ConcernsOnRails::Models::Encryptable
|
|
21
21
|
#
|
|
22
|
-
# encryptable :ssn, :notes
|
|
23
|
-
# encryptable :dob, type: :date
|
|
22
|
+
# encryptable :ssn, :notes # transparent string encryption
|
|
23
|
+
# encryptable :dob, type: :date # decrypts back to a Date
|
|
24
|
+
# encryptable :email, blind_index: true # + a queryable fingerprint column
|
|
24
25
|
# end
|
|
25
26
|
#
|
|
26
27
|
# p = Patient.create!(ssn: "123-45-6789", dob: Date.new(1990, 1, 1))
|
|
@@ -28,22 +29,34 @@ module ConcernsOnRails
|
|
|
28
29
|
# p.reload.dob # => Wed, 01 Jan 1990
|
|
29
30
|
# p.ssn_ciphertext # => "AQEA..." (Base64 envelope; no plaintext at rest)
|
|
30
31
|
# p.ssn_encrypted? # => true
|
|
32
|
+
# Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
|
|
31
33
|
#
|
|
32
34
|
# `type:` casts the decrypted value (reuses the Storable caster set:
|
|
33
35
|
# :string default, :integer, :float, :decimal, :boolean, :date, :datetime).
|
|
34
36
|
# `key:` overrides the gem-level key per field (a String or a Proc).
|
|
35
37
|
#
|
|
38
|
+
# BLIND INDEX (`blind_index: true` or a Hash): because encryption is
|
|
39
|
+
# non-deterministic, encrypted columns are not directly queryable. Opt into
|
|
40
|
+
# a blind index and the concern maintains a deterministic keyed HMAC of the
|
|
41
|
+
# value in a companion `<field>_bidx` column (override with `column:`), and
|
|
42
|
+
# generates `find_by_<field>` / `where_<field>` / `<field>_fingerprint`
|
|
43
|
+
# class methods for exact-match lookups. Pass `expression:` (a callable) to
|
|
44
|
+
# normalize before hashing (e.g. `->(v) { v.to_s.downcase }`) — it is applied
|
|
45
|
+
# on BOTH write and query so they stay symmetric. The index leaks equality
|
|
46
|
+
# (identical values share a digest); use it only for lookup keys.
|
|
47
|
+
#
|
|
36
48
|
# Notes:
|
|
37
49
|
# * The declared column must be `text` (or binary): it stores an opaque
|
|
38
50
|
# Base64 envelope, not the logical type. `nil` stays `nil` (never an
|
|
39
|
-
# encrypted blank).
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
51
|
+
# encrypted blank). A blind-index column is `string`/`text` (a 64-char
|
|
52
|
+
# hex digest) — add an index on it.
|
|
53
|
+
# * The ciphertext itself is non-deterministic (random IV) — the same
|
|
54
|
+
# plaintext yields different ciphertext every write, so `where(:ssn)`
|
|
55
|
+
# matches nothing. Query through a blind index instead. Presence/NULL
|
|
56
|
+
# checks (`where.not(ssn: nil)`) work normally.
|
|
45
57
|
# * Never `update_column`/`update_columns` an encrypted field — those
|
|
46
|
-
# bypass the type and write raw plaintext to the column
|
|
58
|
+
# bypass the type and write raw plaintext to the column (and skip the
|
|
59
|
+
# blind-index refresh).
|
|
47
60
|
# * Auditing an encrypted field would persist its plaintext to the audit
|
|
48
61
|
# column, so declaring a field with BOTH `encryptable` and `auditable_by`
|
|
49
62
|
# raises. Maskable masks the decrypted value; Normalizable normalizes the
|
|
@@ -66,11 +79,33 @@ module ConcernsOnRails
|
|
|
66
79
|
}.freeze
|
|
67
80
|
|
|
68
81
|
included do
|
|
69
|
-
# { field => { type:, key: } }. Subclasses inherit and may
|
|
82
|
+
# { field => { type:, key:, blind_index: } }. Subclasses inherit and may
|
|
83
|
+
# add fields.
|
|
70
84
|
class_attribute :encryptable_rules, instance_accessor: false, default: {}
|
|
71
85
|
# Backstop for the reverse declaration order (Auditable added AFTER
|
|
72
86
|
# Encryptable): the macro-time guard can't see a not-yet-declared audit.
|
|
73
87
|
before_save :encryptable_guard_audited_plaintext!
|
|
88
|
+
before_save :encryptable_refresh_blind_indexes
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Deterministic blind-index fingerprint for a field's value, applying the
|
|
92
|
+
# field's normalization `expression:`. Shared by the generated class
|
|
93
|
+
# finders and the before_save refresh. Returns nil for a nil value.
|
|
94
|
+
def self.blind_fingerprint(rule, value)
|
|
95
|
+
bi = rule[:blind_index]
|
|
96
|
+
return nil unless bi
|
|
97
|
+
return nil if value.nil?
|
|
98
|
+
|
|
99
|
+
normalized = bi[:expression] ? bi[:expression].call(value) : value
|
|
100
|
+
return nil if normalized.nil?
|
|
101
|
+
|
|
102
|
+
config = ConcernsOnRails.encryption
|
|
103
|
+
material = config.resolve_material(rule[:key])
|
|
104
|
+
return normalized.to_s if material == ConcernsOnRails::Encryption::PASSTHROUGH
|
|
105
|
+
|
|
106
|
+
ConcernsOnRails::Support::Encryptor.blind_index(
|
|
107
|
+
normalized, key: material, salt: config.key_derivation_salt
|
|
108
|
+
)
|
|
74
109
|
end
|
|
75
110
|
|
|
76
111
|
# Custom type registered on each encrypted column. cast handles user input
|
|
@@ -79,8 +114,6 @@ module ConcernsOnRails
|
|
|
79
114
|
# so dirty tracking compares the cast plaintext — a re-save of unchanged
|
|
80
115
|
# data is not dirtied by GCM's random IV.
|
|
81
116
|
class EncryptedType < ActiveModel::Type::Value
|
|
82
|
-
PASSTHROUGH = :__concerns_on_rails_passthrough__
|
|
83
|
-
|
|
84
117
|
def initialize(type: :string, key: nil)
|
|
85
118
|
@type = type
|
|
86
119
|
@key = key
|
|
@@ -168,8 +201,8 @@ module ConcernsOnRails
|
|
|
168
201
|
|
|
169
202
|
def write_ciphertext(plaintext)
|
|
170
203
|
config = ConcernsOnRails.encryption
|
|
171
|
-
material =
|
|
172
|
-
return plaintext if material == PASSTHROUGH
|
|
204
|
+
material = config.resolve_material(@key)
|
|
205
|
+
return plaintext if material == ConcernsOnRails::Encryption::PASSTHROUGH
|
|
173
206
|
|
|
174
207
|
ConcernsOnRails::Support::Encryptor.encrypt(
|
|
175
208
|
plaintext, key: material, salt: config.key_derivation_salt
|
|
@@ -178,8 +211,8 @@ module ConcernsOnRails
|
|
|
178
211
|
|
|
179
212
|
def read_plaintext(stored)
|
|
180
213
|
config = ConcernsOnRails.encryption
|
|
181
|
-
material =
|
|
182
|
-
return stored if material == PASSTHROUGH
|
|
214
|
+
material = config.resolve_material(@key)
|
|
215
|
+
return stored if material == ConcernsOnRails::Encryption::PASSTHROUGH
|
|
183
216
|
|
|
184
217
|
ConcernsOnRails::Support::Encryptor.decrypt(
|
|
185
218
|
stored, key: material, salt: config.key_derivation_salt
|
|
@@ -189,48 +222,53 @@ module ConcernsOnRails
|
|
|
189
222
|
|
|
190
223
|
nil
|
|
191
224
|
end
|
|
192
|
-
|
|
193
|
-
# Per-field key: wins; else the gem-level key; else raise (or the
|
|
194
|
-
# passthrough sentinel in the dev/test escape-hatch mode).
|
|
195
|
-
def resolve_key_material(config)
|
|
196
|
-
material = @key.respond_to?(:call) ? @key.call : @key
|
|
197
|
-
material = material.to_s unless material.nil?
|
|
198
|
-
return material if material && !material.empty?
|
|
199
|
-
|
|
200
|
-
global = config.key_material
|
|
201
|
-
return global unless global.nil?
|
|
202
|
-
return PASSTHROUGH if config.on_missing_key == :passthrough
|
|
203
|
-
|
|
204
|
-
raise ConcernsOnRails::Encryption::MissingKeyError,
|
|
205
|
-
"#{LABEL}: no encryption key configured. Set " \
|
|
206
|
-
"ConcernsOnRails.configure_encryption { |c| c.key = ... } or pass key: to the macro."
|
|
207
|
-
end
|
|
208
225
|
end
|
|
209
226
|
|
|
210
227
|
module ClassMethods
|
|
211
228
|
include ConcernsOnRails::Support::ColumnGuard
|
|
212
229
|
|
|
213
230
|
# Declare one or more encrypted fields. Repeatable; per-field options.
|
|
214
|
-
def encryptable(*fields, type: :string, key: nil)
|
|
215
|
-
raise ArgumentError, "#{LABEL}: at least one field is required" if fields.empty?
|
|
216
|
-
|
|
231
|
+
def encryptable(*fields, type: :string, key: nil, blind_index: nil)
|
|
217
232
|
type = type.to_sym
|
|
218
|
-
|
|
219
|
-
|
|
233
|
+
encryptable_validate!(fields, type, blind_index)
|
|
220
234
|
ensure_columns!(LABEL, *fields)
|
|
221
235
|
|
|
222
236
|
fields.each do |field|
|
|
223
237
|
field = field.to_sym
|
|
224
238
|
encryptable_guard_auditable!(field)
|
|
225
|
-
|
|
239
|
+
bi = encryptable_normalize_blind_index(field, blind_index)
|
|
240
|
+
ensure_columns!(LABEL, bi[:column]) if bi
|
|
241
|
+
self.encryptable_rules = encryptable_rules.merge(field => { type: type, key: key, blind_index: bi })
|
|
226
242
|
attribute field, EncryptedType.new(type: type, key: key)
|
|
227
243
|
encryptable_define_helpers(field)
|
|
244
|
+
encryptable_define_blind_index(field, bi) if bi
|
|
228
245
|
encryptable_register_filter_parameter(field)
|
|
229
246
|
end
|
|
230
247
|
end
|
|
231
248
|
|
|
232
249
|
private
|
|
233
250
|
|
|
251
|
+
def encryptable_validate!(fields, type, blind_index)
|
|
252
|
+
raise ArgumentError, "#{LABEL}: at least one field is required" if fields.empty?
|
|
253
|
+
raise ArgumentError, "#{LABEL}: unknown type ':#{type}' (valid: #{VALID_TYPES.join(', ')})" unless VALID_TYPES.include?(type)
|
|
254
|
+
return unless blind_index.is_a?(Hash) && blind_index[:column] && fields.size > 1
|
|
255
|
+
|
|
256
|
+
raise ArgumentError, "#{LABEL}: blind_index column: cannot be combined with multiple fields"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# nil/false -> no index; true -> defaults; Hash -> { column:, expression: }.
|
|
260
|
+
def encryptable_normalize_blind_index(field, option)
|
|
261
|
+
return nil unless option
|
|
262
|
+
|
|
263
|
+
option = {} if option == true
|
|
264
|
+
raise ArgumentError, "#{LABEL}: blind_index: must be true or a Hash" unless option.is_a?(Hash)
|
|
265
|
+
|
|
266
|
+
expression = option[:expression]
|
|
267
|
+
raise ArgumentError, "#{LABEL}: blind_index expression: must be callable" if expression && !expression.respond_to?(:call)
|
|
268
|
+
|
|
269
|
+
{ column: (option[:column] || "#{field}_bidx").to_sym, expression: expression }
|
|
270
|
+
end
|
|
271
|
+
|
|
234
272
|
def encryptable_define_helpers(field)
|
|
235
273
|
# Raw stored value: the DB ciphertext once persisted (before the type
|
|
236
274
|
# deserializes it). Useful for migrations, debugging, and asserting no
|
|
@@ -239,6 +277,26 @@ module ConcernsOnRails
|
|
|
239
277
|
define_method("#{field}_encrypted?") { read_attribute_before_type_cast(field).present? }
|
|
240
278
|
end
|
|
241
279
|
|
|
280
|
+
# find_by_<field> / where_<field> / <field>_fingerprint for equality
|
|
281
|
+
# lookups through the deterministic blind-index column.
|
|
282
|
+
def encryptable_define_blind_index(field, blind_index)
|
|
283
|
+
column = blind_index[:column]
|
|
284
|
+
|
|
285
|
+
define_singleton_method("#{field}_fingerprint") do |value|
|
|
286
|
+
ConcernsOnRails::Models::Encryptable.blind_fingerprint(encryptable_rules.fetch(field), value)
|
|
287
|
+
end
|
|
288
|
+
# Accepts one value, several, or an array — multiple values become an
|
|
289
|
+
# IN query on the fingerprint column. Returns a Relation, so it chains
|
|
290
|
+
# with scopes, `.or`, `.merge` (for joins), and further `.where`.
|
|
291
|
+
define_singleton_method("where_#{field}") do |*values|
|
|
292
|
+
fingerprints = values.flatten.map { |v| public_send("#{field}_fingerprint", v) }
|
|
293
|
+
where(column => fingerprints.length == 1 ? fingerprints.first : fingerprints)
|
|
294
|
+
end
|
|
295
|
+
define_singleton_method("find_by_#{field}") do |value|
|
|
296
|
+
find_by(column => public_send("#{field}_fingerprint", value))
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
242
300
|
# Macro-time guard for the common order (Encryptable declared after
|
|
243
301
|
# Auditable): a field must not be both encrypted and audited.
|
|
244
302
|
def encryptable_guard_auditable!(field)
|
|
@@ -274,6 +332,18 @@ module ConcernsOnRails
|
|
|
274
332
|
"#{LABEL}: #{overlap.map { |f| ":#{f}" }.join(', ')} declared with both Encryptable and " \
|
|
275
333
|
"Auditable; auditing would persist decrypted plaintext. Remove them from auditable_by."
|
|
276
334
|
end
|
|
335
|
+
|
|
336
|
+
# Recompute each blind-index column from the (changed) plaintext just
|
|
337
|
+
# before the row is written, so the fingerprint always matches the value.
|
|
338
|
+
def encryptable_refresh_blind_indexes
|
|
339
|
+
self.class.encryptable_rules.each do |field, rule|
|
|
340
|
+
bi = rule[:blind_index]
|
|
341
|
+
next unless bi
|
|
342
|
+
next unless public_send("#{field}_changed?")
|
|
343
|
+
|
|
344
|
+
self[bi[:column]] = ConcernsOnRails::Models::Encryptable.blind_fingerprint(rule, public_send(field))
|
|
345
|
+
end
|
|
346
|
+
end
|
|
277
347
|
end
|
|
278
348
|
end
|
|
279
349
|
end
|
|
@@ -29,6 +29,9 @@ module ConcernsOnRails
|
|
|
29
29
|
KEY_BYTES = 32 # AES-256
|
|
30
30
|
CIPHER = "aes-256-gcm".freeze
|
|
31
31
|
MIN_ENVELOPE_BYTES = HEADER_LEN + IV_LEN + TAG_LEN
|
|
32
|
+
# Domain-separation label so the blind-index HMAC key differs from the AES
|
|
33
|
+
# key even when both derive from the same configured secret.
|
|
34
|
+
BLIND_INDEX_INFO = "concerns_on_rails/blind_index/v1".freeze
|
|
32
35
|
|
|
33
36
|
# Encrypt a String, returning a Base64 envelope. nil passes through as nil
|
|
34
37
|
# (a blank column stays blank / NULL-able), never an encrypted empty value.
|
|
@@ -79,6 +82,18 @@ module ConcernsOnRails
|
|
|
79
82
|
"could not decrypt value (wrong key or tampered ciphertext)"
|
|
80
83
|
end
|
|
81
84
|
|
|
85
|
+
# Deterministic keyed fingerprint (lowercase hex) for equality lookups — a
|
|
86
|
+
# "blind index". The HMAC key is domain-separated from the AES key via
|
|
87
|
+
# BLIND_INDEX_INFO, so the two are cryptographically independent. The same
|
|
88
|
+
# value + key always yields the same digest, enabling an indexed WHERE.
|
|
89
|
+
def blind_index(value, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
|
|
90
|
+
return nil if value.nil?
|
|
91
|
+
|
|
92
|
+
master = normalize_key(key, salt: salt)
|
|
93
|
+
subkey = OpenSSL::HMAC.digest("SHA256", master, BLIND_INDEX_INFO)
|
|
94
|
+
OpenSSL::HMAC.hexdigest("SHA256", subkey, value.to_s)
|
|
95
|
+
end
|
|
96
|
+
|
|
82
97
|
# Coerce key material to a 32-byte AES key: raw 32-byte binary as-is, a
|
|
83
98
|
# 64-hex string decoded, otherwise a passphrase stretched with PBKDF2. The
|
|
84
99
|
# salt + iteration count are part of the derived key's identity.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.21.
|
|
4
|
+
version: 1.21.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|