phi_attrs 0.1.1 → 0.2.1

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