phi_attrs 0.4.2 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28560c0910d408f722f837687ac660a43aa3e708c10366603490270a9bfa3aaf
4
- data.tar.gz: 9c58fa96bbff3aecf4d7f63defd5bbdba40e72ddea6073dc3f02337b4b9a4b36
3
+ metadata.gz: 40fc413631692c1e040f09c38e551985342b0555f0268f61d7ffcddf79522f2c
4
+ data.tar.gz: e0354aaf9093ae6125ae8a8d227459c59ce19bfd29b2255c1caaece063bf42ea
5
5
  SHA512:
6
- metadata.gz: 639da1bdf5990baacfd20d427690c2ff7fb4445461ab150aafea3f2b9e611074fc1e6dd3a32198e50de18a5002095e818c38a3deec50c3ac3f75560ad3fe831f
7
- data.tar.gz: 0b1efe76204b467c5ea51c48d5df7153e7cf63df3fa89530e2d331d8f9e9b5813673f0feb01443d90ea67bae87eee47ff267e7b319588588f619782e087858ac
6
+ metadata.gz: acea49a6c616147fcee57e5ea929acd4ee1902d21551c21f76b1c485149005cea09acd4cfe9ad5639e661150604e4b4947d56c3897329f181c74d74c33f60236
7
+ data.tar.gz: e60d3558f57e9cb626f13b1d3d14669fba2ca9e15503c51be061f1ae797fe270485129134a54d38ddd7c0a5920d2884c6aaa3a79cb2291255341a205ec59f30a
data/README.md CHANGED
@@ -443,7 +443,7 @@ It is recommended to use the provided `docker-compose` environment for developme
443
443
 
444
444
  ### Tests
445
445
 
446
- Tests are written using [RSpec](https://rspec.info/) and are setup to use [Appraisal](https://github.com/thoughtbot/appraisal) to run tests over multiple Rails versions. Supported runtimes are Ruby 3.2+ and Rails 7.0 through 8.0.
446
+ Tests are written using [RSpec](https://rspec.info/) and are setup to use [Appraisal](https://github.com/thoughtbot/appraisal) to run tests over multiple Rails versions. Supported runtimes are Ruby 3.2+ and Rails 7.0 through 8.1.
447
447
 
448
448
  $ bin/run_tests
449
449
  or for individual tests:
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Namespace for classes and modules that handle PHI Attribute Access Logging
4
4
  module PhiAttrs
5
- PhiStackEntry = Struct.new(:phi_access_allowed, :user_id, :reason, :logged, :uuid, keyword_init: true)
5
+ PhiStackEntry = Struct.new(:phi_access_allowed, :user_id, :reason, :logged, :uuid)
6
6
 
7
7
  # Module for extending ActiveRecord models to handle PHI access logging
8
8
  # and restrict access to attributes.
@@ -12,6 +12,14 @@ module PhiAttrs
12
12
  module PhiRecord
13
13
  extend ActiveSupport::Concern
14
14
 
15
+ # Serializes method wrapping. Wrapping runs from `after_initialize`, so the
16
+ # first instances of a model built concurrently on different threads would
17
+ # otherwise race through the check-then-alias steps below. A thread that
18
+ # loses that race aliases the unwrapped name to the wrapper, and every later
19
+ # call recurses until SystemStackError. A Monitor (not a Mutex) is used so
20
+ # the same thread can take the lock again if building one record builds another.
21
+ PHI_WRAP_LOCK = Monitor.new
22
+
15
23
  included do
16
24
  class_attribute :__phi_exclude_methods
17
25
  class_attribute :__phi_include_methods
@@ -73,21 +81,21 @@ module PhiAttrs
73
81
  # Foo.allow_phi!('user@example.com', 'viewing patient record')
74
82
  #
75
83
  def allow_phi!(user_id = nil, reason = nil)
76
- raise ArgumentError, 'block not allowed. use allow_phi with block' if block_given?
84
+ raise ArgumentError, "block not allowed. use allow_phi with block" if block_given?
77
85
 
78
86
  user_id ||= current_user
79
87
  reason ||= i18n_reason
80
- raise ArgumentError, 'user_id and reason cannot be blank' if user_id.blank? || reason.blank?
88
+ raise ArgumentError, "user_id and reason cannot be blank" if user_id.blank? || reason.blank?
81
89
 
82
90
  user_id = user_id.to_s.gsub(/[\r\n]/, " ")
83
91
  reason = reason.to_s.gsub(/[\r\n]/, " ")
84
92
  uuid = SecureRandom.uuid
85
93
  __phi_stack.push(PhiStackEntry.new(
86
- phi_access_allowed: true,
87
- user_id: user_id,
88
- reason: reason,
89
- uuid: uuid
90
- ))
94
+ phi_access_allowed: true,
95
+ user_id: user_id,
96
+ reason: reason,
97
+ uuid: uuid
98
+ ))
91
99
 
