attr_masker 0.2.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.github/workflows/tests.yml +80 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +13 -1069
- data/CHANGELOG.adoc +4 -0
- data/Gemfile +5 -0
- data/README.adoc +54 -29
- data/attr_masker.gemspec +13 -10
- data/bin/rake +12 -1
- data/bin/rspec +11 -0
- data/bin/rubocop +11 -0
- data/gemfiles/Rails-4.2.gemfile +2 -1
- data/gemfiles/Rails-5.0.gemfile +2 -1
- data/gemfiles/Rails-5.1.gemfile +2 -1
- data/gemfiles/Rails-5.2.gemfile +4 -0
- data/gemfiles/Rails-6.0.gemfile +3 -0
- data/gemfiles/Rails-head.gemfile +1 -1
- data/gemfiles/common.gemfile +3 -4
- data/lib/attr_masker/attribute.rb +15 -5
- data/lib/attr_masker/maskers/replacing.rb +19 -3
- data/lib/attr_masker/maskers/simple.rb +14 -4
- data/lib/attr_masker/model.rb +3 -0
- data/lib/attr_masker/performer.rb +27 -9
- data/lib/attr_masker/version.rb +1 -16
- data/lib/tasks/db.rake +0 -3
- data/spec/dummy/config/routes.rb +0 -1
- data/spec/features/active_record_spec.rb +77 -8
- data/spec/features/mongoid_spec.rb +3 -3
- data/spec/features/shared_examples.rb +94 -1
- data/spec/spec_helper.rb +26 -1
- data/spec/support/00_control_constants.rb +2 -0
- data/spec/support/{00_mongoid_env.rb → 10_mongoid_env.rb} +1 -1
- data/spec/support/20_combustion.rb +3 -3
- data/spec/support/db_cleaner.rb +4 -2
- data/spec/support/rake.rb +1 -1
- data/spec/unit/attribute_spec.rb +88 -6
- metadata +87 -29
- data/.travis.yml +0 -43
- data/gemfiles/Rails-4.0.gemfile +0 -3
- data/gemfiles/Rails-4.1.gemfile +0 -3
data/CHANGELOG.adoc
CHANGED
data/Gemfile
CHANGED
data/README.adoc
CHANGED
@@ -3,18 +3,33 @@
|
|
3
3
|
:pygments-style: native
|
4
4
|
:pygments-linenums-mode: inline
|
5
5
|
|
6
|
-
|
7
|
-
image:https://img.shields.io/
|
6
|
+
ifdef::env-github[]
|
7
|
+
image:https://img.shields.io/gem/v/attr_masker[
|
8
|
+
"Gem Version",
|
9
|
+
link="https://rubygems.org/gems/attr_masker"]
|
10
|
+
image:https://img.shields.io/github/workflow/status/riboseinc/attr_masker/Tests[
|
11
|
+
"Build Status",
|
12
|
+
link="https://github.com/riboseinc/attr_masker/actions"]
|
13
|
+
image:https://img.shields.io/codeclimate/maintainability/riboseinc/attr_masker[
|
14
|
+
"Code Climate",
|
15
|
+
link="https://codeclimate.com/github/riboseinc/attr_masker"]
|
16
|
+
image:https://img.shields.io/codecov/c/github/riboseinc/attr_masker[
|
17
|
+
"Test Coverage",
|
18
|
+
link="https://codecov.io/gh/riboseinc/attr_masker"]
|
19
|
+
image:https://img.shields.io/badge/documentation-rdoc-informational[
|
20
|
+
"Documentation on RubyDoc.info",
|
21
|
+
link="https://rubydoc.info/gems/attr_masker"]
|
22
|
+
endif::[]
|
8
23
|
|
9
24
|
Mask ActiveRecord/Mongoid data with ease!
|
10
25
|
|
11
26
|
== Introduction
|
12
27
|
|
13
28
|
This gem is intended to mask sensitive data so that production database dumps
|
14
|
-
can be used in staging or test environments. It works with Rails 4+ and
|
15
|
-
Rubies. It supports Active Record and Mongoid models.
|
29
|
+
can be used in staging or test environments. It works with Rails 4.2+ and
|
30
|
+
modern Rubies. It supports Active Record and Mongoid models.
|
16
31
|
|
17
|
-
==
|
32
|
+
== Usage instructions
|
18
33
|
|
19
34
|
=== Installation
|
20
35
|
|
@@ -71,31 +86,9 @@ attr_masker :first_name, :if => :tester_user?
|
|
71
86
|
|
72
87
|
The ActiveRecord's `::default_scope` method has no effect on masking. All
|
73
88
|
table records are updated, provided that :if and :unless filters allow that.
|
74
|
-
For example, if you're using a
|
89
|
+
For example, if you're using a https://github.com/rubysherpas/paranoia[Paranoia]
|
75
90
|
gem to soft-delete your data, records marked as deleted will be masked as well.
|
76
91
|
|
77
|
-
=== Using custom maskers
|
78
|
-
|
79
|
-
By default, data is maksed with `AttrMasker::Maskers::Simple` masker which
|
80
|
-
always returns `"(redacted)"` string. But anything what responds to `#call`
|
81
|
-
can be used instead: a lambda, `Method` instance, and more. You can specify it
|
82
|
-
by setting the `:masker` option.
|
83
|
-
|
84
|
-
For instance, you may want to use https://github.com/ffaker/ffaker[ffaker] or
|
85
|
-
https://github.com/skalee/well_read_faker[Well Read Faker] to generate random
|
86
|
-
replacement values:
|
87
|
-
|
88
|
-
[source,ruby]
|
89
|
-
----
|
90
|
-
require "ffaker"
|
91
|
-
|
92
|
-
attr_masker :first_name, :masker => ->(_hash) { FFaker::Name.first_name }
|
93
|
-
----
|
94
|
-
|
95
|
-
A hash is passed as an argument, which includes some information about the
|
96
|
-
record being masked and the attribute value. It can be used to further
|
97
|
-
customize masker's behaviour.
|
98
|
-
|
99
92
|
=== Built-in maskers
|
100
93
|
|
101
94
|
Attr Masker comes with several built-in maskers.
|
@@ -126,7 +119,7 @@ Can be initialized with options.
|
|
126
119
|
|===============================================================================
|
127
120
|
|Name|Default|Description
|
128
121
|
|`replacement`|`"*"`|Replacement string, can be empty.
|
129
|
-
|`alphanum_only`|`false`|When true, only alphanumeric
|
122
|
+
|`alphanum_only`|`false`|When true, only alphanumeric characters are replaced.
|
130
123
|
|===============================================================================
|
131
124
|
+
|
132
125
|
Example:
|
@@ -139,7 +132,39 @@ attr_masker :phone, :masker => rm
|
|
139
132
|
+
|
140
133
|
Would mask "123-456-7890" as "XXX-XXX-XXXX".
|
141
134
|
|
135
|
+
=== Using custom maskers
|
136
|
+
|
137
|
+
Apart from built-in maskers, any object which responds to `#call` can be used,
|
138
|
+
e.g. some lambda or `Method` instance. For instance, you may want to produce
|
139
|
+
unique values basing on other attributes, to mask selectively, or to use
|
140
|
+
tool like https://github.com/skalee/well_read_faker[Well Read Faker] to
|
141
|
+
generate random replacement values:
|
142
|
+
|
143
|
+
[source,ruby]
|
144
|
+
----
|
145
|
+
require "well_read_faker"
|
146
|
+
|
147
|
+
attr_masker :email, masker: ->(model:, **) { "user#{model.id}@example.com" }
|
148
|
+
attr_masker :phone, masker: ->(value:, **) { "******" + value[-3..-1] }
|
149
|
+
attr_masker :bio, masker: ->(**) { WellReadFaker.paragraph }
|
150
|
+
----
|
151
|
+
|
152
|
+
Masker is called with following keyword arguments:
|
153
|
+
|
154
|
+
`value`:: Original value of the field which is about to be masked
|
155
|
+
|
156
|
+
`model`:: Model instance
|
157
|
+
|
158
|
+
`attribute_name`:: Name of the attribute which is about to be masked
|
159
|
+
|
160
|
+
`masking_options`:: Hash of options which were passed in `#attr_masker` call
|
161
|
+
|
162
|
+
This list is likely to be extended in future versions, and that will not be
|
163
|
+
considered a breaking change, therefore it is strongly recommended to always
|
164
|
+
use a splat (`**`) at end of argument list of masker's `#call` method.
|
165
|
+
|
142
166
|
== Roadmap & TODOs
|
167
|
+
|
143
168
|
- documentation
|
144
169
|
- spec tests
|
145
170
|
- Make the `Rails.env` (in which `db:mask` could be run) configurable
|
data/attr_masker.gemspec
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
# (c) 2017 Ribose Inc.
|
2
2
|
#
|
3
3
|
|
4
|
-
lib = File.expand_path("
|
4
|
+
lib = File.expand_path("lib", __dir__)
|
5
5
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
6
6
|
require "attr_masker/version"
|
7
7
|
|
8
8
|
Gem::Specification.new do |gem|
|
9
9
|
gem.name = "attr_masker"
|
10
|
-
gem.version = AttrMasker::
|
10
|
+
gem.version = AttrMasker::VERSION
|
11
11
|
gem.authors = ["Ribose Inc."]
|
12
12
|
gem.email = ["open.source@ribose.com"]
|
13
13
|
gem.homepage = "https://github.com/riboseinc/attr_masker"
|
@@ -17,20 +17,23 @@ Gem::Specification.new do |gem|
|
|
17
17
|
"of certain models by modifying the database."
|
18
18
|
|
19
19
|
gem.files = `git ls-files`.split($/)
|
20
|
-
gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
|
21
20
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
22
21
|
gem.require_paths = ["lib"]
|
23
22
|
|
24
|
-
gem.add_runtime_dependency("rails", ">= 4.0.0", "<
|
23
|
+
gem.add_runtime_dependency("rails", ">= 4.0.0", "< 7")
|
25
24
|
gem.add_runtime_dependency("ruby-progressbar", "~> 1.8")
|
26
25
|
|
27
|
-
gem.add_development_dependency("bundler", "
|
28
|
-
gem.add_development_dependency("combustion", "~>
|
29
|
-
gem.add_development_dependency("database_cleaner", "~> 1.
|
26
|
+
gem.add_development_dependency("bundler", ">= 1.15")
|
27
|
+
gem.add_development_dependency("combustion", "~> 1.0")
|
28
|
+
gem.add_development_dependency("database_cleaner", "~> 1.8")
|
29
|
+
gem.add_development_dependency("database_cleaner-active_record", "~> 1.8")
|
30
|
+
gem.add_development_dependency("database_cleaner-mongoid", "~> 1.8")
|
30
31
|
# Older versions aren't needed as we don't support Rails < 4
|
31
32
|
gem.add_development_dependency("mongoid", ">= 5")
|
32
33
|
gem.add_development_dependency("pry")
|
33
|
-
gem.add_development_dependency("rspec", "
|
34
|
-
gem.add_development_dependency("rubocop", "~> 0.
|
35
|
-
gem.add_development_dependency("
|
34
|
+
gem.add_development_dependency("rspec", "~> 3.0")
|
35
|
+
gem.add_development_dependency("rubocop", "~> 0.54.0")
|
36
|
+
gem.add_development_dependency("simplecov")
|
37
|
+
gem.add_development_dependency("sqlite3", ">= 1.3.13", "< 2")
|
38
|
+
gem.add_development_dependency("warning", "~> 1.1")
|
36
39
|
end
|
data/bin/rake
CHANGED
@@ -12,7 +12,18 @@ require "pathname"
|
|
12
12
|
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
13
|
Pathname.new(__FILE__).realpath)
|
14
14
|
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
15
26
|
require "rubygems"
|
16
27
|
require "bundler/setup"
|
17
28
|
|
18
|
-
load Gem.bin_path("rake", "rake")
|
29
|
+
load Gem.bin_path("rake", "rake") if File.exists?(Gem.bin_path("rake", "rake"))
|
data/bin/rspec
CHANGED
@@ -12,6 +12,17 @@ require "pathname"
|
|
12
12
|
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
13
|
Pathname.new(__FILE__).realpath)
|
14
14
|
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
15
26
|
require "rubygems"
|
16
27
|
require "bundler/setup"
|
17
28
|
|
data/bin/rubocop
CHANGED
@@ -12,6 +12,17 @@ require "pathname"
|
|
12
12
|
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
13
|
Pathname.new(__FILE__).realpath)
|
14
14
|
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
15
26
|
require "rubygems"
|
16
27
|
require "bundler/setup"
|
17
28
|
|
data/gemfiles/Rails-4.2.gemfile
CHANGED
data/gemfiles/Rails-5.0.gemfile
CHANGED
data/gemfiles/Rails-5.1.gemfile
CHANGED
data/gemfiles/Rails-head.gemfile
CHANGED
data/gemfiles/common.gemfile
CHANGED
@@ -13,7 +13,7 @@ module AttrMasker
|
|
13
13
|
end
|
14
14
|
|
15
15
|
# Evaluates the +:if+ and +:unless+ attribute options on given instance.
|
16
|
-
# Returns +true+ or +
|
16
|
+
# Returns +true+ or +false+, depending on whether the attribute should be
|
17
17
|
# masked for this object or not.
|
18
18
|
def should_mask?(model_instance)
|
19
19
|
not (
|
@@ -34,8 +34,16 @@ module AttrMasker
|
|
34
34
|
# returning.
|
35
35
|
def mask(model_instance)
|
36
36
|
value = unmarshal_data(model_instance.send(name))
|
37
|
-
|
38
|
-
|
37
|
+
masker = options[:masker]
|
38
|
+
masker_value = masker.call(value: value, model: model_instance,
|
39
|
+
attribute_name: name, masking_options: options)
|
40
|
+
model_instance.send("#{name}=", marshal_data(masker_value))
|
41
|
+
end
|
42
|
+
|
43
|
+
# Returns a hash of maskable attribute names, and respective attribute
|
44
|
+
# values. Unchanged attributes are skipped.
|
45
|
+
def masked_attributes_new_values(model_instance)
|
46
|
+
model_instance.changes.slice(*column_names).transform_values(&:second)
|
39
47
|
end
|
40
48
|
|
41
49
|
# Evaluates option (typically +:if+ or +:unless+) on given model instance.
|
@@ -55,16 +63,18 @@ module AttrMasker
|
|
55
63
|
|
56
64
|
def marshal_data(data)
|
57
65
|
return data unless options[:marshal]
|
66
|
+
|
58
67
|
options[:marshaler].send(options[:dump_method], data)
|
59
68
|
end
|
60
69
|
|
61
70
|
def unmarshal_data(data)
|
62
71
|
return data unless options[:marshal]
|
72
|
+
|
63
73
|
options[:marshaler].send(options[:load_method], data)
|
64
74
|
end
|
65
75
|
|
66
|
-
def
|
67
|
-
options[:
|
76
|
+
def column_names
|
77
|
+
options[:column_names] || [name]
|
68
78
|
end
|
69
79
|
end
|
70
80
|
end
|
@@ -2,14 +2,30 @@
|
|
2
2
|
#
|
3
3
|
module AttrMasker
|
4
4
|
module Maskers
|
5
|
-
#
|
5
|
+
# +Replacing+ masker replaces every character of string which is being
|
6
|
+
# masked with +replacement+ one, preserving the length of the masked string
|
7
|
+
# (provided that a replacement string contains a single character, which is
|
8
|
+
# a typical case). Optionally, non-alphanumeric characters like dashes or
|
9
|
+
# spaces may be left unchanged.
|
6
10
|
#
|
7
|
-
#
|
8
|
-
#
|
11
|
+
# @example Would mask "Adam West" as "XXXXXXXXX"
|
12
|
+
# class User < ActiveRecord::Base
|
13
|
+
# m = AttrMasker::Maskers::Replacing.new(replacement: "X")
|
14
|
+
# attr_masker :name, :masker => m
|
15
|
+
# end
|
9
16
|
#
|
17
|
+
# @example Would mask "123-456-789" as "XXX-XXX-XXX"
|
18
|
+
# class User < ActiveRecord::Base
|
19
|
+
# m = AttrMasker::Maskers::Replacing.new(
|
20
|
+
# replacement: "X", alphanum_only: true)
|
21
|
+
# attr_masker :phone, :masker => m
|
22
|
+
# end
|
10
23
|
class Replacing
|
11
24
|
attr_reader :replacement, :alphanum_only
|
12
25
|
|
26
|
+
# @param replacement [String] replacement string
|
27
|
+
# @param alphanum_only [Boolean] whether to leave non-alphanumeric
|
28
|
+
# characters unchanged or not
|
13
29
|
def initialize(replacement: "*", alphanum_only: false)
|
14
30
|
replacement = "" if replacement.nil?
|
15
31
|
@replacement = replacement
|
@@ -1,12 +1,22 @@
|
|
1
1
|
module AttrMasker
|
2
2
|
module Maskers
|
3
|
-
#
|
3
|
+
# +Simple+ masker replaces values with a predefined +(redacted)+ string.
|
4
|
+
# This is a default masker, which is used when no specific +:masker+ is
|
5
|
+
# passed in +attr_masker+ method call.
|
4
6
|
#
|
5
|
-
#
|
6
|
-
#
|
7
|
+
# @example Would mask "Adam West" as "(redacted)"
|
8
|
+
# class User < ActiveRecord::Base
|
9
|
+
# m = AttrMasker::Maskers::Simple.new
|
10
|
+
# attr_masker :name, :masker => m
|
11
|
+
# end
|
7
12
|
#
|
13
|
+
# @example Would mask "Adam West" as "(redacted)"
|
14
|
+
# class User < ActiveRecord::Base
|
15
|
+
# attr_masker :name
|
16
|
+
# end
|
8
17
|
class Simple
|
9
|
-
|
18
|
+
# Accepts any keyword arguments, but they all are ignored.
|
19
|
+
def call(**_opts)
|
10
20
|
"(redacted)"
|
11
21
|
end
|
12
22
|
end
|
data/lib/attr_masker/model.rb
CHANGED
@@ -73,6 +73,8 @@ module AttrMasker
|
|
73
73
|
# @user.masker_configuration # returns the masker version of configuration
|
74
74
|
#
|
75
75
|
# See README for more examples
|
76
|
+
#--
|
77
|
+
# rubocop:disable Metrics/MethodLength
|
76
78
|
def attr_masker(*args)
|
77
79
|
default_options = {
|
78
80
|
if: true,
|
@@ -94,6 +96,7 @@ module AttrMasker
|
|
94
96
|
masker_attributes[attribute.name] = attribute
|
95
97
|
end
|
96
98
|
end
|
99
|
+
# rubocop:enable Metrics/MethodLength
|
97
100
|
|
98
101
|
# Default options to use with calls to <tt>attr_masker</tt>
|
99
102
|
# XXX:Keep
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# (c) 2017 Ribose Inc.
|
2
2
|
#
|
3
|
+
|
3
4
|
module AttrMasker
|
4
5
|
module Performer
|
5
6
|
class Base
|
@@ -7,25 +8,40 @@ module AttrMasker
|
|
7
8
|
# Do not want production environment to be masked!
|
8
9
|
#
|
9
10
|
if Rails.env.production?
|
10
|
-
|
11
|
+
unless ENV["FORCE_MASK"]
|
12
|
+
msg = "Attempted to run in production environment."
|
13
|
+
raise AttrMasker::Error, msg
|
14
|
+
end
|
11
15
|
end
|
12
16
|
|
13
17
|
all_models.each do |klass|
|
14
18
|
next if klass.masker_attributes.empty?
|
19
|
+
|
15
20
|
mask_class(klass)
|
16
21
|
end
|
17
22
|
end
|
18
23
|
|
19
24
|
private
|
20
25
|
|
26
|
+
# Mask all objects of a class in batches to not run out of memory!
|
27
|
+
#--
|
28
|
+
# rubocop:todo Metrics/MethodLength
|
21
29
|
def mask_class(klass)
|
22
30
|
progressbar_for_model(klass) do |bar|
|
23
|
-
klass.all.unscoped.
|
24
|
-
|
25
|
-
|
31
|
+
if klass.all.unscoped.respond_to?(:find_each)
|
32
|
+
klass.all.unscoped.find_each(batch_size: 1000) do |model|
|
33
|
+
mask_object model
|
34
|
+
bar.increment
|
35
|
+
end
|
36
|
+
else
|
37
|
+
klass.all.unscoped.each do |model|
|
38
|
+
mask_object model
|
39
|
+
bar.increment
|
40
|
+
end
|
26
41
|
end
|
27
42
|
end
|
28
43
|
end
|
44
|
+
# rubocop:enable Metrics/MethodLength
|
29
45
|
|
30
46
|
# For each masker attribute, mask it, and save it!
|
31
47
|
#
|
@@ -35,9 +51,8 @@ module AttrMasker
|
|
35
51
|
updates = klass.masker_attributes.values.reduce({}) do |acc, attribute|
|
36
52
|
next acc unless attribute.should_mask?(instance)
|
37
53
|
|
38
|
-
|
39
|
-
|
40
|
-
acc.merge!(column_name => masker_value)
|
54
|
+
attribute.mask(instance)
|
55
|
+
acc.merge! attribute.masked_attributes_new_values(instance)
|
41
56
|
end
|
42
57
|
|
43
58
|
make_update instance, updates unless updates.empty?
|
@@ -48,7 +63,7 @@ module AttrMasker
|
|
48
63
|
title: klass.name,
|
49
64
|
total: klass.unscoped.count,
|
50
65
|
throttle_rate: 0.1,
|
51
|
-
format: %
|
66
|
+
format: "%t %c/%C (%j%%) %B %E",
|
52
67
|
)
|
53
68
|
|
54
69
|
yield bar
|
@@ -66,9 +81,12 @@ module AttrMasker
|
|
66
81
|
::ActiveRecord::Base.descendants.select(&:table_exists?)
|
67
82
|
end
|
68
83
|
|
84
|
+
#--
|
85
|
+
# rubocop:disable Rails/SkipsModelValidations
|
69
86
|
def make_update(instance, updates)
|
70
|
-
instance.class.all.unscoped.
|
87
|
+
instance.class.all.unscoped.where(id: instance.id).update_all(updates)
|
71
88
|
end
|
89
|
+
# rubocop:enable Rails/SkipsModelValidations
|
72
90
|
end
|
73
91
|
|
74
92
|
class Mongoid < Base
|