phi_attrs 0.1.1 → 0.2.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 +5 -5
- data/.github/workflows/build.yml +25 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +91 -0
- data/Appraisals +15 -3
- data/CHANGELOG.md +17 -0
- data/DISCLAIMER.txt +15 -0
- data/Dockerfile +1 -1
- data/Gemfile +2 -0
- data/LICENSE.txt +1 -1
- data/README.md +409 -11
- data/Rakefile +13 -0
- data/bin/console +6 -0
- data/bin/helpers/docker +12 -0
- data/bin/rubo_fix +6 -0
- data/bin/run_tests +19 -0
- data/bin/setup +2 -0
- data/bin/ssh_to_container +0 -0
- data/config.ru +2 -2
- data/docker-compose.yml +2 -0
- data/docker/start.sh +0 -0
- data/gemfiles/rails_5.1.gemfile +8 -0
- data/gemfiles/{rails_5.0.gemfile → rails_5.2.gemfile} +2 -2
- data/gemfiles/rails_6.0.gemfile +8 -0
- data/lib/phi_attrs.rb +26 -15
- data/lib/phi_attrs/configure.rb +29 -11
- data/lib/phi_attrs/exceptions.rb +5 -1
- data/lib/phi_attrs/formatter.rb +5 -3
- data/lib/phi_attrs/logger.rb +13 -1
- data/lib/phi_attrs/phi_record.rb +582 -39
- data/lib/phi_attrs/railtie.rb +5 -2
- data/lib/phi_attrs/version.rb +3 -1
- data/phi_attrs.gemspec +17 -5
- metadata +92 -26
- data/.travis.yml +0 -5
data/Rakefile
CHANGED
@@ -1,6 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'bundler/gem_tasks'
|
2
4
|
require 'rspec/core/rake_task'
|
3
5
|
|
4
6
|
RSpec::Core::RakeTask.new(:spec)
|
5
7
|
|
8
|
+
# Set Chandler options
|
9
|
+
if defined? Chandler
|
10
|
+
Chandler::Tasks.configure do |config|
|
11
|
+
config.changelog_path = 'CHANGELOG.md'
|
12
|
+
config.github_repository = 'apsislabs/phi_attrs'
|
13
|
+
end
|
14
|
+
|
15
|
+
# Add chandler as a prerequisite for `rake release`
|
16
|
+
task 'release:rubygem_push' => 'chandler:push'
|
17
|
+
end
|
18
|
+
|
6
19
|
task default: :spec
|
data/bin/console
CHANGED
data/bin/helpers/docker
ADDED
data/bin/rubo_fix
ADDED
data/bin/run_tests
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
source bin/helpers/docker
|
3
|
+
runOnDocker rails
|
4
|
+
|
5
|
+
echo "== Starting unit tests =="
|
6
|
+
bundle exec appraisal rspec
|
7
|
+
if [ $? -ne 0 ]; then
|
8
|
+
echo -e "\n== RSpec failed; push aborted! ==\n"
|
9
|
+
exit 1
|
10
|
+
fi
|
11
|
+
|
12
|
+
echo "== Starting rubocop =="
|
13
|
+
bundle exec rubocop --format worst --format simple --format offenses
|
14
|
+
if [ $? -ne 0 ]; then
|
15
|
+
echo -e "\n== Rubocop failed; push aborted! ==\n"
|
16
|
+
echo -e "To auto-correct errors run:"
|
17
|
+
echo -e "\tbin/rubo_fix"
|
18
|
+
exit 1
|
19
|
+
fi
|
data/bin/setup
CHANGED
data/bin/ssh_to_container
CHANGED
File without changes
|
data/config.ru
CHANGED
data/docker-compose.yml
CHANGED
data/docker/start.sh
CHANGED
File without changes
|
data/lib/phi_attrs.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'rails'
|
2
4
|
require 'active_support'
|
3
5
|
require 'request_store'
|
@@ -11,26 +13,35 @@ require 'phi_attrs/exceptions'
|
|
11
13
|
require 'phi_attrs/phi_record'
|
12
14
|
|
13
15
|
module PhiAttrs
|
14
|
-
def
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
16
|
+
def self.log_phi_access(user, message)
|
17
|
+
PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, user) do
|
18
|
+
PhiAttrs::Logger.info(message)
|
19
|
+
end
|
20
|
+
end
|
19
21
|
|
20
|
-
|
22
|
+
module Model
|
23
|
+
def phi_model(with: nil, except: nil)
|
24
|
+
include PhiRecord
|
25
|
+
end
|
21
26
|
end
|
22
27
|
|
23
|
-
|
28
|
+
module Controller
|
29
|
+
extend ActiveSupport::Concern
|
24
30
|
|
25
|
-
|
26
|
-
|
27
|
-
|
31
|
+
included do
|
32
|
+
before_action :record_i18n_data
|
33
|
+
end
|
28
34
|
|
29
|
-
|
30
|
-
|
31
|
-
|
35
|
+
private
|
36
|
+
|
37
|
+
def record_i18n_data
|
38
|
+
RequestStore.store[:phi_attrs_controller] = self.class.name
|
39
|
+
RequestStore.store[:phi_attrs_action] = params[:action]
|
40
|
+
|
41
|
+
return if PhiAttrs.current_user_method.nil?
|
42
|
+
return unless respond_to?(PhiAttrs.current_user_method, true)
|
32
43
|
|
33
|
-
|
34
|
-
|
44
|
+
RequestStore.store[:phi_attrs_current_user] = send(PhiAttrs.current_user_method)
|
45
|
+
end
|
35
46
|
end
|
36
47
|
end
|
data/lib/phi_attrs/configure.rb
CHANGED
@@ -1,17 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module PhiAttrs
|
2
|
-
|
3
|
-
|
4
|
+
@@log_path = nil
|
5
|
+
@@current_user_method = nil
|
6
|
+
@@translation_prefix = 'phi'
|
7
|
+
|
8
|
+
def self.configure
|
9
|
+
yield self if block_given?
|
10
|
+
end
|
4
11
|
|
5
|
-
|
6
|
-
|
7
|
-
|
12
|
+
def self.log_path
|
13
|
+
@@log_path
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.log_path=(value)
|
17
|
+
@@log_path = value
|
18
|
+
end
|
8
19
|
|
9
|
-
|
10
|
-
|
11
|
-
|
20
|
+
def self.translation_prefix
|
21
|
+
@@translation_prefix
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.translation_prefix=(value)
|
25
|
+
@@translation_prefix = value
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.current_user_method
|
29
|
+
@@current_user_method
|
30
|
+
end
|
12
31
|
|
13
|
-
|
14
|
-
|
15
|
-
end
|
32
|
+
def self.current_user_method=(value)
|
33
|
+
@@current_user_method = value
|
16
34
|
end
|
17
35
|
end
|
data/lib/phi_attrs/exceptions.rb
CHANGED
@@ -1,8 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module PhiAttrs
|
2
4
|
module Exceptions
|
3
5
|
class PhiAccessException < StandardError
|
6
|
+
TAG = 'UNAUTHORIZED ACCESS'
|
7
|
+
|
4
8
|
def initialize(msg)
|
5
|
-
PhiAttrs::Logger.tagged(
|
9
|
+
PhiAttrs::Logger.tagged(TAG) { PhiAttrs::Logger.error(msg) }
|
6
10
|
super(msg)
|
7
11
|
end
|
8
12
|
end
|
data/lib/phi_attrs/formatter.rb
CHANGED
@@ -1,10 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module PhiAttrs
|
2
|
-
|
4
|
+
FORMAT = "%s %5s: %s\n"
|
3
5
|
|
4
6
|
# https://github.com/ruby/ruby/blob/trunk/lib/logger.rb#L587
|
5
7
|
class Formatter < ::Logger::Formatter
|
6
|
-
def call(severity, timestamp,
|
7
|
-
|
8
|
+
def call(severity, timestamp, _progname, msg)
|
9
|
+
format(FORMAT, format_datetime(timestamp), severity, msg2str(msg))
|
8
10
|
end
|
9
11
|
end
|
10
12
|
end
|
data/lib/phi_attrs/logger.rb
CHANGED
@@ -1,7 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module PhiAttrs
|
4
|
+
PHI_ACCESS_LOG_TAG = 'PHI Access Log'
|
5
|
+
|
2
6
|
class Logger
|
3
7
|
class << self
|
4
|
-
|
8
|
+
def logger
|
9
|
+
unless @logger
|
10
|
+
logger = ActiveSupport::Logger.new(PhiAttrs.log_path)
|
11
|
+
logger.formatter = Formatter.new
|
12
|
+
@logger = ActiveSupport::TaggedLogging.new(logger)
|
13
|
+
end
|
14
|
+
@logger
|
15
|
+
end
|
16
|
+
|
5
17
|
delegate :debug, :info, :warn, :error, :fatal, :tagged, to: :logger
|
6
18
|
end
|
7
19
|
end
|
data/lib/phi_attrs/phi_record.rb
CHANGED
@@ -1,106 +1,572 @@
|
|
1
|
-
|
2
|
-
PHI_ACCESS_LOG_TAG = 'PHI Access Log'.freeze
|
1
|
+
# frozen_string_literal: true
|
3
2
|
|
3
|
+
# Namespace for classes and modules that handle PHI Attribute Access Logging
|
4
|
+
module PhiAttrs
|
5
|
+
# Module for extending ActiveRecord models to handle PHI access logging
|
6
|
+
# and restrict access to attributes.
|
7
|
+
#
|
8
|
+
# @author Apsis Labs
|
9
|
+
# @since 0.1.0
|
4
10
|
module PhiRecord
|
5
11
|
extend ActiveSupport::Concern
|
6
12
|
|
7
13
|
included do
|
8
14
|
class_attribute :__phi_exclude_methods
|
9
15
|
class_attribute :__phi_include_methods
|
10
|
-
class_attribute :
|
16
|
+
class_attribute :__phi_extend_methods
|
11
17
|
class_attribute :__phi_methods_wrapped
|
18
|
+
class_attribute :__phi_methods_to_extend
|
12
19
|
|
13
20
|
after_initialize :wrap_phi
|
14
21
|
|
22
|
+
# These have to default to an empty array
|
15
23
|
self.__phi_methods_wrapped = []
|
24
|
+
self.__phi_methods_to_extend = []
|
16
25
|
end
|
17
26
|
|
18
27
|
class_methods do
|
28
|
+
# Set methods to be excluded from PHI access logging.
|
29
|
+
#
|
30
|
+
# @param [Array<Symbol>] *methods Any number of methods to exclude
|
31
|
+
#
|
32
|
+
# @example
|
33
|
+
# exclude_from_phi :foo, :bar
|
34
|
+
#
|
19
35
|
def exclude_from_phi(*methods)
|
20
36
|
self.__phi_exclude_methods = methods.map(&:to_s)
|
21
37
|
end
|
22
38
|
|
39
|
+
# Set methods to be explicitly included in PHI access logging.
|
40
|
+
#
|
41
|
+
# @param [Array<Symbol>] *methods Any number of methods to include
|
42
|
+
#
|
43
|
+
# @example
|
44
|
+
# include_in_phi :foo, :bar
|
45
|
+
#
|
23
46
|
def include_in_phi(*methods)
|
24
47
|
self.__phi_include_methods = methods.map(&:to_s)
|
25
48
|
end
|
26
49
|
|
50
|
+
# Set of methods which should be implicitly allowed if this object
|
51
|
+
# is allowed. The methods that are extended should return ActiveRecord
|
52
|
+
# models that also extend PhiAttrs.
|
53
|
+
#
|
54
|
+
# @param [Array<Symbol>] *methods Any number of methods to extend access to
|
55
|
+
#
|
56
|
+
# @example
|
57
|
+
# has_one :foo
|
58
|
+
# has_one :bar
|
59
|
+
# extend_phi_access :foo, :bar
|
60
|
+
#
|
27
61
|
def extend_phi_access(*methods)
|
28
|
-
self.
|
62
|
+
self.__phi_extend_methods = methods.map(&:to_s)
|
29
63
|
end
|
30
64
|
|
31
|
-
|
32
|
-
|
65
|
+
# Enable PHI access for any instance of this class.
|
66
|
+
#
|
67
|
+
# @param [String] user_id A unique identifier for the person accessing the PHI
|
68
|
+
# @param [String] reason The reason for accessing PHI
|
69
|
+
#
|
70
|
+
# @example
|
71
|
+
# Foo.allow_phi!('user@example.com', 'viewing patient record')
|
72
|
+
#
|
73
|
+
def allow_phi!(user_id = nil, reason = nil)
|
74
|
+
raise ArgumentError, 'block not allowed. use allow_phi with block' if block_given?
|
75
|
+
|
76
|
+
user_id ||= current_user
|
77
|
+
reason ||= i18n_reason
|
78
|
+
raise ArgumentError, 'user_id and reason cannot be blank' if user_id.blank? || reason.blank?
|
79
|
+
|
80
|
+
__phi_stack.push({
|
81
|
+
phi_access_allowed: true,
|
82
|
+
user_id: user_id,
|
83
|
+
reason: reason
|
84
|
+
})
|
33
85
|
|
34
|
-
RequestStore.store[:phi_access][name] ||= {
|
35
|
-
phi_access_allowed: true,
|
36
|
-
user_id: user_id,
|
37
|
-
reason: reason
|
38
|
-
}
|
39
86
|
PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do
|
40
|
-
PhiAttrs::Logger.info("PHI Access Enabled for #{user_id}: #{reason}")
|
87
|
+
PhiAttrs::Logger.info("PHI Access Enabled for '#{user_id}': #{reason}")
|
41
88
|
end
|
42
89
|
end
|
43
90
|
|
91
|
+
# Enable PHI access for any instance of this class in the block given only.
|
92
|
+
#
|
93
|
+
# @param [String] user_id A unique identifier for the person accessing the PHI
|
94
|
+
# @param [String] reason The reason for accessing PHI
|
95
|
+
# @param [collection of PhiRecord] allow_only Specific PhiRecords to allow access to
|
96
|
+
# &block [block] The block in which PHI access is allowed for the class
|
97
|
+
#
|
98
|
+
# @example
|
99
|
+
# Foo.allow_phi('user@example.com', 'viewing patient record') do
|
100
|
+
# # PHI Access Allowed
|
101
|
+
# end
|
102
|
+
# # PHI Access Disallowed
|
103
|
+
#
|
104
|
+
# @example
|
105
|
+
# Foo.allow_phi('user@example.com', 'exporting patient list', allow_only: list_of_foos) do
|
106
|
+
# # PHI Access Allowed for `list_of_foo` only
|
107
|
+
# end
|
108
|
+
# # PHI Access Disallowed
|
109
|
+
#
|
110
|
+
def allow_phi(user_id = nil, reason = nil, allow_only: nil)
|
111
|
+
raise ArgumentError, 'block required. use allow_phi! without block' unless block_given?
|
112
|
+
|
113
|
+
if allow_only.present?
|
114
|
+
raise ArgumentError, 'allow_only must be iterable with each' unless allow_only.respond_to?(:each)
|
115
|
+
raise ArgumentError, "allow_only must all be `#{name}` objects" unless allow_only.all? { |t| t.is_a?(self) }
|
116
|
+
raise ArgumentError, 'allow_only must all have `allow_phi!` methods' unless allow_only.all? { |t| t.respond_to?(:allow_phi!) }
|
117
|
+
end
|
118
|
+
|
119
|
+
# Save this so we don't revoke access previously extended outside the block
|
120
|
+
frozen_instances = Hash[__instances_with_extended_phi.map { |obj| [obj, obj.instance_variable_get(:@__phi_relations_extended).clone] }]
|
121
|
+
|
122
|
+
if allow_only.nil?
|
123
|
+
allow_phi!(user_id, reason)
|
124
|
+
else
|
125
|
+
allow_only.each { |t| t.allow_phi!(user_id, reason) }
|
126
|
+
end
|
127
|
+
|
128
|
+
yield if block_given?
|
129
|
+
|
130
|
+
__instances_with_extended_phi.each do |obj|
|
131
|
+
if frozen_instances.include?(obj)
|
132
|
+
old_extensions = frozen_instances[obj]
|
133
|
+
new_extensions = obj.instance_variable_get(:@__phi_relations_extended) - old_extensions
|
134
|
+
obj.send(:revoke_extended_phi!, new_extensions) if new_extensions.any?
|
135
|
+
else
|
136
|
+
obj.send(:revoke_extended_phi!) # Instance is new to the set, so revoke everything
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
if allow_only.nil?
|
141
|
+
disallow_last_phi!
|
142
|
+
else
|
143
|
+
allow_only.each { |t| t.disallow_last_phi!(preserve_extensions: true) }
|
144
|
+
# We've handled any newly extended allowances ourselves above
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# Explicitly disallow phi access in a specific area of code. This does not
|
149
|
+
# play nicely with the mutating versions of `allow_phi!` and `disallow_phi!`
|
150
|
+
#
|
151
|
+
# At the moment, this doesn't work at all, as the instance won't
|
152
|
+
# necessarily look at the class-level stack when determining if PHI is allowed.
|
153
|
+
#
|
154
|
+
# &block [block] The block in which PHI access is explicitly disallowed.
|
155
|
+
#
|
156
|
+
# @example
|
157
|
+
# # PHI Access Disallowed
|
158
|
+
# Foo.disallow_phi
|
159
|
+
# # PHI Access *Still* Disallowed
|
160
|
+
# end
|
161
|
+
# # PHI Access *Still, still* Disallowed
|
162
|
+
# Foo.allow_phi!('user@example.com', 'viewing patient record')
|
163
|
+
# # PHI Access Allowed
|
164
|
+
# Foo.disallow_phi do
|
165
|
+
# # PHI Access Disallowed
|
166
|
+
# end
|
167
|
+
# # PHI Access Allowed Again
|
168
|
+
def disallow_phi
|
169
|
+
raise ArgumentError, 'block required. use disallow_phi! without block' unless block_given?
|
170
|
+
|
171
|
+
__phi_stack.push({
|
172
|
+
phi_access_allowed: false
|
173
|
+
})
|
174
|
+
|
175
|
+
yield if block_given?
|
176
|
+
|
177
|
+
__phi_stack.pop
|
178
|
+
end
|
179
|
+
|
180
|
+
# Revoke all PHI access for this class, if enabled by PhiRecord#allow_phi!
|
181
|
+
#
|
182
|
+
# @example
|
183
|
+
# Foo.disallow_phi!
|
184
|
+
#
|
44
185
|
def disallow_phi!
|
45
|
-
|
186
|
+
raise ArgumentError, 'block not allowed. use disallow_phi with block' if block_given?
|
187
|
+
|
188
|
+
message = __phi_stack.present? ? "PHI access disabled for #{__user_id_string(__phi_stack)}" : 'PHI access disabled. No class level access was granted.'
|
189
|
+
|
190
|
+
__reset_phi_stack
|
191
|
+
|
46
192
|
PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do
|
47
|
-
PhiAttrs::Logger.info(
|
193
|
+
PhiAttrs::Logger.info(message)
|
48
194
|
end
|
49
195
|
end
|
50
|
-
end
|
51
196
|
|
52
|
-
|
53
|
-
#
|
54
|
-
@
|
55
|
-
|
197
|
+
# Revoke last PHI access for this class, if enabled by PhiRecord#allow_phi!
|
198
|
+
#
|
199
|
+
# @example
|
200
|
+
# Foo.disallow_last_phi!
|
201
|
+
#
|
202
|
+
def disallow_last_phi!
|
203
|
+
raise ArgumentError, 'block not allowed' if block_given?
|
56
204
|
|
57
|
-
|
58
|
-
|
205
|
+
removed_access = __phi_stack.pop
|
206
|
+
message = removed_access.present? ? "PHI access disabled for #{removed_access[:user_id]}" : 'PHI access disabled. No class level access was granted.'
|
207
|
+
|
208
|
+
PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do
|
209
|
+
PhiAttrs::Logger.info(message)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Whether PHI access is allowed for this class
|
214
|
+
#
|
215
|
+
# @example
|
216
|
+
# Foo.phi_allowed?
|
217
|
+
#
|
218
|
+
# @return [Boolean] whether PHI access is allowed for this instance
|
219
|
+
#
|
220
|
+
def phi_allowed?
|
221
|
+
__phi_stack.present? && __phi_stack[-1][:phi_access_allowed]
|
222
|
+
end
|
223
|
+
|
224
|
+
def __instances_with_extended_phi
|
225
|
+
RequestStore.store[:phi_instances_with_extended_phi] ||= Set.new
|
226
|
+
end
|
227
|
+
|
228
|
+
def __phi_stack
|
229
|
+
RequestStore.store[:phi_access] ||= {}
|
230
|
+
RequestStore.store[:phi_access][name] ||= []
|
231
|
+
end
|
232
|
+
|
233
|
+
def __reset_phi_stack
|
234
|
+
RequestStore.store[:phi_access] ||= {}
|
235
|
+
RequestStore.store[:phi_access][name] = []
|
236
|
+
end
|
237
|
+
|
238
|
+
def __user_id_string(access_list)
|
239
|
+
access_list ||= []
|
240
|
+
access_list.map { |c| "'#{c[:user_id]}'" }.join(',')
|
241
|
+
end
|
242
|
+
|
243
|
+
def current_user
|
244
|
+
RequestStore.store[:phi_attrs_current_user]
|
245
|
+
end
|
246
|
+
|
247
|
+
def i18n_reason
|
248
|
+
controller = RequestStore.store[:phi_attrs_controller]
|
249
|
+
action = RequestStore.store[:phi_attrs_action]
|
250
|
+
|
251
|
+
return nil if controller.blank? || action.blank?
|
252
|
+
|
253
|
+
i18n_path = [PhiAttrs.translation_prefix] + __path_to_controller_and_action(controller, action)
|
254
|
+
i18n_path.push(*__path_to_class)
|
255
|
+
i18n_key = i18n_path.join('.')
|
256
|
+
|
257
|
+
return I18n.t(i18n_key) if I18n.exists?(i18n_key)
|
258
|
+
|
259
|
+
locale = I18n.locale || I18n.default_locale
|
260
|
+
|
261
|
+
PhiAttrs::Logger.warn "No #{locale} PHI Reason found for #{i18n_key}"
|
262
|
+
end
|
263
|
+
|
264
|
+
def __path_to_controller_and_action(controller, action)
|
265
|
+
module_paths = controller.underscore.split('/')
|
266
|
+
class_name_parts = module_paths.pop.split('_')
|
267
|
+
class_name_parts.pop if class_name_parts[-1] == 'controller'
|
268
|
+
module_paths.push(class_name_parts.join('_'), action)
|
269
|
+
end
|
270
|
+
|
271
|
+
def __path_to_class
|
272
|
+
module_paths = name.underscore.split('/')
|
273
|
+
class_name_parts = module_paths.pop.split('_')
|
274
|
+
module_paths.push(class_name_parts.join('_'))
|
275
|
+
end
|
59
276
|
end
|
60
277
|
|
278
|
+
# Get all method names to be wrapped with PHI access logging
|
279
|
+
#
|
280
|
+
# @return [Array<String>] the method names to be wrapped with PHI access logging
|
281
|
+
#
|
61
282
|
def __phi_wrapped_methods
|
62
|
-
|
283
|
+
excluded_methods = self.class.__phi_exclude_methods.to_a
|
284
|
+
included_methods = self.class.__phi_include_methods.to_a
|
285
|
+
|
286
|
+
attribute_names - excluded_methods + included_methods - [self.class.primary_key]
|
63
287
|
end
|
64
288
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
289
|
+
# Get all method names to be wrapped with PHI access extension
|
290
|
+
#
|
291
|
+
# @return [Array<String>] the method names to be wrapped with PHI access extension
|
292
|
+
#
|
293
|
+
def __phi_extended_methods
|
294
|
+
self.class.__phi_extend_methods.to_a
|
295
|
+
end
|
296
|
+
|
297
|
+
# Enable PHI access for a single instance of this class.
|
298
|
+
#
|
299
|
+
# @param [String] user_id A unique identifier for the person accessing the PHI
|
300
|
+
# @param [String] reason The reason for accessing PHI
|
301
|
+
#
|
302
|
+
# @example
|
303
|
+
# foo = Foo.find(1)
|
304
|
+
# foo.allow_phi!('user@example.com', 'viewing patient record')
|
305
|
+
#
|
306
|
+
def allow_phi!(user_id = nil, reason = nil)
|
307
|
+
raise ArgumentError, 'block not allowed. use allow_phi with block' if block_given?
|
308
|
+
|
309
|
+
user_id ||= self.class.current_user
|
310
|
+
reason ||= self.class.i18n_reason
|
311
|
+
raise ArgumentError, 'user_id and reason cannot be blank' if user_id.blank? || reason.blank?
|
312
|
+
|
313
|
+
PhiAttrs::Logger.tagged(*phi_log_keys) do
|
314
|
+
@__phi_access_stack.push({
|
315
|
+
phi_access_allowed: true,
|
316
|
+
user_id: user_id,
|
317
|
+
reason: reason
|
318
|
+
})
|
70
319
|
|
71
320
|
PhiAttrs::Logger.info("PHI Access Enabled for '#{user_id}': #{reason}")
|
72
321
|
end
|
73
322
|
end
|
74
323
|
|
324
|
+
# Enable PHI access for a single instance of this class inside the block.
|
325
|
+
# Nested calls to allow_phi will log once per nested call
|
326
|
+
#
|
327
|
+
# @param [String] user_id A unique identifier for the person accessing the PHI
|
328
|
+
# @param [String] reason The reason for accessing PHI
|
329
|
+
# @yield The block in which phi access is allowed
|
330
|
+
#
|
331
|
+
# @example
|
332
|
+
# foo = Foo.find(1)
|
333
|
+
# foo.allow_phi('user@example.com', 'viewing patient record') do
|
334
|
+
# # PHI Access Allowed Here
|
335
|
+
# end
|
336
|
+
# # PHI Access Disallowed Here
|
337
|
+
#
|
338
|
+
def allow_phi(user_id = nil, reason = nil)
|
339
|
+
raise ArgumentError, 'block required. use allow_phi! without block' unless block_given?
|
340
|
+
|
341
|
+
extended_instances = @__phi_relations_extended.clone
|
342
|
+
allow_phi!(user_id, reason)
|
343
|
+
|
344
|
+
yield if block_given?
|
345
|
+
|
346
|
+
new_extensions = @__phi_relations_extended - extended_instances
|
347
|
+
disallow_last_phi!(preserve_extensions: true)
|
348
|
+
revoke_extended_phi!(new_extensions) if new_extensions.any?
|
349
|
+
end
|
350
|
+
|
351
|
+
# Revoke all PHI access for a single instance of this class.
|
352
|
+
#
|
353
|
+
# @example
|
354
|
+
# foo = Foo.find(1)
|
355
|
+
# foo.disallow_phi!
|
356
|
+
#
|
75
357
|
def disallow_phi!
|
358
|
+
raise ArgumentError, 'block not allowed. use disallow_phi with block' if block_given?
|
359
|
+
|
76
360
|
PhiAttrs::Logger.tagged(*phi_log_keys) do
|
77
|
-
|
78
|
-
@__phi_user_id = nil
|
79
|
-
@__phi_access_reason = nil
|
361
|
+
removed_access_for = self.class.__user_id_string(@__phi_access_stack)
|
80
362
|
|
81
|
-
|
363
|
+
revoke_extended_phi!
|
364
|
+
@__phi_access_stack = []
|
365
|
+
|
366
|
+
message = removed_access_for.present? ? "PHI access disabled for #{removed_access_for}" : 'PHI access disabled. No instance level access was granted.'
|
367
|
+
PhiAttrs::Logger.info(message)
|
82
368
|
end
|
83
369
|
end
|
84
370
|
|
371
|
+
# Dissables PHI access for a single instance of this class inside the block.
|
372
|
+
# Nested calls to allow_phi will log once per nested call
|
373
|
+
#
|
374
|
+
# @param [String] user_id A unique identifier for the person accessing the PHI
|
375
|
+
# @param [String] reason The reason for accessing PHI
|
376
|
+
# @yield The block in which phi access is allowed
|
377
|
+
#
|
378
|
+
# @example
|
379
|
+
# foo = Foo.find(1)
|
380
|
+
# foo.allow_phi('user@example.com', 'viewing patient record') do
|
381
|
+
# # PHI Access Allowed Here
|
382
|
+
# end
|
383
|
+
# # PHI Access Disallowed Here
|
384
|
+
#
|
385
|
+
def disallow_phi
|
386
|
+
raise ArgumentError, 'block required. use disallow_phi! without block' unless block_given?
|
387
|
+
|
388
|
+
add_disallow_flag!
|
389
|
+
add_disallow_flag_to_extended_phi!
|
390
|
+
|
391
|
+
yield if block_given?
|
392
|
+
|
393
|
+
remove_disallow_flag_from_extended_phi!
|
394
|
+
remove_disallow_flag!
|
395
|
+
end
|
396
|
+
|
397
|
+
# Revoke last PHI access for a single instance of this class.
|
398
|
+
#
|
399
|
+
# @example
|
400
|
+
# foo = Foo.find(1)
|
401
|
+
# foo.disallow_last_phi!
|
402
|
+
#
|
403
|
+
def disallow_last_phi!(preserve_extensions: false)
|
404
|
+
raise ArgumentError, 'block not allowed' if block_given?
|
405
|
+
|
406
|
+
PhiAttrs::Logger.tagged(*phi_log_keys) do
|
407
|
+
removed_access = @__phi_access_stack.pop
|
408
|
+
|
409
|
+
revoke_extended_phi! unless preserve_extensions
|
410
|
+
message = removed_access.present? ? "PHI access disabled for #{removed_access[:user_id]}" : 'PHI access disabled. No instance level access was granted.'
|
411
|
+
PhiAttrs::Logger.info(message)
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
# Whether PHI access is allowed for a single instance of this class
|
416
|
+
#
|
417
|
+
# @example
|
418
|
+
# foo = Foo.find(1)
|
419
|
+
# foo.phi_allowed?
|
420
|
+
#
|
421
|
+
# @return [Boolean] whether PHI access is allowed for this instance
|
422
|
+
#
|
85
423
|
def phi_allowed?
|
86
|
-
|
424
|
+
!phi_context.nil? && phi_context[:phi_access_allowed]
|
87
425
|
end
|
88
426
|
|
89
|
-
def
|
90
|
-
@
|
427
|
+
def reload
|
428
|
+
@__phi_relations_extended.clear
|
429
|
+
super
|
91
430
|
end
|
92
431
|
|
93
|
-
|
94
|
-
|
432
|
+
protected
|
433
|
+
|
434
|
+
# Adds a disallow phi flag to instance internal stack.
|
435
|
+
# @private since subject to change
|
436
|
+
def add_disallow_flag!
|
437
|
+
@__phi_access_stack.push({
|
438
|
+
phi_access_allowed: false
|
439
|
+
})
|
440
|
+
end
|
441
|
+
|
442
|
+
# removes the last item in instance internal stack.
|
443
|
+
# @private since subject to change
|
444
|
+
def remove_disallow_flag!
|
445
|
+
@__phi_access_stack.pop
|
95
446
|
end
|
96
447
|
|
97
448
|
private
|
98
449
|
|
450
|
+
# Entry point for wrapping methods with PHI access logging. This is called
|
451
|
+
# by an `after_initialize` hook from ActiveRecord.
|
452
|
+
#
|
453
|
+
# @private
|
454
|
+
#
|
455
|
+
def wrap_phi
|
456
|
+
# Disable PHI access by default
|
457
|
+
@__phi_access_stack = []
|
458
|
+
@__phi_methods_extended = Set.new
|
459
|
+
@__phi_relations_extended = Set.new
|
460
|
+
|
461
|
+
# Wrap attributes with PHI Logger and Access Control
|
462
|
+
__phi_wrapped_methods.each { |m| phi_wrap_method(m) }
|
463
|
+
__phi_extended_methods.each { |m| phi_wrap_extension(m) }
|
464
|
+
end
|
465
|
+
|
466
|
+
# Log Key for an instance of this class. If the instance is persisted in the
|
467
|
+
# database, then it is the primary key; otherwise it is the Ruby object_id
|
468
|
+
# in memory.
|
469
|
+
#
|
470
|
+
# This is used by the tagged logger for tagging all log entries to find
|
471
|
+
# the underlying model.
|
472
|
+
#
|
473
|
+
# @private
|
474
|
+
#
|
475
|
+
# @return [Array<String>] log key for an instance of this class
|
476
|
+
#
|
99
477
|
def phi_log_keys
|
100
478
|
@__phi_log_id = persisted? ? "Key: #{attributes[self.class.primary_key]}" : "Object: #{object_id}"
|
101
479
|
@__phi_log_keys = [PHI_ACCESS_LOG_TAG, self.class.name, @__phi_log_id]
|
102
480
|
end
|
103
481
|
|
482
|
+
# The unique identifier for whom access has been allowed on this instance.
|
483
|
+
# This is what was passed in when PhiRecord#allow_phi! was called.
|
484
|
+
#
|
485
|
+
# @private
|
486
|
+
#
|
487
|
+
# @return [String] the user_id passed in to allow_phi!
|
488
|
+
#
|
489
|
+
def phi_allowed_by
|
490
|
+
phi_context[:user_id]
|
491
|
+
end
|
492
|
+
|
493
|
+
# The access reason for allowing access to this instance.
|
494
|
+
# This is what was passed in when PhiRecord#allow_phi! was called.
|
495
|
+
#
|
496
|
+
# @private
|
497
|
+
#
|
498
|
+
# @return [String] the reason passed in to allow_phi!
|
499
|
+
#
|
500
|
+
def phi_access_reason
|
501
|
+
phi_context[:reason]
|
502
|
+
end
|
503
|
+
|
504
|
+
def phi_context
|
505
|
+
instance_phi_context || class_phi_context
|
506
|
+
end
|
507
|
+
|
508
|
+
def instance_phi_context
|
509
|
+
@__phi_access_stack && @__phi_access_stack[-1]
|
510
|
+
end
|
511
|
+
|
512
|
+
def class_phi_context
|
513
|
+
self.class.__phi_stack[-1]
|
514
|
+
end
|
515
|
+
|
516
|
+
# The unique identifiers for everything with access allowed on this instance.
|
517
|
+
#
|
518
|
+
# @private
|
519
|
+
#
|
520
|
+
# @return String of all the user_id's passed in to allow_phi!
|
521
|
+
#
|
522
|
+
def all_phi_allowed_by
|
523
|
+
self.class.__user_id_string(all_phi_context)
|
524
|
+
end
|
525
|
+
|
526
|
+
def all_phi_context
|
527
|
+
(@__phi_access_stack || []) + (self.class.__phi_stack || [])
|
528
|
+
end
|
529
|
+
|
530
|
+
def all_phi_context_logged?
|
531
|
+
all_phi_context.all? { |v| v[:logged] }
|
532
|
+
end
|
533
|
+
|
534
|
+
def set_all_phi_context_logged
|
535
|
+
all_phi_context.each { |c| c[:logged] = true }
|
536
|
+
end
|
537
|
+
|
538
|
+
# Core logic for wrapping methods in PHI access logging and access restriction.
|
539
|
+
#
|
540
|
+
# This method takes a single method name, and creates a new method using
|
541
|
+
# define_method; once this method is defined, the original method name
|
542
|
+
# is aliased to the new method, and the original method is renamed to a
|
543
|
+
# known key.
|
544
|
+
#
|
545
|
+
# @private
|
546
|
+
#
|
547
|
+
# @example
|
548
|
+
# Foo::phi_wrap_method(:bar)
|
549
|
+
#
|
550
|
+
# foo = Foo.find(1)
|
551
|
+
# foo.bar # => raises PHI Access Exception
|
552
|
+
#
|
553
|
+
# foo.allow_phi!('user@example.com', 'testing')
|
554
|
+
#
|
555
|
+
# foo.bar # => returns original value of Foo#bar
|
556
|
+
#
|
557
|
+
# # defines two new methods:
|
558
|
+
# # __bar_phi_wrapped
|
559
|
+
# # __bar_phi_unwrapped
|
560
|
+
# #
|
561
|
+
# # After these methods are defined
|
562
|
+
# # an alias chain is created that
|
563
|
+
# # roughly maps:
|
564
|
+
# #
|
565
|
+
# # bar => __bar_phi_wrapped => __bar_phi_unwrapped
|
566
|
+
# #
|
567
|
+
# # This ensures that all calls to Foo#bar pass
|
568
|
+
# # through access logging.
|
569
|
+
#
|
104
570
|
def phi_wrap_method(method_name)
|
105
571
|
return if self.class.__phi_methods_wrapped.include? method_name
|
106
572
|
|
@@ -111,9 +577,9 @@ module PhiAttrs
|
|
111
577
|
PhiAttrs::Logger.tagged(*phi_log_keys) do
|
112
578
|
raise PhiAttrs::Exceptions::PhiAccessException, "Attempted PHI access for #{self.class.name} #{@__phi_user_id}" unless phi_allowed?
|
113
579
|
|
114
|
-
unless
|
115
|
-
PhiAttrs::Logger.info("
|
116
|
-
|
580
|
+
unless all_phi_context_logged?
|
581
|
+
PhiAttrs::Logger.info("#{self.class.name} access by [#{all_phi_allowed_by}]. Triggered by method: #{method_name}")
|
582
|
+
set_all_phi_context_logged
|
117
583
|
end
|
118
584
|
|
119
585
|
send(unwrapped_method, *args, &block)
|
@@ -126,5 +592,82 @@ module PhiAttrs
|
|
126
592
|
|
127
593
|
self.class.__phi_methods_wrapped << method_name
|
128
594
|
end
|
595
|
+
|
596
|
+
# Core logic for wrapping methods in PHI access extensions. Almost
|
597
|
+
# functionally equivalent to the phi_wrap_method call above,
|
598
|
+
# this method doesn't add any logging or access restriction, but
|
599
|
+
# simply proxies the PhiRecord#allow_phi! call.
|
600
|
+
#
|
601
|
+
# @private
|
602
|
+
#
|
603
|
+
def phi_wrap_extension(method_name)
|
604
|
+
return if self.class.__phi_methods_to_extend.include? method_name
|
605
|
+
|
606
|
+
wrapped_method = wrapped_extended_name(method_name)
|
607
|
+
unwrapped_method = unwrapped_extended_name(method_name)
|
608
|
+
|
609
|
+
self.class.send(:define_method, wrapped_method) do |*args, &block|
|
610
|
+
relation = send(unwrapped_method, *args, &block)
|
611
|
+
|
612
|
+
if phi_allowed?
|
613
|
+
if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
|
614
|
+
relations = relation.is_a?(Enumerable) ? relation : [relation]
|
615
|
+
relations.each do |r|
|
616
|
+
r.allow_phi!(phi_allowed_by, phi_access_reason) unless @__phi_relations_extended.include?(r)
|
617
|
+
end
|
618
|
+
@__phi_relations_extended.merge(relations)
|
619
|
+
self.class.__instances_with_extended_phi.add(self)
|
620
|
+
end
|
621
|
+
end
|
622
|
+
|
623
|
+
relation
|
624
|
+
end
|
625
|
+
|
626
|
+
# method_name => wrapped_method => unwrapped_method
|
627
|
+
self.class.send(:alias_method, unwrapped_method, method_name)
|
628
|
+
self.class.send(:alias_method, method_name, wrapped_method)
|
629
|
+
|
630
|
+
self.class.__phi_methods_to_extend << method_name
|
631
|
+
end
|
632
|
+
|
633
|
+
# Revoke PHI access for all `extend`ed relations (or only those given)
|
634
|
+
def revoke_extended_phi!(relations = nil)
|
635
|
+
relations ||= @__phi_relations_extended
|
636
|
+
relations.each do |relation|
|
637
|
+
relation.disallow_last_phi! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
|
638
|
+
end
|
639
|
+
@__phi_relations_extended.subtract(relations)
|
640
|
+
end
|
641
|
+
|
642
|
+
# Adds a disallow PHI access to the stack for block syntax for all `extend`ed relations (or only those given)
|
643
|
+
def add_disallow_flag_to_extended_phi!(relations = nil)
|
644
|
+
relations ||= @__phi_relations_extended
|
645
|
+
relations.each do |relation|
|
646
|
+
relation.add_disallow_flag! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
|
647
|
+
end
|
648
|
+
end
|
649
|
+
|
650
|
+
# Adds a disallow PHI access to the stack for all for all `extend`ed relations (or only those given)
|
651
|
+
def remove_disallow_flag_from_extended_phi!(relations = nil)
|
652
|
+
relations ||= @__phi_relations_extended
|
653
|
+
relations.each do |relation|
|
654
|
+
relation.remove_disallow_flag! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
|
655
|
+
end
|
656
|
+
end
|
657
|
+
|
658
|
+
def relation_klass(rel)
|
659
|
+
return rel.klass if rel.is_a?(ActiveRecord::Relation)
|
660
|
+
return rel.first.class if rel.is_a?(Enumerable)
|
661
|
+
|
662
|
+
return rel.class
|
663
|
+
end
|
664
|
+
|
665
|
+
def wrapped_extended_name(method_name)
|
666
|
+
:"__#{method_name}_phi_access_extended"
|
667
|
+
end
|
668
|
+
|
669
|
+
def unwrapped_extended_name(method_name)
|
670
|
+
:"__#{method_name}_phi_access_original"
|
671
|
+
end
|
129
672
|
end
|
130
673
|
end
|