92
100
  PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name, uuid) do
93
101
  PhiAttrs::Logger.info("PHI Access Enabled for '#{user_id}': #{reason}")
@@ -137,12 +145,12 @@ module PhiAttrs
137
145
  # end
138
146
  #
139
147
  def get_phi(user_id = nil, reason = nil, allow_only: nil)
140
- raise ArgumentError, 'block required' unless block_given?
148
+ raise ArgumentError, "block required" unless block_given?
141
149
 
142
150
  if allow_only.present?
143
- raise ArgumentError, 'allow_only must be iterable with each' unless allow_only.respond_to?(:each)
151
+ raise ArgumentError, "allow_only must be iterable with each" unless allow_only.respond_to?(:each)
144
152
  raise ArgumentError, "allow_only must all be `#{name}` objects" unless allow_only.all? { |t| t.is_a?(self) }
145
- raise ArgumentError, 'allow_only must all have `allow_phi!` methods' unless allow_only.all? { |t| t.respond_to?(:allow_phi!) }
153
+ raise ArgumentError, "allow_only must all have `allow_phi!` methods" unless allow_only.all? { |t| t.respond_to?(:allow_phi!) }
146
154
  end
147
155
 
148
156
  # Save this so we don't revoke access previously extended outside the block
@@ -197,7 +205,7 @@ module PhiAttrs
197
205
  # end
198
206
  # # PHI Access Allowed Again
199
207
  def disallow_phi
200
- raise ArgumentError, 'block required. use disallow_phi! without block' unless block_given?
208
+ raise ArgumentError, "block required. use disallow_phi! without block" unless block_given?
201
209
 
202
210
  __phi_stack.push(PhiStackEntry.new(phi_access_allowed: false))
203
211
  begin
@@ -213,10 +221,10 @@ module PhiAttrs
213
221
  # Foo.disallow_phi!
214
222
  #
215
223
  def disallow_phi!
216
- raise ArgumentError, 'block not allowed. use disallow_phi with block' if block_given?
224
+ raise ArgumentError, "block not allowed. use disallow_phi with block" if block_given?
217
225
 
218
226
  uuid = __uuid_string(__phi_stack)
219
- message = __phi_stack.present? ? "PHI access disabled for #{__user_id_string(__phi_stack)}" : 'PHI access disabled. No class level access was granted.'
227
+ message = __phi_stack.present? ? "PHI access disabled for #{__user_id_string(__phi_stack)}" : "PHI access disabled. No class level access was granted."
220
228
 
221
229
  __reset_phi_stack
222
230
 
@@ -231,11 +239,11 @@ module PhiAttrs
231
239
  # Foo.disallow_last_phi!
232
240
  #
233
241
  def disallow_last_phi!
234
- raise ArgumentError, 'block not allowed' if block_given?
242
+ raise ArgumentError, "block not allowed" if block_given?
235
243
 
236
244
  removed_access = __phi_stack.pop
237
245
  uuid = __uuid_string(removed_access)
238
- message = removed_access.present? ? "PHI access disabled for #{removed_access.user_id}" : 'PHI access disabled. No class level access was granted.'
246
+ message = removed_access.present? ? "PHI access disabled for #{removed_access.user_id}" : "PHI access disabled. No class level access was granted."
239
247
 
240
248
  PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name, uuid) do
241
249
  PhiAttrs::Logger.info(message)
@@ -269,12 +277,12 @@ module PhiAttrs
269
277
 
270
278
  def __user_id_string(access_list)
271
279
  access_list ||= []
272
- access_list.map { |c| "'#{c.user_id}'" }.join(',')
280
+ access_list.map { |c| "'#{c.user_id}'" }.join(",")
273
281
  end
274
282
 
275
283
  def __uuid_string(access_list)
276
284
  access_list ||= []
277
- Array.wrap(access_list).map(&:uuid).join(',').presence || 'none'
285
+ Array.wrap(access_list).map(&:uuid).join(",").presence || "none"
278
286
  end
