phi_attrs 0.1.2 → 0.2.2

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
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