phi_attrs 0.1.4 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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