279
287
 
280
288
  def current_user
@@ -289,7 +297,7 @@ module PhiAttrs
289
297
 
290
298
  i18n_path = [PhiAttrs.translation_prefix] + __path_to_controller_and_action(controller, action)
291
299
  i18n_path.push(*__path_to_class)
292
- i18n_key = i18n_path.join('.')
300
+ i18n_key = i18n_path.join(".")
293
301
 
294
302
  return I18n.t(i18n_key) if I18n.exists?(i18n_key)
295
303
 
@@ -299,16 +307,16 @@ module PhiAttrs
299
307
  end
300
308
 
301
309
  def __path_to_controller_and_action(controller, action)
302
- module_paths = controller.underscore.split('/')
303
- class_name_parts = module_paths.pop.split('_')
304
- class_name_parts.pop if class_name_parts[-1] == 'controller'
305
- module_paths.push(class_name_parts.join('_'), action)
310
+ module_paths = controller.underscore.split("/")
311
+ class_name_parts = module_paths.pop.split("_")
312
+ class_name_parts.pop if class_name_parts[-1] == "controller"
313
+ module_paths.push(class_name_parts.join("_"), action)
306
314
  end
307
315
 
308
316
  def __path_to_class
309
- module_paths = name.underscore.split('/')
310
- class_name_parts = module_paths.pop.split('_')
311
- module_paths.push(class_name_parts.join('_'))
317
+ module_paths = name.underscore.split("/")
318
+ class_name_parts = module_paths.pop.split("_")
319
+ module_paths.push(class_name_parts.join("_"))
312
320
  end
313
321
  end
314
322
 
@@ -341,21 +349,21 @@ module PhiAttrs
341
349
  # foo.allow_phi!('user@example.com', 'viewing patient record')
342
350
  #
343
351
  def allow_phi!(user_id = nil, reason = nil)
344
- raise ArgumentError, 'block not allowed. use allow_phi with block' if block_given?
352
+ raise ArgumentError, "block not allowed. use allow_phi with block" if block_given?
345
353
 
346
354
  user_id ||= self.class.current_user
347
355
  reason ||= self.class.i18n_reason
348
- raise ArgumentError, 'user_id and reason cannot be blank' if user_id.blank? || reason.blank?
356
+ raise ArgumentError, "user_id and reason cannot be blank" if user_id.blank? || reason.blank?
349
357
 
350
358
  user_id = user_id.to_s.gsub(/[\r\n]/, " ")
351
359
  reason = reason.to_s.gsub(/[\r\n]/, " ")
352
360
  uuid = SecureRandom.uuid
353
361
  @__phi_access_stack.push(PhiStackEntry.new(
354
- phi_access_allowed: true,
355
- user_id: user_id,
356
- reason: reason,
357
- uuid: uuid,
358
- ))
362
+ phi_access_allowed: true,
363
+ user_id: user_id,
364
+ reason: reason,
365
+ uuid: uuid
366
+ ))
359
367
 
360
368
  PhiAttrs::Logger.tagged(*phi_log_keys, uuid) do
361
369
  PhiAttrs::Logger.info("PHI Access Enabled for '#{user_id}': #{reason}")
@@ -398,7 +406,7 @@ module PhiAttrs
398
406
  # end
399
407
  #
400
408
  def get_phi(user_id = nil, reason = nil)
401
- raise ArgumentError, 'block required' unless block_given?
409
+ raise ArgumentError, "block required" unless block_given?
402
410
 
403
411
  extended_instances = @__phi_relations_extended.clone
404
412
  begin
@@ -419,7 +427,7 @@ module PhiAttrs
419
427
  # foo.disallow_phi!
420
428
  #
421
429
  def disallow_phi!
422
- raise ArgumentError, 'block not allowed. use disallow_phi with block' if block_given?
430
+ raise ArgumentError, "block not allowed. use disallow_phi with block" if block_given?
423
431
 
424
432
  removed_access_for_uuid = self.class.__uuid_string(@__phi_access_stack)
425
433
 
@@ -429,7 +437,7 @@ module PhiAttrs
429
437
  revoke_extended_phi!
430
438
  @__phi_access_stack = []
431
439
 
432
- message = removed_access_for.present? ? "PHI access disabled for #{removed_access_for}" : 'PHI access disabled. No instance level access was granted.'
440
+ message = removed_access_for.present? ? "PHI access disabled for #{removed_access_for}" : "PHI access disabled. No instance level access was granted."
433
441
  PhiAttrs::Logger.info(message)
