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.
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
@@ -11,4 +11,10 @@ require 'phi_attrs'
11
11
  # Pry.start
12
12
 
13
13
  require 'irb'
14
+ require 'irb/completion'
15
+
16
+ PhiAttrs.configure do |conf|
17
+ conf.log_path = File.join('log', 'phi_access_console.log')
18
+ end
19
+
14
20
  IRB.start(__FILE__)
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # runOnDocker SERVICE
4
+ runOnDocker() {
5
+ if [[ $DOCKER_CONTAINER -ne 1 ]]; then
6
+ echo "On Host. Executing command on container"
7
+ docker-compose exec -T $1 $0
8
+ exit $?
9
+ fi
10
+ }
11
+
12
+ export -f runOnDocker
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ source bin/helpers/docker
3
+ runOnDocker rails
4
+
5
+ echo "== Starting rubocop =="
6
+ bundle exec rubocop --format worst --format simple --format offenses --auto-correct
@@ -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
@@ -3,5 +3,7 @@ set -euo pipefail
3
3
  IFS=$'\n\t'
4
4
  set -vx
5
5
 
6
+ gem install bundler -v $BUNDLER_VERSION
7
+
6
8
  bundle check || bundle install
7
9
  bundle exec appraisal install
@@ -7,10 +7,12 @@ services:
7
7
  - bundle_cache:/bundle
8
8
  - .:/app
9
9
  environment:
10
+ - BUNDLER_VERSION=2.0.1
10
11
  - BUNDLE_JOBS=5
11
12
  - BUNDLE_PATH=/bundle
12
13
  - BUNDLE_BIN=/bundle/bin
13
14
  - GEM_HOME=/bundle
15
+ - DOCKER_CONTAINER=1
14
16
  command:
15
17
  - docker/start.sh
16
18
 
File without changes
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file was generated by Appraisal
4
+
5
+ source 'https://rubygems.org'
6
+
7
+ gem 'rails', '5.1.3'
8
+ gem 'tzinfo-data'
9
+
10
+ gemspec path: '../'
@@ -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 phi_model(with: nil, except: nil)
15
- include PhiRecord
16
- logger = ActiveSupport::Logger.new(PhiAttrs.log_path)
17
- logger.formatter = Formatter.new
18
- file_logger = ActiveSupport::TaggedLogging.new(logger)
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
- PhiAttrs::Logger.logger = file_logger
22
+ module Model
23
+ def phi_model(with: nil, except: nil)
24
+ include PhiRecord
25
+ end
21
26
  end
22
27
 
23
- @@log_path = nil
28
+ module Controller
29
+ extend ActiveSupport::Concern
24
30
 
25
- def self.configure
26
- yield self if block_given?
27
- end
31
+ included do
32
+ before_action :record_i18n_data
33
+ end
28
34
 
29
- def self.log_path
30
- @@log_path
31
- end
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
- def self.log_path=(value)
34
- @@log_path = value
44
+ RequestStore.store[:phi_attrs_current_user] = send(PhiAttrs.current_user_method)
45
+ end
35
46
  end
36
47
  end
@@ -1,17 +1,35 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module PhiAttrs
2
- module Configure
3
- @@log_path = nil
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
- def configure
6
- yield self if block_given?
7
- end
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
- def log_path
10
- @@log_path
11
- end
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
- def log_path=(value)
14
- @@log_path = value
15
- end
32
+ def self.current_user_method=(value)
33
+ @@current_user_method = value
16
34
  end
17
35
  end
@@ -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('UNAUTHORIZED ACCESS') { PhiAttrs::Logger.error(msg) }
9
+ PhiAttrs::Logger.tagged(TAG) { PhiAttrs::Logger.error(msg) }
6
10
  super(msg)
7
11
  end
8
12
  end
@@ -1,10 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module PhiAttrs
2
- Format = "%s %5s: %s\n".freeze
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, progname, msg)
7
- Format % [format_datetime(timestamp), severity, msg2str(msg)]
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
@@ -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
- cattr_accessor :logger
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
@@ -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 :__phi_methods_extended
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.__phi_methods_extended = []
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
- RequestStore.store[:phi_access] ||= {}
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
- RequestStore.store[:phi_access][name] ||= {
77
- phi_access_allowed: true,
78
- user_id: user_id,
79
- reason: reason
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
- # Revoke PHI access for this class, if enabled by PhiRecord#allow_phi!
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
- RequestStore.store[:phi_access].delete(name) if RequestStore.store[:phi_access].present?
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('PHI access disabled')
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
- @__phi_access_allowed = true
131
- @__phi_user_id = user_id
132
- @__phi_access_reason = reason
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
- # Revoke PHI access for a single instance of this class
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
- @__phi_access_allowed = false
147
- @__phi_user_id = nil
148
- @__phi_access_reason = nil
405
+ removed_access = @__phi_access_stack.pop
149
406
 
150
- PhiAttrs::Logger.info('PHI access disabled')
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
- @__phi_access_allowed || RequestStore.store.dig(:phi_access, self.class.name, :phi_access_allowed)
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
- @__phi_access_allowed = false
176
- @__phi_access_logged = false
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| phi_extend_access(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
- @__phi_user_id || RequestStore.store.dig(:phi_access, self.class.name, :user_id)
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
- @__phi_access_reason || RequestStore.store.dig(:phi_access, self.class.name, :reason)
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 @__phi_access_logged
264
- PhiAttrs::Logger.info("'#{phi_allowed_by}' accessing #{self.class.name}. Triggered by method: #{method_name}")
265
- @__phi_access_logged = true
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 phi_extend_access(method_name)
287
- return if self.class.__phi_methods_extended.include? method_name
596
+ def phi_wrap_extension(method_name)
597
+ return if self.class.__phi_methods_to_extend.include? method_name
288
598
 
289
- wrapped_method = :"__#{method_name}_phi_access_extended"
290
- unwrapped_method = :"__#{method_name}_phi_access_original"
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? && relation.class.included_modules.include?(PhiRecord)
297
- relation.allow_phi!(phi_allowed_by, phi_access_reason) unless relation.phi_allowed?
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.__phi_methods_extended << method_name
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