attribute_guard 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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
+ [![Continuous Integration](https://github.com/bdurand/attribute_guard/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/attribute_guard/actions/workflows/continuous_integration.yml)
4
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](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
@@ -0,0 +1,4 @@
1
+ en:
2
+ errors:
3
+ messages:
4
+ locked: "is locked and cannot be changed"
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: []