434
442
  end
435
443
  end
@@ -449,7 +457,7 @@ module PhiAttrs
449
457
  # # PHI Access Disallowed Here
450
458
  #
451
459
  def disallow_phi
452
- raise ArgumentError, 'block required. use disallow_phi! without block' unless block_given?
460
+ raise ArgumentError, "block required. use disallow_phi! without block" unless block_given?
453
461
 
454
462
  add_disallow_flag!
455
463
  add_disallow_flag_to_extended_phi!
@@ -468,14 +476,14 @@ module PhiAttrs
468
476
  # foo.disallow_last_phi!
469
477
  #
470
478
  def disallow_last_phi!(preserve_extensions: false)
471
- raise ArgumentError, 'block not allowed' if block_given?
479
+ raise ArgumentError, "block not allowed" if block_given?
472
480
 
473
481
  removed_access = @__phi_access_stack.pop
474
482
  uuid = removed_access&.uuid
475
483
 
476
484
  PhiAttrs::Logger.tagged(*phi_log_keys, uuid) do
477
485
  revoke_extended_phi! unless preserve_extensions
478
- message = removed_access.present? ? "PHI access disabled for #{removed_access.user_id}" : 'PHI access disabled. No instance level access was granted.'
486
+ message = removed_access.present? ? "PHI access disabled for #{removed_access.user_id}" : "PHI access disabled. No instance level access was granted."
479
487
  PhiAttrs::Logger.info(message)
480
488
  end
481
489
  end
@@ -486,7 +494,7 @@ module PhiAttrs
486
494
  # @return [String] the user_id passed in to allow_phi!
487
495
  #
488
496
  def phi_allowed_by
489
- return '__new_record__' if new_record?
497
+ return "__new_record__" if new_record?
490
498
 
491
499
  phi_context&.user_id
492
500
  end
@@ -497,7 +505,7 @@ module PhiAttrs
497
505
  # @return [String] the reason passed in to allow_phi!
498
506
  #
499
507
  def phi_access_reason
500
- return 'new record' if new_record?
508
+ return "new record" if new_record?
501
509
 
502
510
  phi_context&.reason
503
511
  end
@@ -523,10 +531,10 @@ module PhiAttrs
523
531
  # end
524
532
  #
525
533
  def require_phi!
526
- raise PhiAttrs::Exceptions::PhiAccessException, 'PHI Access required, please call allow_phi or allow_phi! first' unless phi_allowed?
534
+ raise PhiAttrs::Exceptions::PhiAccessException, "PHI Access required, please call allow_phi or allow_phi! first" unless phi_allowed?
527
535
  end
528
536
 
529
- def reload
537
+ def reload(options = nil)
530
538
  @__phi_relations_extended.clear
531
539
  super
532
540
  end
@@ -605,7 +613,7 @@ module PhiAttrs
605
613
  def all_phi_allowed_by
606
614
  ids = (@__phi_access_stack || []).filter_map { |c| "'#{c.user_id}'" }
607
615
  ids.concat(self.class.__phi_stack.filter_map { |c| "'#{c.user_id}'" })
608
- ids.join(',')
616
+ ids.join(",")
609
617
  end
610
618
 
611
619
  def all_phi_context_logged?
@@ -657,6 +665,18 @@ module PhiAttrs
657
665
  end
658
666
  return if self.class.__phi_methods_wrapped.include? method_name
659
667
 
668
+ PHI_WRAP_LOCK.synchronize { phi_wrap_method!(method_name) }
669
+ end
670
+
671
+ # Does the wrapping for `phi_wrap_method`. Must be called with
672
+ # PHI_WRAP_LOCK held.
673
+ #
674
+ # @private
675
+ #
676
+ def phi_wrap_method!(method_name)
677
+ # Another thread may have wrapped this method while we waited for the lock.
678
+ return if self.class.__phi_methods_wrapped.include? method_name
679
+
660
680
  wrapped_method = :"__#{method_name}_phi_wrapped"
661
681
  unwrapped_method = :"__#{method_name}_phi_unwrapped"
662
682
 
@@ -696,6 +716,18 @@ module PhiAttrs
696
716
  raise NameError, "Undefined relationship in `extend_phi_access`: #{method_name}" unless respond_to?(method_name)
