attribute_guard 1.0.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 +10 -0
- data/MIT-LICENSE.txt +20 -0
- data/README.md +164 -0
- data/VERSION +1 -0
- data/attribute_guard.gemspec +37 -0
- data/lib/attribute_guard.rb +154 -0
- data/lib/locale/en.yml +4 -0
- metadata +79 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a485b9e0bc322539e0ac6b7014eb40824c4d94227fc216fab8add8a4f7a201fe
|
4
|
+
data.tar.gz: 792be937f2a9c6a611773d99c7e9f42c84546e3d1693046a4b7d3ab7b06d7097
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 36e1b736350dba0238b811205629d6dd4e3b15ef12a60084cb56f78104d90e0cf1b2df797ef555e352e023678d7f838302a583769e876f967a4e9fa5d5306452
|
7
|
+
data.tar.gz: d961953fc003bfcbada924c84a07b24aaacbca72d1d14c06d1d148ef76d5fb0926a4ecac6999758a63761c85873bdabcf5d76c1eebfda1285cd299ef3ecf6a69
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
# Changelog
|
2
|
+
All notable changes to this project will be documented in this file.
|
3
|
+
|
4
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
6
|
+
|
7
|
+
## 1.0.0 (unreleased)
|
8
|
+
|
9
|
+
### Added
|
10
|
+
- Initial release.
|
data/MIT-LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2023 Brian Durand
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,164 @@
|
|
1
|
+
# Active Record Attribute Guard
|
2
|
+
|
3
|
+
[](https://github.com/bdurand/attribute_guard/actions/workflows/continuous_integration.yml)
|
4
|
+
[](https://github.com/testdouble/standard)
|
5
|
+
|
6
|
+
This Ruby gem provides an extension for ActiveRecord allowing you to declare certain attributes in a model to be locked. Locked attributes cannot be changed once a record is created unless you explicitly allow changes.
|
7
|
+
|
8
|
+
This feature can be used for a couple of different purposes.
|
9
|
+
|
10
|
+
1. Preventing changes to data that should be immutable.
|
11
|
+
2. Prevent direct data updates that bypass required business logic.
|
12
|
+
|
13
|
+
## Usage
|
14
|
+
|
15
|
+
### Declaring Locked Attributes
|
16
|
+
|
17
|
+
To declare locked attributes you simply need to include the `AttributeGuard` module into your model and then list the attributes with the `lock_attributes` method.
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
class MyModel < ApplicationRecord
|
21
|
+
include AttributeGuard
|
22
|
+
|
23
|
+
lock_attributes :created_by, :created_at
|
24
|
+
end
|
25
|
+
```
|
26
|
+
|
27
|
+
Once that is done, if you try to change a locked value on an existing record, you will get a validation error.
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
record = MyModel.last
|
31
|
+
record.created_at = Time.now
|
32
|
+
record.save! # => raises ActiveRecord::RecordInvalid
|
33
|
+
```
|
34
|
+
|
35
|
+
You can customize the validation error message by setting the value of the `errors.messages.locked` value in your i18n localization files. You can also specify an error message with the optional `error` keyword argument.
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
class MyModel < ApplicationRecord
|
39
|
+
include AttributeGuard
|
40
|
+
|
41
|
+
lock_attributes :created_by, error: "cannot be changed except by an admin"
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
### Unlocking Attributes
|
46
|
+
|
47
|
+
You can allow changes to locked attributes with the `unlock_attributes` method.
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
record = MyModel.last
|
51
|
+
record.unlock_attributes(:created_at, :created_by)
|
52
|
+
record.update!(created_at: Time.now, created_by: nil) # Changes are persisted
|
53
|
+
```
|
54
|
+
|
55
|
+
You can also supply a block to `unlock_attributes` which will clear any unlocked attributes when the block exits.
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
record = MyModel.last
|
59
|
+
record.unlock_attributes(:created_at) do
|
60
|
+
record.update!(created_at: Time.now) # Changes are persisted
|
61
|
+
end
|
62
|
+
|
63
|
+
record.update!(created_at: Time.now) # => raises ActiveRecord::RecordInvalid
|
64
|
+
```
|
65
|
+
|
66
|
+
The `unlock_attributes` method will return the record itself, so you can chain other instance methods off of it.
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
record.unlock_attributes(:created_at).update!(created_at: Time.now)
|
70
|
+
```
|
71
|
+
|
72
|
+
### Using As A Guard
|
73
|
+
|
74
|
+
You can use locked attributes as a guard to prevent direct updates to certain attributes and force changes to go through specific methods instead.
|
75
|
+
|
76
|
+
For example, suppose we have some business logic that needs to execute whenever the `status` field is changed. You might wrap that logic up into a method or service object. For this example, suppose that we want to send some kind of alert any time the status is changed.
|
77
|
+
|
78
|
+
```ruby
|
79
|
+
class MyModel
|
80
|
+
def update_status(new_status)
|
81
|
+
update!(status: new_status)
|
82
|
+
StatusAlert.new(self).send_status_changed_alert
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
record = MyModel.last
|
87
|
+
record.update_status("completed")
|
88
|
+
```
|
89
|
+
|
90
|
+
This has the risk, though, that you can still make direct updates to the `status` which would bypass the additional business logic.
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
record = MyModel.last
|
94
|
+
record.update!(status: "canceled") # StatusAlert method is not called.
|
95
|
+
```
|
96
|
+
|
97
|
+
You can prevent this by locking the `status` attribute and then unlocking it within the method that includes the required business logic.
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
class MyModel
|
101
|
+
include AttributeGuard
|
102
|
+
|
103
|
+
lock_attributes :status
|
104
|
+
|
105
|
+
def update_status(new_status)
|
106
|
+
unlock_attributes(:status) do
|
107
|
+
update!(status: new_status)
|
108
|
+
end
|
109
|
+
StatusAlert.new(self).send_status_changed_alert
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
record = MyModel.last
|
114
|
+
record.update_status("completed") # Status gets updated
|
115
|
+
record.update!(status: "canceled") # raises ActiveRecord::RecordInvalid error
|
116
|
+
```
|
117
|
+
|
118
|
+
### Modes
|
119
|
+
|
120
|
+
The default behavior when a locked attribute is changed is to add a validation error to the record. You can change this behavior with the `mode` option when locking attributes.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
class MyModel
|
124
|
+
include AttributeGuard
|
125
|
+
|
126
|
+
lock_attributes :email, mode: :error
|
127
|
+
lock_attributes :name: mode: :warn
|
128
|
+
lock_attributes :created_at, mode: ->(record, attribute) { raise "Created timestamp cannot be changed" }
|
129
|
+
end
|
130
|
+
```
|
131
|
+
|
132
|
+
* `:error` - Add a validation error to the record. This is the default.
|
133
|
+
|
134
|
+
* `:warn` - Log a warning that the record was changed. This mode is useful to allow you soft deploy locked attributes to production on a mature project and give you information about where you may need to update code to unlock attributes.
|
135
|
+
|
136
|
+
* `Proc` - If you provide a `Proc` object, it will be called with the record and the attribute name when a locked attribute is changed.
|
137
|
+
|
138
|
+
## Installation
|
139
|
+
|
140
|
+
Add this line to your application's Gemfile:
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
gem "attribute_guard"
|
144
|
+
```
|
145
|
+
|
146
|
+
Then execute:
|
147
|
+
```bash
|
148
|
+
$ bundle
|
149
|
+
```
|
150
|
+
|
151
|
+
Or install it yourself as:
|
152
|
+
```bash
|
153
|
+
$ gem install attribute_guard
|
154
|
+
```
|
155
|
+
|
156
|
+
## Contributing
|
157
|
+
|
158
|
+
Open a pull request on [GitHub](https://github.com/bdurand/attribute_guard).
|
159
|
+
|
160
|
+
Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
|
161
|
+
|
162
|
+
## License
|
163
|
+
|
164
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
@@ -0,0 +1,37 @@
|
|
1
|
+
Gem::Specification.new do |spec|
|
2
|
+
spec.name = "attribute_guard"
|
3
|
+
spec.version = File.read(File.expand_path("../VERSION", __FILE__)).strip
|
4
|
+
spec.authors = ["Brian Durand"]
|
5
|
+
spec.email = ["bbdurand@gmail.com"]
|
6
|
+
|
7
|
+
spec.summary = "ActiveRecord extension that allows locking attributes to prevent unintended updates."
|
8
|
+
|
9
|
+
spec.homepage = "https://github.com/bdurand/attribute_guard"
|
10
|
+
spec.license = "MIT"
|
11
|
+
|
12
|
+
# Specify which files should be added to the gem when it is released.
|
13
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
14
|
+
ignore_files = %w[
|
15
|
+
.
|
16
|
+
Appraisals
|
17
|
+
Gemfile
|
18
|
+
Gemfile.lock
|
19
|
+
Rakefile
|
20
|
+
config.ru
|
21
|
+
assets/
|
22
|
+
bin/
|
23
|
+
gemfiles/
|
24
|
+
spec/
|
25
|
+
]
|
26
|
+
spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
|
27
|
+
`git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
|
28
|
+
end
|
29
|
+
|
30
|
+
spec.require_paths = ["lib"]
|
31
|
+
|
32
|
+
spec.required_ruby_version = ">= 2.5"
|
33
|
+
|
34
|
+
spec.add_dependency "activerecord", ">= 5.0"
|
35
|
+
|
36
|
+
spec.add_development_dependency "bundler"
|
37
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require "active_support/lazy_load_hooks"
|
5
|
+
require "active_model/validator"
|
6
|
+
|
7
|
+
ActiveSupport.on_load(:i18n) do
|
8
|
+
I18n.load_path << File.expand_path("locale/en.yml", __dir__)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Extension for ActiveRecord models that adds the capability to lock attributes to prevent direct
|
12
|
+
# changes to them. This is useful for attributes that should only be changed through specific methods.
|
13
|
+
#
|
14
|
+
# @example
|
15
|
+
# class User < ActiveRecord::Base
|
16
|
+
# include AttributeGuard
|
17
|
+
#
|
18
|
+
# lock_attributes :name, :email
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# user = User.create!(name: "Test", email: "test@example")
|
22
|
+
# user.name = "Test 2"
|
23
|
+
# user.save! # => raises ActiveRecord::RecordInvalid
|
24
|
+
#
|
25
|
+
# user.unlock_attributes(:name)
|
26
|
+
# user.name = "Test 2"
|
27
|
+
# user.save! # => saves successfully
|
28
|
+
#
|
29
|
+
# user.unlock_attributes(:name) do
|
30
|
+
# user.name = "Test 3"
|
31
|
+
# user.save! # => saves successfully
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# user.name = "Test 4"
|
35
|
+
# user.save! # => raises ActiveRecord::RecordInvalid
|
36
|
+
module AttributeGuard
|
37
|
+
extend ActiveSupport::Concern
|
38
|
+
|
39
|
+
included do
|
40
|
+
class_attribute :locked_attributes, default: {}, instance_accessor: false
|
41
|
+
private_class_method :locked_attributes=
|
42
|
+
private_class_method :locked_attributes
|
43
|
+
|
44
|
+
validates_with LockedAttributesValidator
|
45
|
+
end
|
46
|
+
|
47
|
+
# Validator that checks for changes to locked attributes.
|
48
|
+
class LockedAttributesValidator < ActiveModel::Validator
|
49
|
+
def validate(record)
|
50
|
+
return if record.new_record?
|
51
|
+
|
52
|
+
record.class.send(:locked_attributes).each do |attribute, params|
|
53
|
+
if record.changes.include?(attribute) && record.attribute_locked?(attribute)
|
54
|
+
message, mode = params
|
55
|
+
if mode == :warn
|
56
|
+
record&.logger&.warn("Changed locked attribute #{attribute} on #{record.class.name} with id #{record.id}")
|
57
|
+
elsif mode.is_a?(Proc)
|
58
|
+
mode.call(record, attribute)
|
59
|
+
else
|
60
|
+
record.errors.add(attribute, message)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
module ClassMethods
|
68
|
+
# Locks the given attributes so that they cannot be changed directly. Subclasses inherit
|
69
|
+
# the locked attributes from their parent classes.
|
70
|
+
#
|
71
|
+
# You can optionally specify a mode of what to do when a locked attribute is changed. The
|
72
|
+
# default is to add an error to the model, but you can also specify :warn to log a warning
|
73
|
+
# or a Proc to call with the record and attribute name.
|
74
|
+
#
|
75
|
+
# @param attributes [Array<Symbol, String>] the attributes to lock
|
76
|
+
# @param error [String, Symbol, Boolean] the error message to use in validate errors
|
77
|
+
# @param mode [Symbol, Proc] mode to use when a locked attribute is changed
|
78
|
+
# @return [void]
|
79
|
+
def lock_attributes(*attributes, error: :locked, mode: :error)
|
80
|
+
locked = locked_attributes.dup
|
81
|
+
error = error.dup.freeze if error.is_a?(String)
|
82
|
+
|
83
|
+
attributes.flatten.each do |attribute|
|
84
|
+
locked[attribute.to_s] = [error, mode]
|
85
|
+
end
|
86
|
+
|
87
|
+
self.locked_attributes = locked
|
88
|
+
end
|
89
|
+
|
90
|
+
# Returns the names of the locked attributes.
|
91
|
+
#
|
92
|
+
# @return [Array<String>] the names of the locked attributes.
|
93
|
+
def locked_attribute_names
|
94
|
+
locked_attributes.keys
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Unlocks the given attributes so that they can be changed. If a block is given, the attributes
|
99
|
+
# are unlocked only for the duration of the block.
|
100
|
+
#
|
101
|
+
# This method returns the object itself so that it can be chained.
|
102
|
+
#
|
103
|
+
# @example
|
104
|
+
# user.unlock_attributes(:email).update!(email: "user@example.com")
|
105
|
+
#
|
106
|
+
# @param attributes [Array<Symbol, String>] the attributes to unlock
|
107
|
+
# @return [ActiveRecord::Base] the object itself
|
108
|
+
def unlock_attributes(*attributes)
|
109
|
+
attributes = attributes.flatten.map(&:to_s)
|
110
|
+
return if attributes.empty?
|
111
|
+
|
112
|
+
@unlocked_attributes ||= Set.new
|
113
|
+
if block_given?
|
114
|
+
save_val = @unlocked_attributes
|
115
|
+
begin
|
116
|
+
@unlocked_attributes = @unlocked_attributes.dup.merge(attributes)
|
117
|
+
yield
|
118
|
+
ensure
|
119
|
+
@unlocked_attributes = save_val
|
120
|
+
clear_unlocked_attributes if @unlocked_attributes.empty?
|
121
|
+
end
|
122
|
+
else
|
123
|
+
@unlocked_attributes.merge(attributes)
|
124
|
+
end
|
125
|
+
|
126
|
+
self
|
127
|
+
end
|
128
|
+
|
129
|
+
# Returns true if the given attribute is currently locked.
|
130
|
+
#
|
131
|
+
# @param attribute [Symbol, String] the attribute to check
|
132
|
+
# @return [Boolean] whether the attribute is locked
|
133
|
+
def attribute_locked?(attribute)
|
134
|
+
return false if new_record?
|
135
|
+
|
136
|
+
attribute = attribute.to_s
|
137
|
+
return false unless self.class.send(:locked_attributes).include?(attribute)
|
138
|
+
|
139
|
+
if defined?(@unlocked_attributes)
|
140
|
+
!@unlocked_attributes.include?(attribute.to_s)
|
141
|
+
else
|
142
|
+
true
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# Clears any unlocked attributes.
|
147
|
+
#
|
148
|
+
# @return [void]
|
149
|
+
def clear_unlocked_attributes
|
150
|
+
if defined?(@unlocked_attributes)
|
151
|
+
remove_instance_variable(:@unlocked_attributes)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
data/lib/locale/en.yml
ADDED
metadata
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: attribute_guard
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Brian Durand
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-07-28 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
description:
|
42
|
+
email:
|
43
|
+
- bbdurand@gmail.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- CHANGELOG.md
|
49
|
+
- MIT-LICENSE.txt
|
50
|
+
- README.md
|
51
|
+
- VERSION
|
52
|
+
- attribute_guard.gemspec
|
53
|
+
- lib/attribute_guard.rb
|
54
|
+
- lib/locale/en.yml
|
55
|
+
homepage: https://github.com/bdurand/attribute_guard
|
56
|
+
licenses:
|
57
|
+
- MIT
|
58
|
+
metadata: {}
|
59
|
+
post_install_message:
|
60
|
+
rdoc_options: []
|
61
|
+
require_paths:
|
62
|
+
- lib
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '2.5'
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
69
|
+
requirements:
|
70
|
+
- - ">="
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: '0'
|
73
|
+
requirements: []
|
74
|
+
rubygems_version: 3.4.12
|
75
|
+
signing_key:
|
76
|
+
specification_version: 4
|
77
|
+
summary: ActiveRecord extension that allows locking attributes to prevent unintended
|
78
|
+
updates.
|
79
|
+
test_files: []
|