phi_attrs 0.1.1 → 0.2.1

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