697
717
  return if self.class.__phi_methods_to_extend.include? method_name
698
718
 
719
+ PHI_WRAP_LOCK.synchronize { phi_wrap_extension!(method_name) }
720
+ end
721
+
722
+ # Does the wrapping for `phi_wrap_extension`. Must be called with
723
+ # PHI_WRAP_LOCK held.
724
+ #
725
+ # @private
726
+ #
727
+ def phi_wrap_extension!(method_name)
728
+ # Another thread may have wrapped this method while we waited for the lock.
729
+ return if self.class.__phi_methods_to_extend.include? method_name
730
+
699
731
  wrapped_method = wrapped_extended_name(method_name)
700
732
  unwrapped_method = unwrapped_extended_name(method_name)
701
733
 
@@ -704,7 +736,7 @@ module PhiAttrs
704
736
  relation = send(unwrapped_method, *args, **kwargs, &block)
705
737
 
706
738
  access_context = phi_context
707
- if access_context&.phi_access_allowed && relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
739
+ if access_context&.phi_access_allowed && relation.present? && relation_klass(relation).include?(PhiRecord)
708
740
  relations = relation.is_a?(Enumerable) ? relation : [relation]
709
741
  relations.each do |r|
710
742
  next if @__phi_relations_extended.include?(r) && r.phi_allowed?
@@ -730,7 +762,7 @@ module PhiAttrs
730
762
  def revoke_extended_phi!(relations = nil)
731
763
  relations ||= @__phi_relations_extended
732
764
  relations.each do |relation|
733
- relation.disallow_last_phi! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
765
+ relation.disallow_last_phi! if relation.present? && relation_klass(relation).include?(PhiRecord)
734
766
  end
735
767
  @__phi_relations_extended.subtract(relations)
736
768
  end
@@ -739,7 +771,7 @@ module PhiAttrs
739
771
  def add_disallow_flag_to_extended_phi!(relations = nil)
740
772
  relations ||= @__phi_relations_extended
741
773
  relations.each do |relation|
742
- relation.add_disallow_flag! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
774
+ relation.add_disallow_flag! if relation.present? && relation_klass(relation).include?(PhiRecord)
743
775
  end
744
776
  end
745
777
 
@@ -747,7 +779,7 @@ module PhiAttrs
747
779
  def remove_disallow_flag_from_extended_phi!(relations = nil)
748
780
  relations ||= @__phi_relations_extended
749
781
  relations.each do |relation|
750
- relation.remove_disallow_flag! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)
782
+ relation.remove_disallow_flag! if relation.present? && relation_klass(relation).include?(PhiRecord)
751
783
  end
752
784
  end
753
785
 
@@ -27,7 +27,7 @@ RSpec::Matchers.define :allow_phi_access do
27
27
  @reason = reason
28
28
  end
29
29
 
30
- # :nocov:
30
+ # simplecov:disable
31
31
  failure_message do |result|
32
32
  msgs = []
33
33
 
@@ -41,5 +41,5 @@ RSpec::Matchers.define :allow_phi_access do
41
41
  failure_message_when_negated do |result|
42
42
  "PHI access was allowed by '#{result.phi_allowed_by}', because '#{result.phi_access_reason}'"
43
43
  end
44
- # :nocov:
44
+ # simplecov:enable
45
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhiAttrs
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/phi_attrs.rb CHANGED
@@ -1,17 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rails'
4
- require 'active_support'
5
- require 'request_store'
6
- require 'securerandom'
7
-
8
- require 'phi_attrs/version'
9
- require 'phi_attrs/configure'
10
- require 'phi_attrs/railtie' if defined?(Rails)
11
- require 'phi_attrs/formatter'
12
- require 'phi_attrs/logger'
13
- require 'phi_attrs/exceptions'
14
- require 'phi_attrs/phi_record'
3
+ require "rails"
4
+ require "active_support"
5
+ require "request_store"
6
+ require "securerandom"
7
+ require "monitor"
8
+
9
+ require "phi_attrs/version"
10
+ require "phi_attrs/configure"
11
+ require "phi_attrs/railtie" if defined?(Rails)
12
+ require "phi_attrs/formatter"
13
+ require "phi_attrs/logger"
14
+ require "phi_attrs/exceptions"
15
+ require "phi_attrs/phi_record"
15
16
 
16
17
  module PhiAttrs
17
18
  def self.log_phi_access(user, message)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phi_attrs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wyatt Kirby