phi_attrs 0.1.2 → 0.2.2

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