daffy_lib 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/.circleci/config.yml +64 -0
- data/.github/pull_request_template.md +19 -0
- data/.gitignore +19 -0
- data/.rspec +1 -0
- data/.rubocop.yml +60 -0
- data/CODEOWNERS +15 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +266 -0
- data/README.md +116 -0
- data/Rakefile +8 -0
- data/SECURITY.MD +5 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/daffy_lib.gemspec +43 -0
- data/lib/daffy_lib.rb +15 -0
- data/lib/daffy_lib/caching_encryptor.rb +114 -0
- data/lib/daffy_lib/concerns/has_encrypted_attributes.rb +28 -0
- data/lib/daffy_lib/concerns/has_guid.rb +62 -0
- data/lib/daffy_lib/concerns/partition_provider.rb +59 -0
- data/lib/daffy_lib/models/application_record.rb +9 -0
- data/lib/daffy_lib/models/encryption_key.rb +16 -0
- data/lib/daffy_lib/railtie.rb +12 -0
- data/lib/daffy_lib/services/key_management_service.rb +106 -0
- data/lib/daffy_lib/validators/string_validator.rb +18 -0
- data/lib/daffy_lib/version.rb +5 -0
- data/lib/tasks/db_tasks.rake +29 -0
- metadata +362 -0
data/README.md
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
[](https://circleci.com/gh/Zetatango/daffy_lib) [](https://codecov.io/gh/Zetatango/daffy_lib) [](https://badge.fury.io/rb/daffy_lib)
|
2
|
+
# DaffyLib
|
3
|
+
|
4
|
+
This gem is a caching encryptor which improves performance when encrypting/decrypting large amounts of data. It will keep a plaintext key cached for a given amount of time, as well as provide partitioning to allow entire rows to be encrypted with the same key. Keys are uniquely identified by a pair of a partition guid and an encryption epoch.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
gem 'daffy_lib'
|
12
|
+
```
|
13
|
+
|
14
|
+
And then execute:
|
15
|
+
|
16
|
+
$ bundle install
|
17
|
+
|
18
|
+
Or install it yourself as:
|
19
|
+
|
20
|
+
$ gem install daffy_lib
|
21
|
+
|
22
|
+
## Usage
|
23
|
+
|
24
|
+
We illustrate usage of this library with an example. Suppose we have classes `User` and `User::Attribute` where attributes belong to users and have values that need to be encrypted. We wish for `User` to be the partition provider for `User::Attribute`.
|
25
|
+
|
26
|
+
The `User` class will need to `include DaffyLib::PartitionProvider` and implement the method `provider_partition_guid` which returns an identifier, for instance a user's `guid`.
|
27
|
+
|
28
|
+
The `User::Attribute` class will need to `include DaffyLib::PartitionProvider` as well as `include DaffyLib::HasEncryptedAttributes`. It will need to declare `partition_provider :user`.
|
29
|
+
|
30
|
+
It then needs to implement a `generate_partition_guid` which returns the linked `User`'s `guid`, as well as a `generate_encryption_epoch` method which defines the encryption epoch. The suggested implementations are:
|
31
|
+
|
32
|
+
```
|
33
|
+
def generate_partition_guid
|
34
|
+
return partition_guid if partition_guid.present?
|
35
|
+
|
36
|
+
self.partition_guid = provider_partition_guid
|
37
|
+
end
|
38
|
+
|
39
|
+
def generate_encryption_epoch
|
40
|
+
return encryption_epoch if encryption_epoch.present?
|
41
|
+
|
42
|
+
self.encryption_epoch = DaffyLib::KeyManagementService.encryption_key_epoch(Time.now)
|
43
|
+
end
|
44
|
+
```
|
45
|
+
|
46
|
+
Now suppose the `User::Attributes` has a `values` field to be encrypted. One declares
|
47
|
+
|
48
|
+
```
|
49
|
+
attr_encrypted :values, encryptor: ZtCachingEncryptor, encrypt_method: :zt_encrypt, decrypt_method: :zt_decrypt,
|
50
|
+
encode: true, partition_guid: proc { |object| object.generate_partition_guid },
|
51
|
+
encryption_epoch: proc { |object| object.generate_encryption_epoch }, expires_in: 5.minutes
|
52
|
+
```
|
53
|
+
|
54
|
+
where the `expires_in` field denotes how long a plaintext key should be kept in cache.
|
55
|
+
|
56
|
+
Note further that a class can be its own partition provider; i.e. if `User` itself had encrypted attributes, all the steps above for `User::Attributes` apply, except there is no need to declare `partition_provider`, and the recommended implementation for `generate_partition_guid` is to return (or create) the `guid` of the `User`.
|
57
|
+
|
58
|
+
There are partial rake tasks to assist with the necessary database migrations included.
|
59
|
+
|
60
|
+
Run `rake db:migrate:add_encryption_keys_table` to generate the migration file to add the encryption keys table. Add the following lines for indexing to the generated file.
|
61
|
+
|
62
|
+
```
|
63
|
+
t.index [:guid], name: :index_encryption_keys_on_guid, unique: true
|
64
|
+
t.index [:partition_guid, :key_epoch], name: :index_encryption_keys, unique: true
|
65
|
+
|
66
|
+
```
|
67
|
+
Next, if the models do not have existing records, one can run `rake db:migrate:add_encryption_fields[modelname]` to add the `partition_guid` and `encryption_epoch` columns.
|
68
|
+
|
69
|
+
However, if there may already be existing records, then the `generate_partition_guid` and `generate_encryption_epoch` methods need to be invoked before the new columns can be set to required. Below is a sample migration file for our example above, where one should replace `models` with their own.
|
70
|
+
```
|
71
|
+
def up
|
72
|
+
models = %i[users users/attributes]
|
73
|
+
|
74
|
+
models.each do |model|
|
75
|
+
|
76
|
+
model_classname = model.to_s.camelize.singularize.constantize
|
77
|
+
model_tablename = model.to_s.gsub('/', '_')
|
78
|
+
|
79
|
+
add_column model_tablename, :partition_guid, :string
|
80
|
+
add_column model_tablename, :encryption_epoch, :datetime
|
81
|
+
|
82
|
+
model_classname.reset_column_information
|
83
|
+
model_classname.find_each do |record|
|
84
|
+
record.generate_partition_guid
|
85
|
+
record.generate_encryption_epoch
|
86
|
+
|
87
|
+
record.save!(validate: false)
|
88
|
+
end
|
89
|
+
|
90
|
+
change_column model_tablename, :partition_guid, :string, null: false
|
91
|
+
change_column model_tablename, :encryption_epoch, :datetime, null: false
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def down
|
96
|
+
models = %i[users users/attributes]
|
97
|
+
|
98
|
+
models.each do |model|
|
99
|
+
model_tablename = model.to_s.gsub('/', '_')
|
100
|
+
|
101
|
+
remove_column model_tablename, :partition_guid
|
102
|
+
remove_column model_tablename, :encryption_epoch
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
```
|
107
|
+
|
108
|
+
|
109
|
+
## Development
|
110
|
+
|
111
|
+
Development on this project should occur on separate feature branches and pull requests should be submitted. When submitting a pull request, the pull request comment template should be filled out as much as possible to ensure a quick review and increase the likelihood of the pull request being accepted.
|
112
|
+
|
113
|
+
## Contributing
|
114
|
+
|
115
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/Zetatango/daffy_lib.
|
116
|
+
|
data/Rakefile
ADDED
data/SECURITY.MD
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "daffy_lib"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/daffy_lib.gemspec
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/daffy_lib/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "daffy_lib"
|
7
|
+
spec.version = DaffyLib::VERSION
|
8
|
+
spec.authors = ["Benoît Jeaurond, Weiyun Lu"]
|
9
|
+
spec.email = ["weiyun.lu@arioplatform.com"]
|
10
|
+
|
11
|
+
spec.summary = 'A library for caching encryptor'
|
12
|
+
spec.homepage = 'https://github.com/Zetatango/daffy_lib'
|
13
|
+
|
14
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
15
|
+
f.match(%r{^(test|spec|features)/})
|
16
|
+
end
|
17
|
+
spec.bindir = 'exe'
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_development_dependency 'attr_encrypted'
|
22
|
+
spec.add_development_dependency 'bundler'
|
23
|
+
spec.add_development_dependency 'bundler-audit'
|
24
|
+
spec.add_development_dependency 'codecov'
|
25
|
+
spec.add_development_dependency 'factory_bot_rails'
|
26
|
+
spec.add_development_dependency 'rake'
|
27
|
+
spec.add_development_dependency 'rspec'
|
28
|
+
spec.add_development_dependency 'rspec-collection_matchers'
|
29
|
+
spec.add_development_dependency 'rspec-mocks'
|
30
|
+
spec.add_development_dependency 'rspec-rails'
|
31
|
+
spec.add_development_dependency 'rspec_junit_formatter'
|
32
|
+
spec.add_development_dependency 'rubocop'
|
33
|
+
spec.add_development_dependency 'rubocop-performance'
|
34
|
+
spec.add_development_dependency 'rubocop-rspec'
|
35
|
+
spec.add_development_dependency 'rubocop_runner'
|
36
|
+
spec.add_development_dependency 'simplecov'
|
37
|
+
spec.add_development_dependency 'sqlite3'
|
38
|
+
spec.add_development_dependency 'timecop'
|
39
|
+
|
40
|
+
spec.add_dependency 'porky_lib'
|
41
|
+
spec.add_dependency 'rails'
|
42
|
+
spec.add_dependency 'redis'
|
43
|
+
end
|
data/lib/daffy_lib.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "daffy_lib/version"
|
4
|
+
|
5
|
+
module DaffyLib
|
6
|
+
require 'daffy_lib/caching_encryptor'
|
7
|
+
require 'daffy_lib/concerns/has_guid'
|
8
|
+
require 'daffy_lib/concerns/has_encrypted_attributes'
|
9
|
+
require 'daffy_lib/concerns/partition_provider'
|
10
|
+
require 'daffy_lib/models/application_record'
|
11
|
+
require 'daffy_lib/models/encryption_key'
|
12
|
+
require 'daffy_lib/railtie' if defined?(Rails) # for the rake tasks
|
13
|
+
require 'daffy_lib/services/key_management_service'
|
14
|
+
require 'daffy_lib/validators/string_validator'
|
15
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class DaffyLib::CachingEncryptor
|
4
|
+
class CachingEncryptorException < StandardError; end
|
5
|
+
|
6
|
+
class EncryptionFailedException < CachingEncryptorException; end
|
7
|
+
class DecryptionFailedException < CachingEncryptorException; end
|
8
|
+
class InvalidParameterException < CachingEncryptorException; end
|
9
|
+
|
10
|
+
def self.zt_encrypt(*args, &_block)
|
11
|
+
data, partition_guid, encryption_epoch, expires_in, cmk_key_id = validate_encrypt_params(*args)
|
12
|
+
|
13
|
+
kms = DaffyLib::KeyManagementService.new(partition_guid, expires_in, cmk_key_id)
|
14
|
+
|
15
|
+
key_info = kms.find_or_create_encryption_key(encryption_epoch)
|
16
|
+
|
17
|
+
plaintext_key = kms.retrieve_plaintext_key(key_info)
|
18
|
+
|
19
|
+
encryption_result = PorkyLib::Symmetric.instance.encrypt_with_key(data, plaintext_key)
|
20
|
+
|
21
|
+
# The value returned from this method is stored in the encrypted_{attr} field in the DB, but there isn't a way to tell the attr_encrypted library
|
22
|
+
# the value of the nonce/IV to store or the value of the encryption key to store. As a result, we will store a JSON object as the encrypted_{attr},
|
23
|
+
# with the raw byte values Base64 encoded.
|
24
|
+
{
|
25
|
+
key_guid: key_info.guid,
|
26
|
+
# Store this with the data in case we need to decrypt outside the platform
|
27
|
+
key: key_info.encrypted_data_encryption_key,
|
28
|
+
data: Base64.encode64(encryption_result.ciphertext),
|
29
|
+
nonce: Base64.encode64(encryption_result.nonce)
|
30
|
+
}.to_json
|
31
|
+
rescue DaffyLib::KeyManagementService::KeyManagementServiceException => e
|
32
|
+
Rails.logger.error("KeyManagementService exception on encrypt: #{e.message}")
|
33
|
+
|
34
|
+
raise EncryptionFailedException
|
35
|
+
rescue RbNaCl::CryptoError, RbNaCl::LengthError => e
|
36
|
+
Rails.logger.error("RbNaCl exception on encrypt: #{e.message}")
|
37
|
+
|
38
|
+
raise EncryptionFailedException
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.zt_decrypt(*args, &block)
|
42
|
+
value, expires_in, cmk_key_id = validate_decrypt_params(*args)
|
43
|
+
|
44
|
+
ciphertext_info = JSON.parse(value, symbolize_names: true)
|
45
|
+
|
46
|
+
# Call the legacy decrypt function if there is no key_guid present
|
47
|
+
return legacy_decrypt(*args, block) unless ciphertext_info.key?(:key_guid)
|
48
|
+
|
49
|
+
key_guid = ciphertext_info[:key_guid]
|
50
|
+
ciphertext = Base64.decode64(ciphertext_info[:data])
|
51
|
+
nonce = Base64.decode64(ciphertext_info[:nonce])
|
52
|
+
|
53
|
+
key_info = DaffyLib::EncryptionKey.find_by!(guid: key_guid)
|
54
|
+
|
55
|
+
kms = DaffyLib::KeyManagementService.new(key_info.partition_guid, expires_in, cmk_key_id)
|
56
|
+
plaintext_key = kms.retrieve_plaintext_key(key_info)
|
57
|
+
|
58
|
+
PorkyLib::Symmetric.instance.decrypt_with_key(
|
59
|
+
ciphertext,
|
60
|
+
plaintext_key,
|
61
|
+
nonce
|
62
|
+
).plaintext
|
63
|
+
rescue JSON::JSONError => e
|
64
|
+
Rails.logger.error("JSON parse error on decryption: #{e.message}")
|
65
|
+
|
66
|
+
raise DecryptionFailedException
|
67
|
+
rescue ActiveRecord::RecordNotFound => e
|
68
|
+
Rails.logger.error("Failed to find encryption key for guid #{key_guid} on decrypt: #{e.message}")
|
69
|
+
|
70
|
+
raise DecryptionFailedException
|
71
|
+
rescue DaffyLib::KeyManagementService::KeyManagementServiceException => e
|
72
|
+
Rails.logger.error("KeyManagementService exception on decrypt: #{e.message}")
|
73
|
+
|
74
|
+
raise DecryptionFailedException
|
75
|
+
rescue RbNaCl::CryptoError, RbNaCl::LengthError => e
|
76
|
+
Rails.logger.error("RbNaCl exception on decrypt: #{e.message}")
|
77
|
+
|
78
|
+
raise DecryptionFailedException
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.legacy_decrypt(*args, &_block)
|
82
|
+
ciphertext_data = JSON.parse(args.first[:value], symbolize_names: true)
|
83
|
+
ciphertext_key = Base64.decode64(ciphertext_data[:key])
|
84
|
+
ciphertext = Base64.decode64(ciphertext_data[:data])
|
85
|
+
nonce = Base64.decode64(ciphertext_data[:nonce])
|
86
|
+
|
87
|
+
PorkyLib::Symmetric.instance.decrypt(ciphertext_key, ciphertext, nonce).first
|
88
|
+
end
|
89
|
+
private_class_method :legacy_decrypt
|
90
|
+
|
91
|
+
def self.validate_encrypt_params(*args)
|
92
|
+
data = args.first[:value]
|
93
|
+
partition_guid = args.first[:partition_guid]
|
94
|
+
encryption_epoch = args.first[:encryption_epoch]
|
95
|
+
expires_in = args.first[:expires_in]
|
96
|
+
cmk_key_id = args.first[:cmk_key_id]
|
97
|
+
|
98
|
+
raise InvalidParameterException unless data.present? && partition_guid.present? && encryption_epoch.present? && expires_in.present? && cmk_key_id.present?
|
99
|
+
|
100
|
+
[data, partition_guid, encryption_epoch, expires_in, cmk_key_id]
|
101
|
+
end
|
102
|
+
private_class_method :validate_encrypt_params
|
103
|
+
|
104
|
+
def self.validate_decrypt_params(*args)
|
105
|
+
value = args.first[:value]
|
106
|
+
expires_in = args.first[:expires_in]
|
107
|
+
cmk_key_id = args.first[:cmk_key_id]
|
108
|
+
|
109
|
+
raise InvalidParameterException unless value.present? && expires_in.present? && cmk_key_id.present?
|
110
|
+
|
111
|
+
[value, expires_in, cmk_key_id]
|
112
|
+
end
|
113
|
+
private_class_method :validate_decrypt_params
|
114
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DaffyLib::HasEncryptedAttributes
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
before_create :generate_partition_guid, :generate_encryption_epoch
|
8
|
+
|
9
|
+
validate :ensure_encryption_info_does_not_change
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate_partition_guid
|
13
|
+
raise NoMethodError
|
14
|
+
end
|
15
|
+
|
16
|
+
def generate_encryption_epoch
|
17
|
+
raise NoMethodError
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def ensure_encryption_info_does_not_change
|
23
|
+
return if new_record?
|
24
|
+
|
25
|
+
errors.add(:partition_guid, 'cannot be changed for persisted records') if partition_guid_changed?
|
26
|
+
errors.add(:encryption_epoch, 'cannot be changed for persisted records') if encryption_epoch_changed?
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support'
|
4
|
+
|
5
|
+
module DaffyLib::HasGuid
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
REGEXP = /^[\w]+_[\w]+$/.freeze
|
9
|
+
|
10
|
+
mattr_accessor :registry do
|
11
|
+
{}
|
12
|
+
end
|
13
|
+
|
14
|
+
class_methods do
|
15
|
+
# rubocop:disable Naming/PredicateName
|
16
|
+
|
17
|
+
def has_guid(prefix)
|
18
|
+
if DaffyLib::HasGuid.registry[prefix].present?
|
19
|
+
raise ArgumentError, "Prefix #{prefix} has already been registered by class #{DaffyLib::HasGuid.registry[prefix]}"
|
20
|
+
end
|
21
|
+
|
22
|
+
DaffyLib::HasGuid.registry[prefix] = self
|
23
|
+
|
24
|
+
class_eval do
|
25
|
+
cattr_accessor :has_guid_prefix do
|
26
|
+
prefix
|
27
|
+
end
|
28
|
+
|
29
|
+
before_create :generate_guid
|
30
|
+
validate :ensure_guid_does_not_change
|
31
|
+
|
32
|
+
def dup
|
33
|
+
super.tap { |duplicate| duplicate.guid = nil if duplicate.respond_to?('guid=') }
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_param
|
37
|
+
guid.to_param
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.generate_guid
|
41
|
+
"#{has_guid_prefix}_#{SecureRandom.base58(16)}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def generate_guid
|
45
|
+
self.guid = guid.presence || self.class.generate_guid
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.validation_regexp
|
49
|
+
/^#{has_guid_prefix}_[\w]+$/
|
50
|
+
end
|
51
|
+
|
52
|
+
def ensure_guid_does_not_change
|
53
|
+
return if new_record?
|
54
|
+
return unless guid_changed?
|
55
|
+
|
56
|
+
errors.add(:guid, 'cannot be changed for persisted records')
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
# rubocop:enable Naming/PredicateName
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DaffyLib::PartitionProvider
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
class_methods do
|
7
|
+
def partition_provider(attribute)
|
8
|
+
class_eval do
|
9
|
+
cattr_accessor :partition_provider_attribute do
|
10
|
+
attribute
|
11
|
+
end
|
12
|
+
|
13
|
+
def provider_partition_guid
|
14
|
+
provider_info
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def partition_provider_guid(guid_method, model)
|
20
|
+
class_eval do
|
21
|
+
cattr_accessor :model do
|
22
|
+
model
|
23
|
+
end
|
24
|
+
|
25
|
+
cattr_accessor :guid_method do
|
26
|
+
guid_method
|
27
|
+
end
|
28
|
+
|
29
|
+
def provider_partition_guid
|
30
|
+
provider_record_info(record)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def record
|
36
|
+
model.find_by(guid: send(guid_method))
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def provider_info
|
43
|
+
raise ActiveRecord::RecordInvalid if partition_provider_attribute.nil?
|
44
|
+
|
45
|
+
provider_record_info(send(partition_provider_attribute))
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def provider_record_info(record)
|
51
|
+
raise ActiveRecord::RecordInvalid unless record.present? && record.is_a?(DaffyLib::PartitionProvider)
|
52
|
+
|
53
|
+
info = record.send(:provider_partition_guid)
|
54
|
+
|
55
|
+
raise ActiveRecord::RecordInvalid if info.nil?
|
56
|
+
|
57
|
+
info
|
58
|
+
end
|
59
|
+
end
|