phi_attrs 0.1.4 → 0.2.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 +4 -4
- data/.gitignore +3 -0
- data/.rubocop.yml +90 -0
- data/.travis.yml +4 -1
- data/Appraisals +4 -2
- 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 +377 -13
- 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/docker-compose.yml +2 -0
- data/docker/start.sh +0 -0
- data/gemfiles/rails_5.1.gemfile +10 -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 +397 -41
- data/lib/phi_attrs/railtie.rb +5 -2
- data/lib/phi_attrs/version.rb +3 -1
- data/phi_attrs.gemspec +13 -1
- metadata +71 -6
- data/gemfiles/rails_5.0.gemfile +0 -8
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/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,7 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# Namespace for classes and modules that handle PHI Attribute Access Logging
|
2
4
|
module PhiAttrs
|
3
|
-
PHI_ACCESS_LOG_TAG = 'PHI Access Log'.freeze
|
4
|
-
|
5
5
|
# Module for extending ActiveRecord models to handle PHI access logging
|
6
6
|
# and restrict access to attributes.
|
7
7
|
#
|
@@ -15,13 +15,15 @@ module PhiAttrs
|
|
15
15
|
class_attribute :__phi_include_methods
|
16
16
|
class_attribute :__phi_extend_methods
|
17
17
|
class_attribute :__phi_methods_wrapped
|
18
|
-
class_attribute :
|
18
|
+
class_attribute :__phi_methods_to_extend
|
19
|
+
class_attribute :__instances_with_extended_phi
|
19
20
|
|
20
21
|
after_initialize :wrap_phi
|
21
22
|
|
22
23
|
# These have to default to an empty array
|
23
24
|
self.__phi_methods_wrapped = []
|
24
|
-
self.
|
25
|
+
self.__phi_methods_to_extend = []
|
26
|
+
self.__instances_with_extended_phi = Set.new
|
25
27
|
end
|
26
28
|
|
27
29
|
class_methods do
|
@@ -70,31 +72,205 @@ module PhiAttrs
|
|
70
72
|
# @example
|
71
73
|
# Foo.allow_phi!('user@example.com', 'viewing patient record')
|
72
74
|
#
|
73
|
-
def allow_phi!(user_id, reason)
|
74
|
-
|
75
|
+
def allow_phi!(user_id = nil, reason = nil)
|
76
|
+
raise ArgumentError, 'block not allowed. use allow_phi with block' if block_given?
|
75
77
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
78
|
+
user_id ||= current_user
|
79
|
+
reason ||= i18n_reason
|
80
|
+
raise ArgumentError, 'user_id and reason cannot be blank' if user_id.blank? || reason.blank?
|
81
|
+
|
82
|
+
__phi_stack.push({
|
83
|
+
phi_access_allowed: true,
|
84
|
+
user_id: user_id,
|
85
|
+
reason: reason
|
86
|
+
})
|
81
87
|
|
82
88
|
PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do
|
83
|
-
PhiAttrs::Logger.info("PHI Access Enabled for #{user_id}: #{reason}")
|
89
|
+
PhiAttrs::Logger.info("PHI Access Enabled for '#{user_id}': #{reason}")
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Enable PHI access for any instance of this class in the block given only.
|
94
|
+
#
|
95
|
+
# @param [String] user_id A unique identifier for the person accessing the PHI
|
96
|
+
# @param [String] reason The reason for accessing PHI
|
97
|
+
# @param [collection of PhiRecord] allow_only Specific PhiRecords to allow access to
|
98
|
+
# &block [block] The block in which PHI access is allowed for the class
|
99
|
+
#
|
100
|
+
# @example
|
101
|
+
# Foo.allow_phi('user@example.com', 'viewing patient record') do
|
102
|
+
# # PHI Access Allowed
|
103
|
+
# end
|
104
|
+
# # PHI Access Disallowed
|
105
|
+
#
|
106
|
+
# @example
|
107
|
+
# Foo.allow_phi('user@example.com', 'exporting patient list', allow_only: list_of_foos) do
|
108
|
+
# # PHI Access Allowed for `list_of_foo` only
|
109
|
+
# end
|
110
|
+
# # PHI Access Disallowed
|
111
|
+
#
|
112
|
+
def allow_phi(user_id = nil, reason = nil, allow_only: nil)
|
113
|
+
raise ArgumentError, 'block required. use allow_phi! without block' unless block_given?
|
114
|
+
|
115
|
+
if allow_only.present?
|
116
|
+
raise ArgumentError, 'allow_only must be iterable with each' unless allow_only.respond_to?(:each)
|
117
|
+
raise ArgumentError, "allow_only must all be `#{name}` objects" unless allow_only.all? { |t| t.is_a?(self) }
|
118
|
+
raise ArgumentError, 'allow_only must all have `allow_phi!` methods' unless allow_only.all? { |t| t.respond_to?(:allow_phi!) }
|
119
|
+
end
|
120
|
+
|
121
|
+
# Save this so we don't revoke access previously extended outside the block
|
122
|
+
frozen_instances = Hash[__instances_with_extended_phi.map { |obj| [obj, obj.instance_variable_get(:@__phi_relations_extended).clone] }]
|
123
|
+
|
124
|
+
if allow_only.nil?
|
125
|
+
allow_phi!(user_id, reason)
|
126
|
+
else
|
127
|
+
allow_only.each { |t| t.allow_phi!(user_id, reason) }
|
128
|
+
end
|
129
|
+
|
130
|
+
yield if block_given?
|
131
|
+
|
132
|
+
__instances_with_extended_phi.each do |obj|
|
133
|
+
if frozen_instances.include?(obj)
|
134
|
+
old_extensions = frozen_instances[obj]
|
135
|
+
new_extensions = obj.instance_variable_get(:@__phi_relations_extended) - old_extensions
|
136
|
+
obj.send(:revoke_extended_phi!, new_extensions) if new_extensions.any?
|
137
|
+
else
|
138
|
+
obj.send(:revoke_extended_phi!) # Instance is new to the set, so revoke everything
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
if allow_only.nil?
|
143
|
+
disallow_last_phi!
|
144
|
+
else
|
145
|
+
allow_only.each { |t| t.disallow_last_phi!(preserve_extensions: true) }
|
146
|
+
# We've handled any newly extended allowances ourselves above
|
84
147
|
end
|
85
148
|
end
|
86
149
|
|
87
|
-
#
|
150
|
+
# Explicitly disallow phi access in a specific area of code. This does not
|
151
|
+
# play nicely with the mutating versions of `allow_phi!` and `disallow_phi!`
|
152
|
+
#
|
153
|
+
# At the moment, this doesn't work at all, as the instance won't
|
154
|
+
# necessarily look at the class-level stack when determining if PHI is allowed.
|
155
|
+
#
|
156
|
+
# &block [block] The block in which PHI access is explicitly disallowed.
|
157
|
+
#
|
158
|
+
# @example
|
159
|
+
# # PHI Access Disallowed
|
160
|
+
# Foo.disallow_phi
|
161
|
+
# # PHI Access *Still* Disallowed
|
162
|
+
# end
|
163
|
+
# # PHI Access *Still, still* Disallowed
|
164
|
+
# Foo.allow_phi!('user@example.com', 'viewing patient record')
|
165
|
+
# # PHI Access Allowed
|
166
|
+
# Foo.disallow_phi do
|
167
|
+
# # PHI Access Disallowed
|
168
|
+
# end
|
169
|
+
# # PHI Access Allowed Again
|
170
|
+
def disallow_phi
|
171
|
+
raise ArgumentError, 'block required. use disallow_phi! without block' unless block_given?
|
172
|
+
|
173
|
+
__phi_stack.push({
|
174
|
+
phi_access_allowed: false
|
175
|
+
})
|
176
|
+
|
177
|
+
yield if block_given?
|
178
|
+
|
179
|
+
__phi_stack.pop
|
180
|
+
end
|
181
|
+
|
182
|
+
# Revoke all PHI access for this class, if enabled by PhiRecord#allow_phi!
|
88
183
|
#
|
89
184
|
# @example
|
90
185
|
# Foo.disallow_phi!
|
91
186
|
#
|
92
187
|
def disallow_phi!
|
93
|
-
|
188
|
+
raise ArgumentError, 'block not allowed. use disallow_phi with block' if block_given?
|
189
|
+
|
190
|
+
message = __phi_stack.present? ? "PHI access disabled for #{__user_id_string(__phi_stack)}" : 'PHI access disabled. No class level access was granted.'
|
191
|
+
|
192
|
+
__reset_phi_stack
|
193
|
+
|
94
194
|
PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do
|
95
|
-
PhiAttrs::Logger.info(
|
195
|
+
PhiAttrs::Logger.info(message)
|
96
196
|
end
|
97
197
|
end
|
198
|
+
|
199
|
+
# Revoke last PHI access for this class, if enabled by PhiRecord#allow_phi!
|
200
|
+
#
|
201
|
+
# @example
|
202
|
+
# Foo.disallow_last_phi!
|
203
|
+
#
|
204
|
+
def disallow_last_phi!
|
205
|
+
raise ArgumentError, 'block not allowed' if block_given?
|
206
|
+
|
207
|
+
removed_access = __phi_stack.pop
|
208
|
+
message = removed_access.present? ? "PHI access disabled for #{removed_access[:user_id]}" : 'PHI access disabled. No class level access was granted.'
|
209
|
+
|
210
|
+
PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do
|
211
|
+
PhiAttrs::Logger.info(message)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Whether PHI access is allowed for this class
|
216
|
+
#
|
217
|
+
# @example
|
218
|
+
# Foo.phi_allowed?
|
219
|
+
#
|
220
|
+
# @return [Boolean] whether PHI access is allowed for this instance
|
221
|
+
#
|
222
|
+
def phi_allowed?
|
223
|
+
__phi_stack.present? && __phi_stack[-1][:phi_access_allowed]
|
224
|
+
end
|
225
|
+
|
226
|
+
def __phi_stack
|
227
|
+
RequestStore.store[:phi_access] ||= {}
|
228
|
+
RequestStore.store[:phi_access][name] ||= []
|
229
|
+
end
|
230
|
+
|
231
|
+
def __reset_phi_stack
|
232
|
+
RequestStore.store[:phi_access] ||= {}
|
233
|
+
RequestStore.store[:phi_access][name] = []
|
234
|
+
end
|
235
|
+
|
236
|
+
def __user_id_string(access_list)
|
237
|
+
access_list ||= []
|
238
|
+
access_list.map { |c| "'#{c[:user_id]}'" }.join(',')
|
239
|
+
end
|
240
|
+
|
241
|
+
def current_user
|
242
|
+
RequestStore.store[:phi_attrs_current_user]
|
243
|
+
end
|
244
|
+
|
245
|
+
def i18n_reason
|
246
|
+
controller = RequestStore.store[:phi_attrs_controller]
|
247
|
+
action = RequestStore.store[:phi_attrs_action]
|
248
|
+
|
249
|
+
return nil if controller.blank? || action.blank?
|
250
|
+
|
251
|
+
i18n_path = [PhiAttrs.translation_prefix] + __path_to_controller_and_action(controller, action)
|
252
|
+
i18n_path.push(*__path_to_class)
|
253
|
+
i18n_key = i18n_path.join('.')
|
254
|
+
|
255
|
+
return I18n.t(i18n_key) if I18n.exists?(i18n_key)
|
256
|
+
|
257
|
+
locale = I18n.locale || I18n.default_locale
|
258
|
+
|
259
|
+
PhiAttrs::Logger.warn "No #{locale} PHI Reason found for #{i18n_key}"
|
260
|
+
end
|
261
|
+
|
262
|
+
def __path_to_controller_and_action(controller, action)
|
263
|
+
module_paths = controller.underscore.split('/')
|
264
|
+
class_name_parts = module_paths.pop.split('_')
|
265
|
+
class_name_parts.pop if class_name_parts[-1] == 'controller'
|
266
|
+
module_paths.push(class_name_parts.join('_'), action)
|
267
|
+
end
|
268
|
+
|
269
|
+
def __path_to_class
|
270
|
+
module_paths = name.underscore.split('/')
|
271
|
+
class_name_parts = module_paths.pop.split('_')
|
272
|
+
module_paths.push(class_name_parts.join('_'))
|
273
|
+
end
|
98
274
|
end
|
99
275
|
|
100
276
|
# Get all method names to be wrapped with PHI access logging
|
@@ -125,29 +301,112 @@ module PhiAttrs
|
|
125
301
|
# foo = Foo.find(1)
|
126
302
|
# foo.allow_phi!('user@example.com', 'viewing patient record')
|
127
303
|
#
|
128
|
-
def allow_phi!(user_id, reason)
|
304
|
+
def allow_phi!(user_id = nil, reason = nil)
|
305
|
+
raise ArgumentError, 'block not allowed. use allow_phi with block' if block_given?
|
306
|
+
|
307
|
+
user_id ||= self.class.current_user
|
308
|
+
reason ||= self.class.i18n_reason
|
309
|
+
raise ArgumentError, 'user_id and reason cannot be blank' if user_id.blank? || reason.blank?
|
310
|
+
|
129
311
|
PhiAttrs::Logger.tagged(*phi_log_keys) do
|
130
|
-
@
|
131
|
-
|
132
|
-
|
312
|
+
@__phi_access_stack.push({
|
313
|
+
phi_access_allowed: true,
|
314
|
+
user_id: user_id,
|
315
|
+
reason: reason
|
316
|
+
})
|
133
317
|
|
134
318
|
PhiAttrs::Logger.info("PHI Access Enabled for '#{user_id}': #{reason}")
|
135
319
|
end
|
136
320
|
end
|
137
321
|
|
138
|
-
#
|
322
|
+
# Enable PHI access for a single instance of this class inside the block.
|
323
|
+
# Nested calls to allow_phi will log once per nested call
|
324
|
+
#
|
325
|
+
# @param [String] user_id A unique identifier for the person accessing the PHI
|
326
|
+
# @param [String] reason The reason for accessing PHI
|
327
|
+
# @yield The block in which phi access is allowed
|
328
|
+
#
|
329
|
+
# @example
|
330
|
+
# foo = Foo.find(1)
|
331
|
+
# foo.allow_phi('user@example.com', 'viewing patient record') do
|
332
|
+
# # PHI Access Allowed Here
|
333
|
+
# end
|
334
|
+
# # PHI Access Disallowed Here
|
335
|
+
#
|
336
|
+
def allow_phi(user_id = nil, reason = nil)
|
337
|
+
raise ArgumentError, 'block required. use allow_phi! without block' unless block_given?
|
338
|
+
|
339
|
+
extended_instances = @__phi_relations_extended.clone
|
340
|
+
allow_phi!(user_id, reason)
|
341
|
+
|
342
|
+
yield if block_given?
|
343
|
+
|
344
|
+
new_extensions = @__phi_relations_extended - extended_instances
|
345
|
+
disallow_last_phi!(preserve_extensions: true)
|
346
|
+
revoke_extended_phi!(new_extensions) if new_extensions.any?
|
347
|
+
end
|
348
|
+
|
349
|
+
# Revoke all PHI access for a single instance of this class.
|
139
350
|
#
|
140
351
|
# @example
|
141
352
|
# foo = Foo.find(1)
|
142
353
|
# foo.disallow_phi!
|
143
354
|
#
|
144
355
|
def disallow_phi!
|
356
|
+
raise ArgumentError, 'block not allowed. use disallow_phi with block' if block_given?
|
357
|
+
|
358
|
+
PhiAttrs::Logger.tagged(*phi_log_keys) do
|
359
|
+
removed_access_for = self.class.__user_id_string(@__phi_access_stack)
|
360
|
+
|
361
|
+
revoke_extended_phi!
|
362
|
+
@__phi_access_stack = []
|
363
|
+
|
364
|
+
message = removed_access_for.present? ? "PHI access disabled for #{removed_access_for}" : 'PHI access disabled. No instance level access was granted.'
|
365
|
+
PhiAttrs::Logger.info(message)
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
# Dissables PHI access for a single instance of this class inside the block.
|
370
|
+
# Nested calls to allow_phi will log once per nested call
|
371
|
+
#
|
372
|
+
# @param [String] user_id A unique identifier for the person accessing the PHI
|
373
|
+
# @param [String] reason The reason for accessing PHI
|
374
|
+
# @yield The block in which phi access is allowed
|
375
|
+
#
|
376
|
+
# @example
|
377
|
+
# foo = Foo.find(1)
|
378
|
+
# foo.allow_phi('user@example.com', 'viewing patient record') do
|
379
|
+
# # PHI Access Allowed Here
|
380
|
+
# end
|
381
|
+
# # PHI Access Disallowed Here
|
382
|
+
#
|
383
|
+
def disallow_phi
|
384
|
+
raise ArgumentError, 'block required. use disallow_phi! without block' unless block_given?
|
385
|
+
|
386
|
+
add_disallow_flag!
|
387
|
+
add_disallow_flag_to_extended_phi!
|
388
|
+
|
389
|
+
yield if block_given?
|
390
|
+
|
391
|
+
remove_disallow_flag_from_extended_phi!
|
392
|
+
remove_disallow_flag!
|
393
|
+
end
|
394
|
+
|
395
|
+
# Revoke last PHI access for a single instance of this class.
|
396
|
+
#
|
397
|
+
# @example
|
398
|
+
# foo = Foo.find(1)
|
399
|
+
# foo.disallow_last_phi!
|
400
|
+
#
|
401
|
+
def disallow_last_phi!(preserve_extensions: false)
|
402
|
+
raise ArgumentError, 'block not allowed' if block_given?
|
403
|
+
|
145
404
|
PhiAttrs::Logger.tagged(*phi_log_keys) do
|
146
|
-
|
147
|
-
@__phi_user_id = nil
|
148
|
-
@__phi_access_reason = nil
|
405
|
+
removed_access = @__phi_access_stack.pop
|
149
406
|
|
150
|
-
|
407
|
+
revoke_extended_phi! unless preserve_extensions
|
408
|
+
message = removed_access.present? ? "PHI access disabled for #{removed_access[:user_id]}" : 'PHI access disabled. No instance level access was granted.'
|
409
|
+
PhiAttrs::Logger.info(message)
|
151
410
|
end
|
152
411
|
end
|
153
412
|
|
@@ -160,7 +419,23 @@ module PhiAttrs
|
|
160
419
|
# @return [Boolean] whether PHI access is allowed for this instance
|
161
420
|
#
|
162
421
|
def phi_allowed?
|
163
|
-
|
422
|
+
!phi_context.nil? && phi_context[:phi_access_allowed]
|
423
|
+
end
|
424
|
+
|
425
|
+
protected
|
426
|
+
|
427
|
+
# Adds a disallow phi flag to instance internal stack.
|
428
|
+
# @private since subject to change
|
429
|
+
def add_disallow_flag!
|
430
|
+
@__phi_access_stack.push({
|
431
|
+
phi_access_allowed: false
|
432
|
+
})
|
433
|
+
end
|
434
|
+
|
435
|
+
# removes the last item in instance internal stack.
|
436
|
+
# @private since subject to change
|
437
|
+
def remove_disallow_flag!
|
438
|
+
@__phi_access_stack.pop
|
164
439
|
end
|
165
440
|
|
166
441
|
private
|
@@ -172,12 +447,13 @@ module PhiAttrs
|
|
172
447
|
#
|
173
448
|
def wrap_phi
|
174
449
|
# Disable PHI access by default
|
175
|
-
@
|
176
|
-
@
|
450
|
+
@__phi_access_stack = []
|
451
|
+
@__phi_methods_extended = Set.new
|
452
|
+
@__phi_relations_extended = Set.new
|
177
453
|
|
178
454
|
# Wrap attributes with PHI Logger and Access Control
|
179
455
|
__phi_wrapped_methods.each { |m| phi_wrap_method(m) }
|
180
|
-
__phi_extended_methods.each { |m|
|
456
|
+
__phi_extended_methods.each { |m| phi_wrap_extension(m) }
|
181
457
|
end
|
182
458
|
|
183
459
|
# Log Key for an instance of this class. If the instance is persisted in the
|
@@ -204,7 +480,7 @@ module PhiAttrs
|
|
204
480
|
# @return [String] the user_id passed in to allow_phi!
|
205
481
|
#
|
206
482
|
def phi_allowed_by
|
207
|
-
|
483
|
+
phi_context[:user_id]
|
208
484
|
end
|
209
485
|
|
210
486
|
# The access reason for allowing access to this instance.
|
@@ -215,7 +491,41 @@ module PhiAttrs
|
|
215
491
|
# @return [String] the reason passed in to allow_phi!
|
216
492
|
#
|
217
493
|
def phi_access_reason
|
218
|
-
|
494
|
+
phi_context[:reason]
|
495
|
+
end
|
496
|
+
|
497
|
+
def phi_context
|
498
|
+
instance_phi_context || class_phi_context
|
499
|
+
end
|
500
|
+
|
501
|
+
def instance_phi_context
|
502
|
+
@__phi_access_stack && @__phi_access_stack[-1]
|
503
|
+
end
|
504
|
+
|
505
|
+
def class_phi_context
|
506
|
+
self.class.__phi_stack[-1]
|
507
|
+
end
|
508
|
+
|
509
|
+
# The unique identifiers for everything with access allowed on this instance.
|
510
|
+
#
|
511
|
+
# @private
|
512
|
+
#
|
513
|
+
# @return String of all the user_id's passed in to allow_phi!
|
514
|
+
#
|
515
|
+
def all_phi_allowed_by
|
516
|
+
self.class.__user_id_string(all_phi_context)
|
517
|
+
end
|
518
|
+
|
519
|
+
def all_phi_context
|
520
|
+
(@__phi_access_stack || []) + (self.class.__phi_stack || [])
|
521
|
+
end
|
522
|
+
|
523
|
+
def all_phi_context_logged?
|
524
|
+
all_phi_context.all? { |v| v[:logged] }
|
525
|
+
end
|
526
|
+
|
527
|
+
def set_all_phi_context_logged
|
528
|
+
all_phi_context.each { |c| c[:logged] = true }
|
219
529
|
end
|
220
530
|
|
221
531
|
# Core logic for wrapping methods in PHI access logging and access restriction.
|
@@ -260,9 +570,9 @@ module PhiAttrs
|
|
260
570
|
PhiAttrs::Logger.tagged(*phi_log_keys) do
|
261
571
|
raise PhiAttrs::Exceptions::PhiAccessException, "Attempted PHI access for #{self.class.name} #{@__phi_user_id}" unless phi_allowed?
|
262
572
|
|
263
|
-
unless
|
264
|
-
PhiAttrs::Logger.info("
|
265
|
-
|
573
|
+
unless all_phi_context_logged?
|
574
|
+
PhiAttrs::Logger.info("#{self.class.name} access by [#{all_phi_allowed_by}]. Triggered by method: #{method_name}")
|
575
|
+
set_all_phi_context_logged
|
266
576
|
end
|
267
577
|
|
268
578
|
send(unwrapped_method, *args, &block)
|
@@ -283,18 +593,24 @@ module PhiAttrs
|
|
283
593
|
#
|
284
594
|
# @private
|
285
595
|
#
|
286
|
-
def
|
287
|
-
return if self.class.
|
596
|
+
def phi_wrap_extension(method_name)
|
597
|
+
return if self.class.__phi_methods_to_extend.include? method_name
|
288
598
|
|
289
|
-
wrapped_method =
|
290
|
-
unwrapped_method =
|
599
|
+
wrapped_method = wrapped_extended_name(method_name)
|
600
|
+
unwrapped_method = unwrapped_extended_name(method_name)
|
291
601
|
|
292
602
|
self.class.send(:define_method, wrapped_method) do |*args, &block|
|
293
|
-
# get the unwrapped relation
|
294
603
|
relation = send(unwrapped_method, *args, &block)
|
295
604
|
|
296
|
-
if phi_allowed?
|
297
|
-
relation.
|
605
|
+
if phi_allowed?
|
606
|
+
if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
|
607
|
+
relations = relation.is_a?(Enumerable) ? relation : [relation]
|
608
|
+
relations.each do |r|
|
609
|
+
r.allow_phi!(phi_allowed_by, phi_access_reason) unless @__phi_relations_extended.include?(r)
|
610
|
+
end
|
611
|
+
@__phi_relations_extended.merge(relations)
|
612
|
+
self.class.__instances_with_extended_phi.add(self)
|
613
|
+
end
|
298
614
|
end
|
299
615
|
|
300
616
|
relation
|
@@ -304,7 +620,47 @@ module PhiAttrs
|
|
304
620
|
self.class.send(:alias_method, unwrapped_method, method_name)
|
305
621
|
self.class.send(:alias_method, method_name, wrapped_method)
|
306
622
|
|
307
|
-
self.class.
|
623
|
+
self.class.__phi_methods_to_extend << method_name
|
624
|
+
end
|
625
|
+
|
626
|
+
# Revoke PHI access for all `extend`ed relations (or only those given)
|
627
|
+
def revoke_extended_phi!(relations = nil)
|
628
|
+
relations ||= @__phi_relations_extended
|
629
|
+
relations.each do |relation|
|
630
|
+
relation.disallow_last_phi! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
|
631
|
+
end
|
632
|
+
@__phi_relations_extended.subtract(relations)
|
633
|
+
end
|
634
|
+
|
635
|
+
# Adds a disallow PHI access to the stack for block syntax for all `extend`ed relations (or only those given)
|
636
|
+
def add_disallow_flag_to_extended_phi!(relations = nil)
|
637
|
+
relations ||= @__phi_relations_extended
|
638
|
+
relations.each do |relation|
|
639
|
+
relation.add_disallow_flag! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
|
640
|
+
end
|
641
|
+
end
|
642
|
+
|
643
|
+
# Adds a disallow PHI access to the stack for all for all `extend`ed relations (or only those given)
|
644
|
+
def remove_disallow_flag_from_extended_phi!(relations = nil)
|
645
|
+
relations ||= @__phi_relations_extended
|
646
|
+
relations.each do |relation|
|
647
|
+
relation.remove_disallow_flag! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
|
648
|
+
end
|
649
|
+
end
|
650
|
+
|
651
|
+
def relation_klass(rel)
|
652
|
+
return rel.klass if rel.is_a?(ActiveRecord::Relation)
|
653
|
+
return rel.first.class if rel.is_a?(Enumerable)
|
654
|
+
|
655
|
+
return rel.class
|
656
|
+
end
|
657
|
+
|
658
|
+
def wrapped_extended_name(method_name)
|
659
|
+
:"__#{method_name}_phi_access_extended"
|
660
|
+
end
|
661
|
+
|
662
|
+
def unwrapped_extended_name(method_name)
|
663
|
+
:"__#{method_name}_phi_access_original"
|
308
664
|
end
|
309
665
|
end
|
310
666
|
end
|