├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── DISCLAIMER.txt ├── Dockerfile ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── helpers │ └── docker ├── rubo_fix ├── run_container ├── run_tests ├── setup └── ssh_to_container ├── config.ru ├── docker-compose.yml ├── docker └── start.sh ├── gemfiles ├── rails_6.1.gemfile └── rails_7.0.gemfile ├── lib ├── phi_attrs.rb └── phi_attrs │ ├── configure.rb │ ├── exceptions.rb │ ├── formatter.rb │ ├── logger.rb │ ├── phi_record.rb │ ├── railtie.rb │ ├── rspec.rb │ └── version.rb ├── phi_attrs.gemspec └── spec ├── dummy ├── app │ ├── assets │ │ └── config │ │ │ └── manifest.js │ ├── controllers │ │ └── application_controller.rb │ └── models │ │ ├── address.rb │ │ ├── application_record.rb │ │ ├── health_record.rb │ │ ├── missing_attribute_model.rb │ │ ├── missing_extend_model.rb │ │ ├── patient_detail.rb │ │ └── patient_info.rb ├── application.rb ├── config │ ├── database.yml │ ├── initializers │ │ └── phi_attrs.rb │ ├── locales │ │ └── en.yml │ ├── routes.rb │ └── storage.yml ├── db │ ├── migrate │ │ └── 20170214100255_create_patient_infos.rb │ └── schema.rb ├── factories │ ├── addresses.rb │ ├── health_records.rb │ ├── missing_attribute_models.rb │ ├── missing_extend_models.rb │ ├── patient_details.rb │ └── patient_infos.rb ├── log │ └── .gitignore ├── public │ └── favicon.ico └── tmp │ └── development_secret.txt ├── phi_attrs ├── configure_spec.rb ├── controllers │ ├── current_user_spec.rb │ ├── i18n_nested_controller_spec.rb │ └── i18n_sample_controller_spec.rb ├── delegations_spec.rb ├── exceptions_spec.rb ├── logger_spec.rb ├── phi_record │ ├── class__allow_phi_spec.rb │ ├── class__disallow_phi_spec.rb │ ├── class__phi_allowed_spec.rb │ ├── instance__allow_phi_spec.rb │ ├── instance__disallow_phi_spec.rb │ ├── instance__phi_allowed_spec.rb │ └── phi_wrapping_spec.rb └── version_spec.rb ├── phi_attrs_spec.rb ├── spec_helper.rb └── support ├── block_access_helpers.rb └── error_helpers.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Spec CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: Ruby ${{ matrix.ruby }} 9 | strategy: 10 | matrix: 11 | ruby: [3.1, 3.2, 3.3] 12 | env: 13 | RUBY_VERSION: ${{ matrix.ruby }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Ruby ${{ matrix.ruby }} 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ${{ matrix.ruby }} 20 | bundler-cache: true 21 | - name: Install dependencies 22 | run: | 23 | bundle exec appraisal install 24 | - name: Run rspec 25 | run: | 26 | bundle exec appraisal "rake dummy:db:create dummy:db:migrate && rspec" 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Gem 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: '3.1' 16 | bundler-cache: true 17 | - name: Release Gem 18 | if: contains(github.ref, 'refs/tags/v') 19 | env: 20 | RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}} 21 | TAG: ${{ github.event.release.tag_name }} 22 | run: | 23 | echo "Setting up gem credentials..." 24 | mkdir -p ~/.gem 25 | 26 | cat << EOF > ~/.gem/credentials 27 | --- 28 | :rubygems_api_key: ${RUBYGEMS_API_KEY} 29 | EOF 30 | 31 | chmod 0600 ~/.gem/credentials 32 | 33 | bundle exec rake build 34 | 35 | echo "Running gem release task..." 36 | gem push pkg/phi_attrs-${TAG#v}.gem 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | *Gemfile.lock 12 | .byebug_history 13 | 14 | /spec/dummy/tmp/ 15 | /spec/dummy/db/schema.rb 16 | *.sqlite* 17 | *.sqlite3* 18 | *.log 19 | .rspec_status 20 | gemfiles/*.gemfile.lock 21 | gemfiles/.bundle 22 | 23 | # Macs. Ugh. 24 | .DS_Store 25 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rails 2 | 3 | Rails: 4 | Enabled: true 5 | 6 | AllCops: 7 | Exclude: 8 | - 'bin/**/*' 9 | - 'gemfiles/**/*' 10 | - 'spec/dummy/db/schema.rb' 11 | NewCops: enable 12 | TargetRubyVersion: 2.7 13 | 14 | Gemspec/RequireMFA: 15 | Enabled: false 16 | 17 | Layout/IndentationWidth: 18 | Enabled: true 19 | 20 | Layout/LineLength: 21 | Exclude: 22 | - 'spec/**/*' 23 | - 'lib/phi_attrs/phi_record.rb' # TODO: RUBOCOP Cleanup exclusion 24 | Max: 140 25 | 26 | Lint/UnreachableCode: 27 | Exclude: 28 | - 'lib/phi_attrs/phi_record.rb' # TODO: RUBOCOP Cleanup exclusion 29 | 30 | Lint/UnusedMethodArgument: 31 | Exclude: 32 | - 'lib/phi_attrs.rb' # TODO: RUBOCOP Cleanup exclusion 33 | 34 | Metrics/AbcSize: 35 | Max: 30 36 | Exclude: 37 | - 'spec/internal/db/**/*' 38 | - 'lib/phi_attrs/phi_record.rb' # TODO: RUBOCOP Cleanup exclusion 39 | 40 | Metrics/BlockLength: 41 | Enabled: false 42 | 43 | Metrics/ClassLength: 44 | Max: 1500 45 | 46 | Metrics/CyclomaticComplexity: 47 | Exclude: 48 | - 'lib/phi_attrs/phi_record.rb' # TODO: RUBOCOP Cleanup exclusion 49 | 50 | Metrics/MethodLength: 51 | Exclude: 52 | - 'spec/**/*' 53 | - 'lib/phi_attrs/phi_record.rb' # TODO: RUBOCOP Cleanup exclusion 54 | Max: 20 55 | 56 | Metrics/ModuleLength: 57 | Exclude: 58 | - 'lib/phi_attrs/phi_record.rb' # TODO: RUBOCOP Cleanup exclusion 59 | 60 | Metrics/PerceivedComplexity: 61 | Exclude: 62 | - 'lib/phi_attrs/phi_record.rb' # TODO: RUBOCOP Cleanup exclusion 63 | 64 | Naming/PredicateName: 65 | Enabled: false 66 | 67 | Rails/DynamicFindBy: 68 | Exclude: 69 | - 'spec/spec_helper.rb' # TODO: RUBOCOP Cleanup exclusion 70 | 71 | # Style/BracesAroundHashParameters: 72 | # Enabled: false 73 | 74 | Style/ClassVars: 75 | Enabled: false 76 | 77 | Style/CommentedKeyword: 78 | Exclude: 79 | - 'spec/**/*' 80 | 81 | Style/ConditionalAssignment: 82 | Enabled: false 83 | 84 | Style/Documentation: 85 | Enabled: false 86 | 87 | Style/EmptyMethod: 88 | EnforcedStyle: expanded 89 | 90 | Style/SymbolArray: 91 | EnforcedStyle: brackets 92 | 93 | Style/RedundantReturn: 94 | Enabled: false 95 | 96 | Style/WordArray: 97 | MinSize: 4 98 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails_6.1' do 4 | gem 'rails', '~> 6.1' 5 | gem 'rspec', '~> 3.10' 6 | gem 'rspec-rails', '~> 5.1' 7 | gem 'sqlite3', '~> 1.5' 8 | end 9 | 10 | appraise 'rails_7.0' do 11 | gem 'rails', '~> 7.0' 12 | gem 'rspec', '~> 3.12' 13 | gem 'rspec-rails', '~> 6.0' 14 | gem 'sqlite3', '~> 1.5' 15 | end 16 | -------------------------------------------------------------------------------- /DISCLAIMER.txt: -------------------------------------------------------------------------------- 1 | DISCLAIMER 2 | ========== 3 | 4 | While access logging is part of maintaining a HIPAA compliant application, there are many requirements for building a HIPAA secure application which are not addressed by `phi_attrs`, and as such use of `phi_attrs` on its own does not ensure HIPAA Compliance. As such, this software is provided on an "as-is" basis, and Apsis Labs, LLP makes no warranties or assurances with regards to the HIPAA compliance of any application which makes use of `phi_attrs`. 5 | 6 | For further reading on how to ensure your application meets the HIPAA security standards, review the HHS Security Series Technical Safeguards and the Summary of the HIPAA Security Rule, in addition to consulting your compliance and legal counsel. 7 | 8 | Apsis Labs, LLP is not a law firm and does not provide legal advice. The information in this software does not constitute legal advice, nor does usage of this software create an attorney-client relationship. 9 | 10 | Apsis Labs, LLP is not a HIPAA covered entity, and usage of this software does not create a business associate relationship, nor does it enact a business associate agreement. 11 | 12 | --- 13 | 14 | 1. HHS Security Series Technical Safeguards: https://www.hhs.gov/sites/default/files/ocr/privacy/hipaa/administrative/securityrule/techsafeguards.pdf 15 | 2. Summary of the HIPAA Security Rule: https://www.hhs.gov/hipaa/for-professionals/security/laws-regulations/index.html -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG RUBY_VERSION=3.1.3 2 | 3 | FROM ruby:${RUBY_VERSION}-buster 4 | 5 | RUN apt-get update -qq && apt-get install -y --no-install-recommends \ 6 | build-essential \ 7 | git \ 8 | bash \ 9 | sqlite3 10 | 11 | ENV APP_HOME /app 12 | WORKDIR $APP_HOME 13 | 14 | COPY . $APP_HOME/ 15 | 16 | RUN gem update --system 17 | 18 | EXPOSE 3000 19 | 20 | CMD ["bash"] 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in phi_attrs.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Apsis Labs, LLP 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phi_attrs [![Gem Version](https://badge.fury.io/rb/phi_attrs.svg)](https://badge.fury.io/rb/phi_attrs) [![Spec CI](https://github.com/apsislabs/phi_attrs/workflows/Spec%20CI/badge.svg)](https://github.com/apsislabs/phi_attrs/actions) 2 | 3 | 4 | HIPAA compliant PHI access logging for Ruby on Rails. 5 | 6 | According to [HIPAA Security Rule](https://www.hhs.gov/hipaa/for-professionals/security/index.html) `§ 164.312(b)`, HIPAA covered entities are required to: 7 | 8 | > Implement hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information. 9 | 10 | The `phi_attrs` gem is intended to assist with implementing logging to comply with the access log requirements of `§ 164.308(a)(1)(ii)(D)`: 11 | 12 | > Information system activity review (Required). Implement procedures to regularly review records of information system activity, such as audit logs, access reports, and security incident tracking reports. 13 | 14 | To do so, `phi_attrs` extends `ActiveRecord` models by adding automated logging and explicit access control methods. The access control mechanism creates a separate `phi_access_log`. 15 | 16 | **Please Note:** while `phi_attrs` helps facilitate access logging, it still requires due diligence by developers, both in ensuring that models and attributes which store PHI are flagged with `phi_model` and that calls to `allow_phi!` properly attribute both a _unique_ identifier and an explicit reason for PHI access. 17 | 18 | **Please Note:** there are other aspects of building a HIPAA secure application which are not addressed by `phi_attrs`, and as such _use of `phi_attrs` on its own does not ensure HIPAA Compliance_. For further reading on how to ensure your application meets the HIPAA security standards, review the [HHS Security Series Technical Safeguards](https://www.hhs.gov/sites/default/files/ocr/privacy/hipaa/administrative/securityrule/techsafeguards.pdf) and [Summary of the HIPAA Security Rule](https://www.hhs.gov/hipaa/for-professionals/security/laws-regulations/index.html), in addition to consulting your compliance and legal counsel. 19 | 20 | ## Stability 21 | 22 | All versions of this project below `1.0.0` should be considered unstable beta software. Even minor-version updates may introduce breaking changes to the public API at this stage. We strongly suggest that you lock the installed version in your Gemfile to avoid unintended breaking updates. 23 | 24 | ## Installation 25 | 26 | Add this line to your application's Gemfile: 27 | 28 | ```ruby 29 | gem 'phi_attrs' 30 | ``` 31 | 32 | And then execute: 33 | 34 | $ bundle 35 | 36 | Or install it yourself as: 37 | 38 | $ gem install phi_attrs 39 | 40 | ## Initialize 41 | 42 | Create an initializer to configure the PHI log file location. Log rotation can be configured with log_shift_age and log_shift_size (disabled by default). 43 | 44 | Example: 45 | 46 | `config/initializers/phi_attrs.rb` 47 | 48 | ```ruby 49 | PhiAttrs.configure do |conf| 50 | conf.log_path = Rails.root.join("log", "phi_access_#{Rails.env}.log") 51 | conf.log_shift_age = 10 # how many logs to keep of `log_shift_size` or frequency to rotate ('daily', 'weekly' or 'monthly'). Disable rotation with 0 (default). 52 | conf.log_shift_size = 100.megabytes # size in bytes when using `log_shift_age` as a number 53 | end 54 | ``` 55 | 56 | ## Usage 57 | 58 | ```ruby 59 | class PatientInfo < ActiveRecord::Base 60 | phi_model 61 | 62 | exclude_from_phi :last_name 63 | include_in_phi :birthday 64 | 65 | def birthday 66 | Time.current 67 | end 68 | end 69 | ``` 70 | 71 | Access is granted on a instance level: 72 | 73 | ```ruby 74 | info = PatientInfo.new 75 | info.allow_phi!("allowed_user@example.com", "Customer Service") 76 | ``` 77 | 78 | *When using on an instance if you find it in a second place you will need to call allow_phi! again.* 79 | 80 | or a class: 81 | 82 | ```ruby 83 | PatientInfo.allow_phi!("allowed_user@example.com", "Customer Service") 84 | ``` 85 | 86 | As of version `0.1.5`, a block syntax is available. As above, this is available on both class and instance levels. 87 | 88 | Note the lack of a `!` at the end. These methods should not be used alongside the mutating (bang) methods! We recommend using the block syntax for tighter control. 89 | 90 | ```ruby 91 | patient = PatientInfo.find(params[:id]) 92 | patient.allow_phi('allowed_user@example.com', 'Display Customer Data') do 93 | @data = patient.to_json 94 | end # Access no longer allowed beyond this point 95 | ``` 96 | 97 | or a block on a class: 98 | 99 | ```ruby 100 | PatientInfo.allow_phi('allowed_user@example.com', 'Display Customer Data') do 101 | @data = PatientInfo.find(params[:id]).to_json 102 | end # Access no longer allowed beyond this point 103 | ``` 104 | 105 | ### Controlling What Is PHI 106 | 107 | When you include `phi_model` on your active record all fields except the id will be considered PHI. 108 | 109 | To remove fields from PHI tracking use `exclude_from_phi`: 110 | 111 | ```ruby 112 | # created_at and updated_at will be accessible as normal 113 | class PatientInfo < ActiveRecord::Base 114 | phi_model 115 | 116 | exclude_from_phi :created_at, :updated_at 117 | end 118 | ``` 119 | 120 | To add a method as PHI use `include_in_phi`. Include takes precedence over exclude so a method that appears in both will be considered PHI. 121 | 122 | ```ruby 123 | # birthday and node will throw PHIExceptions if accessed without permission 124 | class PatientInfo < ActiveRecord::Base 125 | phi_model 126 | 127 | include_in_phi :birthday, :note 128 | 129 | def birthday 130 | Time.current 131 | end 132 | 133 | attr_accessor :note 134 | end 135 | ``` 136 | 137 | #### Example Usage 138 | 139 | Example of `exclude_from_phi` and `include_in_phi` with inheritance. 140 | 141 | ```ruby 142 | class PatientInfo < ActiveRecord::Base 143 | phi_model 144 | end 145 | 146 | pi = PatientInfo.new(first_name: "Ash", last_name: "Ketchum") 147 | pi.created_at 148 | # PHIAccessException! 149 | pi.last_name 150 | # PHIAccessException! 151 | pi.allow_phi "Ash", "Testing PHI Attrs" { pi.last_name } 152 | # "Ketchum" 153 | ``` 154 | 155 | ```ruby 156 | class PatientInfoTwo < PatientInfo 157 | exclude_from_phi :created_at 158 | end 159 | 160 | pi = PatientInfoTwo.new(first_name: "Ash", last_name: "Ketchum") 161 | pi.created_at 162 | # current time 163 | pi.last_name 164 | # PHIAccessException! 165 | pi.allow_phi "Ash", "Testing PHI Attrs" { pi.last_name } 166 | # "Ketchum" 167 | ``` 168 | 169 | ```ruby 170 | class PatientInfoThree < PatientInfoTwo 171 | include_in_phi :created_at # Changed our mind 172 | end 173 | 174 | pi = PatientInfoThree.new(first_name: "Ash", last_name: "Ketchum") 175 | pi.created_at 176 | # PHIAccessException! 177 | pi.last_name 178 | # PHIAccessException! 179 | pi.allow_phi "Ash", "Testing PHI Attrs" { pi.last_name } 180 | # "Ketchum" 181 | ``` 182 | 183 | ### Extending PHI Access 184 | 185 | Sometimes you'll have a single mental model that is composed of several `ActiveRecord` models joined by association. In this case, instead of calling `allow_phi!` on all joined models, we expose a shorthand of extending PHI access to related models. 186 | 187 | ```ruby 188 | class PatientInfo < ActiveRecord::Base 189 | phi_model 190 | end 191 | 192 | class Patient < ActiveRecord::Base 193 | has_one :patient_info 194 | 195 | phi_model 196 | 197 | extend_phi_access :patient_info 198 | end 199 | 200 | patient = Patient.new 201 | patient.allow_phi!('user@example.com', 'reason') 202 | patient.patient_info.first_name 203 | ``` 204 | 205 | **NOTE:** This is not intended to be used on all relationships! Only those where you intend to grant implicit access based on access to another model. In this use case, we assume that allowed access to `Patient` implies allowed access to `PatientInfo`, and therefore does not require an additional `allow_phi!` check. There are no guaranteed safeguards against circular `extend_phi_access` calls! 206 | 207 | ### Check If PHI Access Is Allowed 208 | 209 | To check if PHI is allowed for a particular instance of a class call `phi_allowed?`. 210 | 211 | ```ruby 212 | patient = Patient.new 213 | patient.phi_allowed? # => false 214 | 215 | patient.allow_phi('user@example.com', 'reason') do 216 | patient.phi_allowed? # => true 217 | end 218 | 219 | patient.phi_allowed? # => false 220 | 221 | patient.allow_phi!('user@example.com', 'reason') 222 | patient.phi_allowed? # => true 223 | ``` 224 | 225 | This also works if access was granted at the class level: 226 | 227 | ```ruby 228 | patient = Patient.new 229 | patient.phi_allowed? # => false 230 | Patient.allow_phi!('user@example.com', 'reason') 231 | patient.phi_allowed? # => true 232 | ``` 233 | 234 | There is also a `phi_allowed?` check available to see at the class level. 235 | 236 | ```ruby 237 | Patient.phi_allowed? # => false 238 | Patient.allow_phi!('user@example.com', 'reason') 239 | Patient.phi_allowed? # => true 240 | ``` 241 | 242 | **Note that any instance level access grants will not change class level access:** 243 | 244 | ```ruby 245 | patient = Patient.new 246 | 247 | patient.phi_allowed? # => false 248 | Patient.phi_allowed? # => false 249 | 250 | patient.allow_phi!('user@example.com', 'reason') 251 | 252 | patient.phi_allowed? # => true 253 | Patient.phi_allowed? # => false 254 | ``` 255 | 256 | 257 | ### Revoking PHI Access 258 | 259 | You can remove access to PHI with `disallow_phi!`. Each `disallow_phi!` call removes all access granted by `allow_phi!` at that level (class or instance). 260 | 261 | At a class level: 262 | 263 | ```ruby 264 | Patient.disallow_phi! 265 | ``` 266 | 267 | Or at a instance level: 268 | 269 | ```ruby 270 | patient.disallow_phi! 271 | ``` 272 | 273 | * *If access is granted at both class and instance level you will need to call `disallow_phi!` twice, once for the instance and once for the class.* 274 | 275 | There is also a block syntax of `disallow_phi` for temporary suppression phi access to the class or instance level 276 | 277 | ```ruby 278 | patient = PatientInfo.find(params[:id]) 279 | patient.allow_phi!('allowed_user@example.com', 'Display Patient Data') 280 | patient.disallow_phi do 281 | @data = patient.to_json # PHIAccessException 282 | end # Access is allowed again beyond this point 283 | ``` 284 | 285 | or a block level on a class: 286 | 287 | ```ruby 288 | PatientInfo.allow_phi!('allowed_user@example.com', 'Display Patient Data') 289 | PatientInfo.disallow_phi do 290 | @data = PatientInfo.find(params[:id]).to_json # PHIAccessException 291 | end # Access is allowed again beyond this point 292 | ``` 293 | 294 | * *Reminder instance level `phi_allow` will take precedent over a class level `disallow_phi`* 295 | 296 | ### Manual PHI Access Logging 297 | 298 | If you aren't using `phi_record` you can still use `phi_attrs` to manually log phi access in your application. Where ever you are granting PHI access call: 299 | 300 | ```ruby 301 | user = 'user@example.com' 302 | message = 'accessed list of all patients' 303 | PhiAttrs.log_phi_access(user, message) 304 | ``` 305 | 306 | ### Reason Translations 307 | 308 | It can get cumbersome to pass around PHI Access reasons. PHI Attrs allows you to 309 | use your translations file to keep your code dry. If your translation file 310 | contains a reason for the combination of controller, action, and model you can 311 | skip passing `reason`: 312 | 313 | ```ruby 314 | module Admin 315 | class PatientDashboardController < ApplicationController 316 | def expelliarmus 317 | patient_info.allow_phi(current_user) do 318 | # reason tries to use `phi.admin.patient_dashbaord.expelliarmus.patient_info` 319 | end 320 | end 321 | 322 | def leviosa 323 | patient_info.allow_phi(current_user) do 324 | # reason tries to use `phi.admin.patient_dashbaord.leviosa.patient_info` 325 | end 326 | end 327 | end 328 | end 329 | ``` 330 | 331 | The following `en.yml` file would work: 332 | 333 | ```yml 334 | en: 335 | phi: 336 | admin: 337 | patient_dashboard: 338 | expelliarmus: 339 | patient_info: "Patient Disarmed" 340 | leviosa: 341 | patient_info: "Patient Levitated" 342 | ``` 343 | 344 | If you have a typo in your en.yml file or you choose not to provide a translation 345 | for your phi reasons your code will fail with an ArgumentError. To assist you in 346 | debugging PHI Attrs will print a `:warn` message with the expected location for 347 | the missing translation. 348 | 349 | If you would like to change from `phi` to a custom location you can set the path in your initializer. 350 | 351 | ```ruby 352 | PhiAttrs.configure do |conf| 353 | conf.translation_prefix = 'custom_prefix' 354 | end 355 | ``` 356 | 357 | ### Default User 358 | 359 | Passing around the current user can clutter your code. PHI Attrs allows you to 360 | configure a controller method that will be called to get the currently logged in 361 | user: 362 | 363 | #### `config/initializers/phi_attrs.rb` 364 | 365 | ```ruby 366 | PhiAttrs.configure do |conf| 367 | conf.current_user_method = :user_email 368 | end 369 | ``` 370 | 371 | #### `app/controllers/home_controller.rb` 372 | 373 | ```ruby 374 | class ApplicationController < ActionController::Base 375 | private 376 | 377 | def user_email 378 | current_user&.email 379 | end 380 | end 381 | ``` 382 | 383 | With the above code, any call to `allow_phi` (that starts in a controller 384 | derived from ApplicationController) will use the result of `user_email` as the 385 | user argument of `allow_phi`. 386 | 387 | Note that if you have a default user, but choose not to use translations for 388 | reasons you'll have to pass `nil` as the user: 389 | 390 | ```ruby 391 | person_phi.allow_phi(nil, "Because I felt like looking at PHI") do 392 | # Allows PHI 393 | end 394 | ``` 395 | 396 | ### Request UUID 397 | 398 | It can be helpful to include the Rails request UUID to match up your general application 399 | logs to your PHI access logs. The following snippet will prepend your PHI access logs 400 | with the request UUID. 401 | 402 | #### `app/controllers/application_controller.rb` 403 | 404 | ```ruby 405 | around_action :tag_phi_log_with_request_id 406 | 407 | ... 408 | 409 | private 410 | 411 | def tag_phi_log_with_request_id 412 | PhiAttrs::Logger.logger.tagged("Request ID: #{request.uuid}") do 413 | yield 414 | end 415 | end 416 | ``` 417 | ## Best Practices 418 | 419 | * Mix and matching `instance`, `class` and `block` syntaxes for allowing/denying PHI is not recommended. 420 | * Sticking with one style in your application will make it easier to understand what access is granted and where. 421 | 422 | ## Development 423 | 424 | It is recommended to use the provided `docker-compose` environment for development to help ensure dependency consistency and code isolation from other projects you may be working on. 425 | 426 | ### Begin 427 | 428 | $ docker-compose up 429 | $ bin/ssh_to_container 430 | 431 | ### Tests 432 | 433 | Tests are written using [RSpec](https://rspec.info/) and are setup to use [Appraisal](https://github.com/thoughtbot/appraisal) to run tests over multiple rails versions. 434 | 435 | $ bin/run_tests 436 | or for individual tests: 437 | $ bin/ssh_to_container 438 | $ bundle exec appraisal rspec spec/path/to/spec.rb 439 | 440 | To run just a particular rails version: 441 | $ bundle exec appraisal rails_6.1 rspec 442 | $ bundle exec appraisal rails_7.0 rspec 443 | 444 | ### Console 445 | 446 | An interactive prompt that will allow you to experiment with the gem. 447 | 448 | $ bin/ssh_to_container 449 | $ bin/console 450 | 451 | ### Local Install 452 | 453 | Run `bin/setup` to install dependencies. Then, run `bundle exec appraisal rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 454 | 455 | To install this gem onto your local machine, run `bundle exec rake install`. 456 | 457 | ### Versioning 458 | 459 | To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 460 | 461 | 462 | ## Contributing 463 | 464 | Bug reports and pull requests are welcome on GitHub at https://github.com/apsislabs/phi_attrs. 465 | 466 | Any PRs should be accompanied with documentation in `README.md`. 467 | 468 | ### Releasing 469 | 470 | * Squash and merge your PR, including a bump to `lib/phi_attrs/version.rb` 471 | * Draft a new release, creating a new tag with the new version number from `version.rb`, i.e. `v0.3.2` 472 | * Auto-generate release notes, add any context if necessary 473 | * Publish release; release will be automatically built and published to rubygems 474 | 475 | ## License 476 | 477 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 478 | 479 | ## Legal Disclaimer 480 | 481 | Apsis Labs, LLP is not a law firm and does not provide legal advice. The information in this repo and software does not constitute legal advice, nor does usage of this software create an attorney-client relationship. 482 | 483 | Apsis Labs, LLP is not a HIPAA covered entity, and usage of this software does not create a business associate relationship, nor does it enact a business associate agreement. 484 | 485 | [Full Disclaimer](./DISCLAIMER.txt) 486 | 487 | --- 488 | 489 | # Built by Apsis 490 | 491 | [![apsis](https://s3-us-west-2.amazonaws.com/apsiscdn/apsis.png)](https://www.apsis.io) 492 | 493 | `phi_attrs` was built by Apsis Labs. We love sharing what we build! Check out our [other libraries on Github](https://github.com/apsislabs), and if you like our work you can [hire us](https://www.apsis.io) to build your vision. 494 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | require 'bundler/gem_tasks' 6 | 7 | require 'rake' 8 | 9 | namespace :dummy do 10 | require_relative 'spec/dummy/application' 11 | Dummy::Application.load_tasks 12 | end 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'phi_attrs' 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 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 | 20 | IRB.start(__FILE__) 21 | -------------------------------------------------------------------------------- /bin/helpers/docker: -------------------------------------------------------------------------------- 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 13 | -------------------------------------------------------------------------------- /bin/rubo_fix: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | source bin/helpers/docker 3 | runOnDocker ruby3 4 | 5 | echo "== Starting rubocop ==" 6 | bundle exec rubocop --format worst --format simple --format offenses --autocorrect 7 | -------------------------------------------------------------------------------- /bin/run_container: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | CONTAINER=$1 4 | if [[ -z $CONTAINER ]]; then 5 | CONTAINER='ruby3' 6 | fi 7 | 8 | docker-compose run $CONTAINER bash 9 | -------------------------------------------------------------------------------- /bin/run_tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | source bin/helpers/docker 3 | runOnDocker ruby3 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 20 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | gem install bundler -v $BUNDLER_VERSION 7 | 8 | bundle check || bundle install 9 | bundle exec appraisal install 10 | bundle exec appraisal rake dummy:db:drop dummy:db:create dummy:db:migrate 11 | -------------------------------------------------------------------------------- /bin/ssh_to_container: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | CONTAINER=$1 4 | if [[ -z $CONTAINER ]]; then 5 | CONTAINER='ruby3' 6 | fi 7 | 8 | docker-compose exec -it $CONTAINER bash 9 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | 6 | Bundler.require :default, :development 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | ruby2: 5 | build: 6 | context: . 7 | args: 8 | - RUBY_VERSION=2.7.0 9 | volumes: 10 | - bundle_cache_2:/bundle 11 | - .:/app 12 | environment: 13 | - BUNDLER_VERSION=2.2.33 14 | - BUNDLE_JOBS=5 15 | - BUNDLE_PATH=/bundle 16 | - BUNDLE_BIN=/bundle/bin 17 | - GEM_HOME=/bundle 18 | - DOCKER_CONTAINER=1 19 | command: 20 | - docker/start.sh 21 | ruby3: 22 | build: . 23 | volumes: 24 | - bundle_cache_3:/bundle 25 | - .:/app 26 | environment: 27 | - BUNDLER_VERSION=2.4.0 28 | - BUNDLE_JOBS=5 29 | - BUNDLE_PATH=/bundle 30 | - BUNDLE_BIN=/bundle/bin 31 | - GEM_HOME=/bundle 32 | - DOCKER_CONTAINER=1 33 | command: 34 | - docker/start.sh 35 | 36 | volumes: 37 | bundle_cache_2: 38 | bundle_cache_3: 39 | -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "Beginning Setup" 4 | /app/bin/setup 5 | 6 | echo "Environment Ready" 7 | tail -f /etc/hosts 8 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.1" 6 | gem "rspec", "~> 3.10" 7 | gem "rspec-rails", "~> 5.1" 8 | gem "sqlite3", "~> 1.5" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 7.0" 6 | gem "rspec", "~> 3.12" 7 | gem "rspec-rails", "~> 6.0" 8 | gem "sqlite3", "~> 1.5" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /lib/phi_attrs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails' 4 | require 'active_support' 5 | require 'request_store' 6 | 7 | require 'phi_attrs/version' 8 | require 'phi_attrs/configure' 9 | require 'phi_attrs/railtie' if defined?(Rails) 10 | require 'phi_attrs/formatter' 11 | require 'phi_attrs/logger' 12 | require 'phi_attrs/exceptions' 13 | require 'phi_attrs/phi_record' 14 | 15 | module PhiAttrs 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 21 | 22 | module Model 23 | def phi_model(with: nil, except: nil) 24 | include PhiRecord 25 | end 26 | end 27 | 28 | module Controller 29 | extend ActiveSupport::Concern 30 | 31 | included do 32 | before_action :record_i18n_data 33 | end 34 | 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) 43 | 44 | RequestStore.store[:phi_attrs_current_user] = send(PhiAttrs.current_user_method) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/phi_attrs/configure.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PhiAttrs 4 | @@log_path = nil 5 | @@log_shift_age = 0 # Default to disabled 6 | @@log_shift_size = 1_048_576 # 1MB - Default from logger class 7 | @@current_user_method = nil 8 | @@translation_prefix = 'phi' 9 | 10 | def self.configure 11 | yield self if block_given? 12 | end 13 | 14 | def self.log_path 15 | @@log_path 16 | end 17 | 18 | def self.log_path=(value) 19 | @@log_path = value 20 | end 21 | 22 | def self.log_shift_age 23 | @@log_shift_age 24 | end 25 | 26 | def self.log_shift_age=(value) 27 | @@log_shift_age = value 28 | end 29 | 30 | def self.log_shift_size 31 | @@log_shift_size 32 | end 33 | 34 | def self.log_shift_size=(value) 35 | @@log_shift_size = value 36 | end 37 | 38 | def self.translation_prefix 39 | @@translation_prefix 40 | end 41 | 42 | def self.translation_prefix=(value) 43 | @@translation_prefix = value 44 | end 45 | 46 | def self.current_user_method 47 | @@current_user_method 48 | end 49 | 50 | def self.current_user_method=(value) 51 | @@current_user_method = value 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/phi_attrs/exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PhiAttrs 4 | module Exceptions 5 | class PhiAccessException < StandardError 6 | TAG = 'UNAUTHORIZED ACCESS' 7 | 8 | def initialize(msg) 9 | PhiAttrs::Logger.tagged(TAG) { PhiAttrs::Logger.error(msg) } 10 | super 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/phi_attrs/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PhiAttrs 4 | FORMAT = "%s %5s: %s\n" 5 | 6 | # https://github.com/ruby/ruby/blob/trunk/lib/logger.rb#L587 7 | class Formatter < ::Logger::Formatter 8 | def call(severity, timestamp, _progname, msg) 9 | format(FORMAT, format_datetime(timestamp), severity, msg2str(msg)) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/phi_attrs/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PhiAttrs 4 | PHI_ACCESS_LOG_TAG = 'PHI Access Log' 5 | 6 | class Logger 7 | class << self 8 | def logger 9 | unless @logger 10 | logger = ActiveSupport::Logger.new(PhiAttrs.log_path, PhiAttrs.log_shift_age, PhiAttrs.log_shift_size) 11 | logger.formatter = Formatter.new 12 | @logger = ActiveSupport::TaggedLogging.new(logger) 13 | end 14 | @logger 15 | end 16 | 17 | delegate :debug, :info, :warn, :error, :fatal, :tagged, to: :logger 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/phi_attrs/phi_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 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 10 | module PhiRecord 11 | extend ActiveSupport::Concern 12 | 13 | included do 14 | class_attribute :__phi_exclude_methods 15 | class_attribute :__phi_include_methods 16 | class_attribute :__phi_extend_methods 17 | class_attribute :__phi_methods_wrapped 18 | class_attribute :__phi_methods_to_extend 19 | 20 | after_initialize :wrap_phi 21 | 22 | # These have to default to an empty array 23 | self.__phi_methods_wrapped = [] 24 | self.__phi_methods_to_extend = [] 25 | end 26 | 27 | class_methods do 28 | # Set methods to be excluded from PHI access logging. 29 | # 30 | # @param [Array] *methods Any number of methods to exclude 31 | # 32 | # @example 33 | # exclude_from_phi :foo, :bar 34 | # 35 | def exclude_from_phi(*methods) 36 | self.__phi_exclude_methods = methods.map(&:to_s) 37 | end 38 | 39 | # Set methods to be explicitly included in PHI access logging. 40 | # 41 | # @param [Array] *methods Any number of methods to include 42 | # 43 | # @example 44 | # include_in_phi :foo, :bar 45 | # 46 | def include_in_phi(*methods) 47 | self.__phi_include_methods = methods.map(&:to_s) 48 | end 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] *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 | # 61 | def extend_phi_access(*methods) 62 | self.__phi_extend_methods = methods.map(&:to_s) 63 | end 64 | 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 | }) 85 | 86 | PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do 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 | # Enable PHI access for any instance of this class in the block given only 116 | # returning whatever the block returns. 117 | # 118 | # @param [String] user_id A unique identifier for the person accessing the PHI 119 | # @param [String] reason The reason for accessing PHI 120 | # @param [collection of PhiRecord] allow_only Specific PhiRecords to allow access to 121 | # &block [block] The block in which PHI access is allowed for the class 122 | # 123 | # @example 124 | # results = Foo.allow_phi('user@example.com', 'viewing patient record') do 125 | # Foo.search(params) 126 | # end 127 | # 128 | # @example 129 | # loaded_foo = Foo.allow_phi('user@example.com', 'exporting patient list', allow_only: list_of_foos) do 130 | # Bar.find_by(foo: list_of_foos).include(:foo) 131 | # end 132 | # 133 | def get_phi(user_id = nil, reason = nil, allow_only: nil) 134 | raise ArgumentError, 'block required' unless block_given? 135 | 136 | if allow_only.present? 137 | raise ArgumentError, 'allow_only must be iterable with each' unless allow_only.respond_to?(:each) 138 | raise ArgumentError, "allow_only must all be `#{name}` objects" unless allow_only.all? { |t| t.is_a?(self) } 139 | raise ArgumentError, 'allow_only must all have `allow_phi!` methods' unless allow_only.all? { |t| t.respond_to?(:allow_phi!) } 140 | end 141 | 142 | # Save this so we don't revoke access previously extended outside the block 143 | frozen_instances = __instances_with_extended_phi.index_with { |obj| obj.instance_variable_get(:@__phi_relations_extended).clone } 144 | 145 | begin 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 | return yield 153 | ensure 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 169 | end 170 | end 171 | end 172 | 173 | # Explicitly disallow phi access in a specific area of code. This does not 174 | # play nicely with the mutating versions of `allow_phi!` and `disallow_phi!` 175 | # 176 | # At the moment, this doesn't work at all, as the instance won't 177 | # necessarily look at the class-level stack when determining if PHI is allowed. 178 | # 179 | # &block [block] The block in which PHI access is explicitly disallowed. 180 | # 181 | # @example 182 | # # PHI Access Disallowed 183 | # Foo.disallow_phi 184 | # # PHI Access *Still* Disallowed 185 | # end 186 | # # PHI Access *Still, still* Disallowed 187 | # Foo.allow_phi!('user@example.com', 'viewing patient record') 188 | # # PHI Access Allowed 189 | # Foo.disallow_phi do 190 | # # PHI Access Disallowed 191 | # end 192 | # # PHI Access Allowed Again 193 | def disallow_phi 194 | raise ArgumentError, 'block required. use disallow_phi! without block' unless block_given? 195 | 196 | __phi_stack.push({ 197 | phi_access_allowed: false 198 | }) 199 | 200 | yield if block_given? 201 | 202 | __phi_stack.pop 203 | end 204 | 205 | # Revoke all PHI access for this class, if enabled by PhiRecord#allow_phi! 206 | # 207 | # @example 208 | # Foo.disallow_phi! 209 | # 210 | def disallow_phi! 211 | raise ArgumentError, 'block not allowed. use disallow_phi with block' if block_given? 212 | 213 | message = __phi_stack.present? ? "PHI access disabled for #{__user_id_string(__phi_stack)}" : 'PHI access disabled. No class level access was granted.' 214 | 215 | __reset_phi_stack 216 | 217 | PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do 218 | PhiAttrs::Logger.info(message) 219 | end 220 | end 221 | 222 | # Revoke last PHI access for this class, if enabled by PhiRecord#allow_phi! 223 | # 224 | # @example 225 | # Foo.disallow_last_phi! 226 | # 227 | def disallow_last_phi! 228 | raise ArgumentError, 'block not allowed' if block_given? 229 | 230 | removed_access = __phi_stack.pop 231 | message = removed_access.present? ? "PHI access disabled for #{removed_access[:user_id]}" : 'PHI access disabled. No class level access was granted.' 232 | 233 | PhiAttrs::Logger.tagged(PHI_ACCESS_LOG_TAG, name) do 234 | PhiAttrs::Logger.info(message) 235 | end 236 | end 237 | 238 | # Whether PHI access is allowed for this class 239 | # 240 | # @example 241 | # Foo.phi_allowed? 242 | # 243 | # @return [Boolean] whether PHI access is allowed for this instance 244 | # 245 | def phi_allowed? 246 | __phi_stack.present? && __phi_stack[-1][:phi_access_allowed] 247 | end 248 | 249 | def __instances_with_extended_phi 250 | RequestStore.store[:phi_instances_with_extended_phi] ||= Set.new 251 | end 252 | 253 | def __phi_stack 254 | RequestStore.store[:phi_access] ||= {} 255 | RequestStore.store[:phi_access][name] ||= [] 256 | end 257 | 258 | def __reset_phi_stack 259 | RequestStore.store[:phi_access] ||= {} 260 | RequestStore.store[:phi_access][name] = [] 261 | end 262 | 263 | def __user_id_string(access_list) 264 | access_list ||= [] 265 | access_list.map { |c| "'#{c[:user_id]}'" }.join(',') 266 | end 267 | 268 | def current_user 269 | RequestStore.store[:phi_attrs_current_user] 270 | end 271 | 272 | def i18n_reason 273 | controller = RequestStore.store[:phi_attrs_controller] 274 | action = RequestStore.store[:phi_attrs_action] 275 | 276 | return nil if controller.blank? || action.blank? 277 | 278 | i18n_path = [PhiAttrs.translation_prefix] + __path_to_controller_and_action(controller, action) 279 | i18n_path.push(*__path_to_class) 280 | i18n_key = i18n_path.join('.') 281 | 282 | return I18n.t(i18n_key) if I18n.exists?(i18n_key) 283 | 284 | locale = I18n.locale || I18n.default_locale 285 | 286 | PhiAttrs::Logger.warn "No #{locale} PHI Reason found for #{i18n_key}" 287 | end 288 | 289 | def __path_to_controller_and_action(controller, action) 290 | module_paths = controller.underscore.split('/') 291 | class_name_parts = module_paths.pop.split('_') 292 | class_name_parts.pop if class_name_parts[-1] == 'controller' 293 | module_paths.push(class_name_parts.join('_'), action) 294 | end 295 | 296 | def __path_to_class 297 | module_paths = name.underscore.split('/') 298 | class_name_parts = module_paths.pop.split('_') 299 | module_paths.push(class_name_parts.join('_')) 300 | end 301 | end 302 | 303 | # Get all method names to be wrapped with PHI access logging 304 | # 305 | # @return [Array] the method names to be wrapped with PHI access logging 306 | # 307 | def __phi_wrapped_methods 308 | excluded_methods = self.class.__phi_exclude_methods.to_a 309 | included_methods = self.class.__phi_include_methods.to_a 310 | 311 | attribute_names - excluded_methods + included_methods - [self.class.primary_key] 312 | end 313 | 314 | # Get all method names to be wrapped with PHI access extension 315 | # 316 | # @return [Array] the method names to be wrapped with PHI access extension 317 | # 318 | def __phi_extended_methods 319 | self.class.__phi_extend_methods.to_a 320 | end 321 | 322 | # Enable PHI access for a single instance of this class. 323 | # 324 | # @param [String] user_id A unique identifier for the person accessing the PHI 325 | # @param [String] reason The reason for accessing PHI 326 | # 327 | # @example 328 | # foo = Foo.find(1) 329 | # foo.allow_phi!('user@example.com', 'viewing patient record') 330 | # 331 | def allow_phi!(user_id = nil, reason = nil) 332 | raise ArgumentError, 'block not allowed. use allow_phi with block' if block_given? 333 | 334 | user_id ||= self.class.current_user 335 | reason ||= self.class.i18n_reason 336 | raise ArgumentError, 'user_id and reason cannot be blank' if user_id.blank? || reason.blank? 337 | 338 | PhiAttrs::Logger.tagged(*phi_log_keys) do 339 | @__phi_access_stack.push({ 340 | phi_access_allowed: true, 341 | user_id: user_id, 342 | reason: reason 343 | }) 344 | 345 | PhiAttrs::Logger.info("PHI Access Enabled for '#{user_id}': #{reason}") 346 | end 347 | end 348 | 349 | # Enable PHI access for a single instance of this class inside the block. 350 | # Nested calls to allow_phi will log once per nested call 351 | # 352 | # @param [String] user_id A unique identifier for the person accessing the PHI 353 | # @param [String] reason The reason for accessing PHI 354 | # @yield The block in which phi access is allowed 355 | # 356 | # @example 357 | # foo = Foo.find(1) 358 | # foo.allow_phi('user@example.com', 'viewing patient record') do 359 | # # PHI Access Allowed Here 360 | # end 361 | # # PHI Access Disallowed Here 362 | # 363 | def allow_phi(user_id = nil, reason = nil, &block) 364 | get_phi(user_id, reason, &block) 365 | return 366 | end 367 | 368 | # Enable PHI access for a single instance of this class inside the block. 369 | # Returns whatever is returned from the block. 370 | # Nested calls to get_phi will log once per nested call 371 | # s 372 | # @param [String] user_id A unique identifier for the person accessing the PHI 373 | # @param [String] reason The reason for accessing PHI 374 | # @yield The block in which phi access is allowed 375 | # 376 | # @return PHI 377 | # 378 | # @example 379 | # foo = Foo.find(1) 380 | # phi_data = foo.get_phi('user@example.com', 'viewing patient record') do 381 | # foo.phi_field 382 | # end 383 | # 384 | def get_phi(user_id = nil, reason = nil) 385 | raise ArgumentError, 'block required' unless block_given? 386 | 387 | extended_instances = @__phi_relations_extended.clone 388 | begin 389 | allow_phi!(user_id, reason) 390 | 391 | return yield 392 | ensure 393 | new_extensions = @__phi_relations_extended - extended_instances 394 | disallow_last_phi!(preserve_extensions: true) 395 | revoke_extended_phi!(new_extensions) if new_extensions.any? 396 | end 397 | end 398 | 399 | # Revoke all PHI access for a single instance of this class. 400 | # 401 | # @example 402 | # foo = Foo.find(1) 403 | # foo.disallow_phi! 404 | # 405 | def disallow_phi! 406 | raise ArgumentError, 'block not allowed. use disallow_phi with block' if block_given? 407 | 408 | PhiAttrs::Logger.tagged(*phi_log_keys) do 409 | removed_access_for = self.class.__user_id_string(@__phi_access_stack) 410 | 411 | revoke_extended_phi! 412 | @__phi_access_stack = [] 413 | 414 | message = removed_access_for.present? ? "PHI access disabled for #{removed_access_for}" : 'PHI access disabled. No instance level access was granted.' 415 | PhiAttrs::Logger.info(message) 416 | end 417 | end 418 | 419 | # Dissables PHI access for a single instance of this class inside the block. 420 | # Nested calls to allow_phi will log once per nested call 421 | # 422 | # @param [String] user_id A unique identifier for the person accessing the PHI 423 | # @param [String] reason The reason for accessing PHI 424 | # @yield The block in which phi access is allowed 425 | # 426 | # @example 427 | # foo = Foo.find(1) 428 | # foo.allow_phi('user@example.com', 'viewing patient record') do 429 | # # PHI Access Allowed Here 430 | # end 431 | # # PHI Access Disallowed Here 432 | # 433 | def disallow_phi 434 | raise ArgumentError, 'block required. use disallow_phi! without block' unless block_given? 435 | 436 | add_disallow_flag! 437 | add_disallow_flag_to_extended_phi! 438 | 439 | yield if block_given? 440 | 441 | remove_disallow_flag_from_extended_phi! 442 | remove_disallow_flag! 443 | end 444 | 445 | # Revoke last PHI access for a single instance of this class. 446 | # 447 | # @example 448 | # foo = Foo.find(1) 449 | # foo.disallow_last_phi! 450 | # 451 | def disallow_last_phi!(preserve_extensions: false) 452 | raise ArgumentError, 'block not allowed' if block_given? 453 | 454 | PhiAttrs::Logger.tagged(*phi_log_keys) do 455 | removed_access = @__phi_access_stack.pop 456 | 457 | revoke_extended_phi! unless preserve_extensions 458 | message = removed_access.present? ? "PHI access disabled for #{removed_access[:user_id]}" : 'PHI access disabled. No instance level access was granted.' 459 | PhiAttrs::Logger.info(message) 460 | end 461 | end 462 | 463 | # The unique identifier for whom access has been allowed on this instance. 464 | # This is what was passed in when PhiRecord#allow_phi! was called. 465 | # 466 | # @return [String] the user_id passed in to allow_phi! 467 | # 468 | def phi_allowed_by 469 | phi_context[:user_id] 470 | end 471 | 472 | # The access reason for allowing access to this instance. 473 | # This is what was passed in when PhiRecord#allow_phi! was called. 474 | # 475 | # @return [String] the reason passed in to allow_phi! 476 | # 477 | def phi_access_reason 478 | phi_context[:reason] 479 | end 480 | 481 | # Whether PHI access is allowed for a single instance of this class 482 | # 483 | # @example 484 | # foo = Foo.find(1) 485 | # foo.phi_allowed? 486 | # 487 | # @return [Boolean] whether PHI access is allowed for this instance 488 | # 489 | def phi_allowed? 490 | !phi_context.nil? && phi_context[:phi_access_allowed] 491 | end 492 | 493 | # Require phi access. Raises an error pre-emptively if it has not been granted. 494 | # 495 | # @example 496 | # def use_phi(patient_record) 497 | # patient_record.require_phi! 498 | # # ...use PHI Freely 499 | # end 500 | # 501 | def require_phi! 502 | raise PhiAccessException, 'PHI Access required, please call allow_phi or allow_phi! first' unless phi_allowed? 503 | end 504 | 505 | def reload 506 | @__phi_relations_extended.clear 507 | super 508 | end 509 | 510 | protected 511 | 512 | # Adds a disallow phi flag to instance internal stack. 513 | # @private since subject to change 514 | def add_disallow_flag! 515 | @__phi_access_stack.push({ 516 | phi_access_allowed: false 517 | }) 518 | end 519 | 520 | # removes the last item in instance internal stack. 521 | # @private since subject to change 522 | def remove_disallow_flag! 523 | @__phi_access_stack.pop 524 | end 525 | 526 | private 527 | 528 | # Entry point for wrapping methods with PHI access logging. This is called 529 | # by an `after_initialize` hook from ActiveRecord. 530 | # 531 | # @private 532 | # 533 | def wrap_phi 534 | # Disable PHI access by default 535 | @__phi_access_stack = [] 536 | @__phi_methods_extended = Set.new 537 | @__phi_relations_extended = Set.new 538 | 539 | # Wrap attributes with PHI Logger and Access Control 540 | __phi_wrapped_methods.each { |m| phi_wrap_method(m) } 541 | __phi_extended_methods.each { |m| phi_wrap_extension(m) } 542 | end 543 | 544 | # Log Key for an instance of this class. If the instance is persisted in the 545 | # database, then it is the primary key; otherwise it is the Ruby object_id 546 | # in memory. 547 | # 548 | # This is used by the tagged logger for tagging all log entries to find 549 | # the underlying model. 550 | # 551 | # @private 552 | # 553 | # @return [Array] log key for an instance of this class 554 | # 555 | def phi_log_keys 556 | @__phi_log_id = persisted? ? "Key: #{attributes[self.class.primary_key]}" : "Object: #{object_id}" 557 | @__phi_log_keys = [PHI_ACCESS_LOG_TAG, self.class.name, @__phi_log_id] 558 | end 559 | 560 | def phi_context 561 | instance_phi_context || class_phi_context 562 | end 563 | 564 | def instance_phi_context 565 | @__phi_access_stack && @__phi_access_stack[-1] 566 | end 567 | 568 | def class_phi_context 569 | self.class.__phi_stack[-1] 570 | end 571 | 572 | # The unique identifiers for everything with access allowed on this instance. 573 | # 574 | # @private 575 | # 576 | # @return String of all the user_id's passed in to allow_phi! 577 | # 578 | def all_phi_allowed_by 579 | self.class.__user_id_string(all_phi_context) 580 | end 581 | 582 | def all_phi_context 583 | (@__phi_access_stack || []) + (self.class.__phi_stack || []) 584 | end 585 | 586 | def all_phi_context_logged? 587 | all_phi_context.all? { |v| v[:logged] } 588 | end 589 | 590 | def set_all_phi_context_logged 591 | all_phi_context.each { |c| c[:logged] = true } 592 | end 593 | 594 | # Core logic for wrapping methods in PHI access logging and access restriction. 595 | # 596 | # This method takes a single method name, and creates a new method using 597 | # define_method; once this method is defined, the original method name 598 | # is aliased to the new method, and the original method is renamed to a 599 | # known key. 600 | # 601 | # @private 602 | # 603 | # @example 604 | # Foo::phi_wrap_method(:bar) 605 | # 606 | # foo = Foo.find(1) 607 | # foo.bar # => raises PHI Access Exception 608 | # 609 | # foo.allow_phi!('user@example.com', 'testing') 610 | # 611 | # foo.bar # => returns original value of Foo#bar 612 | # 613 | # # defines two new methods: 614 | # # __bar_phi_wrapped 615 | # # __bar_phi_unwrapped 616 | # # 617 | # # After these methods are defined 618 | # # an alias chain is created that 619 | # # roughly maps: 620 | # # 621 | # # bar => __bar_phi_wrapped => __bar_phi_unwrapped 622 | # # 623 | # # This ensures that all calls to Foo#bar pass 624 | # # through access logging. 625 | # 626 | def phi_wrap_method(method_name) 627 | unless respond_to?(method_name) 628 | PhiAttrs::Logger.warn("#{self.class.name} tried to wrap non-existent method (#{method_name})") 629 | return 630 | end 631 | return if self.class.__phi_methods_wrapped.include? method_name 632 | 633 | wrapped_method = :"__#{method_name}_phi_wrapped" 634 | unwrapped_method = :"__#{method_name}_phi_unwrapped" 635 | 636 | self.class.send(:define_method, wrapped_method) do |*args, **kwargs, &block| 637 | PhiAttrs::Logger.tagged(*phi_log_keys) do 638 | unless phi_allowed? 639 | raise PhiAttrs::Exceptions::PhiAccessException, "Attempted PHI access for #{self.class.name} #{@__phi_user_id}" 640 | end 641 | 642 | unless all_phi_context_logged? 643 | PhiAttrs::Logger.info("#{self.class.name} access by [#{all_phi_allowed_by}]. Triggered by method: #{method_name}") 644 | set_all_phi_context_logged 645 | end 646 | 647 | send(unwrapped_method, *args, **kwargs, &block) 648 | end 649 | end 650 | 651 | # method_name => wrapped_method => unwrapped_method 652 | self.class.send(:alias_method, unwrapped_method, method_name) 653 | self.class.send(:alias_method, method_name, wrapped_method) 654 | 655 | self.class.__phi_methods_wrapped << method_name 656 | end 657 | 658 | # Core logic for wrapping methods in PHI access extensions. Almost 659 | # functionally equivalent to the phi_wrap_method call above, 660 | # this method doesn't add any logging or access restriction, but 661 | # simply proxies the PhiRecord#allow_phi! call. 662 | # 663 | # @private 664 | # 665 | def phi_wrap_extension(method_name) 666 | raise NameError, "Undefined relationship in `extend_phi_access`: #{method_name}" unless respond_to?(method_name) 667 | return if self.class.__phi_methods_to_extend.include? method_name 668 | 669 | wrapped_method = wrapped_extended_name(method_name) 670 | unwrapped_method = unwrapped_extended_name(method_name) 671 | 672 | self.class.send(:define_method, wrapped_method) do |*args, **kwargs, &block| 673 | relation = send(unwrapped_method, *args, **kwargs, &block) 674 | 675 | if phi_allowed? && (relation.present? && relation_klass(relation).included_modules.include?(PhiRecord)) 676 | relations = relation.is_a?(Enumerable) ? relation : [relation] 677 | relations.each do |r| 678 | r.allow_phi!(phi_allowed_by, phi_access_reason) unless @__phi_relations_extended.include?(r) 679 | end 680 | @__phi_relations_extended.merge(relations) 681 | self.class.__instances_with_extended_phi.add(self) 682 | end 683 | 684 | relation 685 | end 686 | 687 | # method_name => wrapped_method => unwrapped_method 688 | self.class.send(:alias_method, unwrapped_method, method_name) 689 | self.class.send(:alias_method, method_name, wrapped_method) 690 | 691 | self.class.__phi_methods_to_extend << method_name 692 | end 693 | 694 | # Revoke PHI access for all `extend`ed relations (or only those given) 695 | def revoke_extended_phi!(relations = nil) 696 | relations ||= @__phi_relations_extended 697 | relations.each do |relation| 698 | relation.disallow_last_phi! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord) 699 | end 700 | @__phi_relations_extended.subtract(relations) 701 | end 702 | 703 | # Adds a disallow PHI access to the stack for block syntax for all `extend`ed relations (or only those given) 704 | def add_disallow_flag_to_extended_phi!(relations = nil) 705 | relations ||= @__phi_relations_extended 706 | relations.each do |relation| 707 | relation.add_disallow_flag! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord) 708 | end 709 | end 710 | 711 | # Adds a disallow PHI access to the stack for all for all `extend`ed relations (or only those given) 712 | def remove_disallow_flag_from_extended_phi!(relations = nil) 713 | relations ||= @__phi_relations_extended 714 | relations.each do |relation| 715 | relation.remove_disallow_flag! if relation.present? && relation_klass(relation).included_modules.include?(PhiRecord) 716 | end 717 | end 718 | 719 | def relation_klass(rel) 720 | return rel.klass if rel.is_a?(ActiveRecord::Relation) 721 | return rel.first.class if rel.is_a?(Enumerable) 722 | 723 | return rel.class 724 | end 725 | 726 | def wrapped_extended_name(method_name) 727 | :"__#{method_name}_phi_access_extended" 728 | end 729 | 730 | def unwrapped_extended_name(method_name) 731 | :"__#{method_name}_phi_access_original" 732 | end 733 | end 734 | end 735 | -------------------------------------------------------------------------------- /lib/phi_attrs/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'phi_attrs' 4 | require 'rails' 5 | 6 | module PhiAttrs 7 | class Railtie < Rails::Railtie 8 | initializer 'phi_attrs.initialize' do |_app| 9 | ActiveSupport.on_load(:active_record) do 10 | ActiveSupport.on_load(:active_record) { extend PhiAttrs::Model } 11 | ActiveSupport.on_load(:action_controller) { include PhiAttrs::Controller } 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/phi_attrs/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/expectations' 4 | 5 | DO_NOT_SPECIFY = 'do not specify `allowed_by` or `with_access_reason` for negated `allow_phi_access`' 6 | 7 | RSpec::Matchers.define :allow_phi_access do 8 | match do |result| 9 | @allowed = result.phi_allowed? 10 | @user_id_matches = @user_id.nil? || @user_id == result.phi_allowed_by 11 | @reason_matches = @reason.nil? || @reason == result.phi_access_reason 12 | 13 | @allowed && @user_id_matches && @reason_matches 14 | end 15 | 16 | match_when_negated do |result| 17 | raise ArgumentError, DO_NOT_SPECIFY unless @user_id.nil? && @reason.nil? 18 | 19 | !result.phi_allowed? 20 | end 21 | 22 | chain :allowed_by do |user_id| 23 | @user_id = user_id 24 | end 25 | 26 | chain :with_access_reason do |reason| 27 | @reason = reason 28 | end 29 | 30 | # :nocov: 31 | failure_message do |result| 32 | msgs = [] 33 | 34 | msgs = ['PHI Access was not allowed.'] unless @allowed 35 | msgs << "PHI Access was allowed by '#{result.phi_allowed_by}' (not '#{@user_id}')." unless @user_id_matches 36 | msgs << "PHI Access was allowed because '#{result.phi_access_reason}' (not because '#{@reason}')." unless @reason_matches 37 | 38 | msgs.join "\n" 39 | end 40 | 41 | failure_message_when_negated do |result| 42 | "PHI access was allowed by '#{result.phi_allowed_by}', because '#{result.phi_access_reason}'" 43 | end 44 | # :nocov: 45 | end 46 | -------------------------------------------------------------------------------- /lib/phi_attrs/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PhiAttrs 4 | VERSION = '0.3.2' 5 | end 6 | -------------------------------------------------------------------------------- /phi_attrs.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'phi_attrs/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'phi_attrs' 9 | spec.version = PhiAttrs::VERSION 10 | spec.authors = ['Wyatt Kirby'] 11 | spec.email = ['wyatt@apsis.io'] 12 | 13 | spec.summary = 'PHI Access Restriction & Logging for Rails ActiveRecord' 14 | spec.homepage = 'https://www.apsis.io' 15 | spec.license = 'MIT' 16 | spec.post_install_message = ' 17 | Thank you for installing phi_attrs! By installing this gem, 18 | you acknowledge and agree to the disclaimer as provided in the 19 | DISCLAIMER.txt file. 20 | 21 | For full details, see: https://github.com/apsislabs/phi_attrs/blob/main/DISCLAIMER.txt 22 | ' 23 | 24 | spec.required_ruby_version = '>= 2.7.0' 25 | 26 | spec.files = Dir['{app,config,lib}/**/*', 'CHANGELOG.md', 'DISCLAIMER.txt', 'LICENSE.txt', 'README.md'] 27 | 28 | spec.bindir = 'exe' 29 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 30 | spec.require_paths = ['lib'] 31 | 32 | spec.add_dependency 'rails', '>= 6.0.0' 33 | spec.add_dependency 'request_store', '~> 1.4' 34 | 35 | spec.add_development_dependency 'appraisal' 36 | spec.add_development_dependency 'bundler', '>= 2.2.33' 37 | spec.add_development_dependency 'byebug' 38 | spec.add_development_dependency 'factory_bot_rails' 39 | spec.add_development_dependency 'faker' 40 | spec.add_development_dependency 'rake' 41 | spec.add_development_dependency 'rubocop' 42 | spec.add_development_dependency 'rubocop-rails' 43 | spec.add_development_dependency 'simplecov', '~> 0.16' 44 | spec.add_development_dependency 'tzinfo-data' 45 | spec.metadata['rubygems_mfa_required'] = 'false' 46 | end 47 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apsislabs/phi_attrs/7f6bd8709380a023258c089dcd1f5f62d3aafa9e/spec/dummy/app/assets/config/manifest.js -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Address < ApplicationRecord 4 | belongs_to :patient_info 5 | phi_model 6 | 7 | def inlined(avoid_phi: false) 8 | avoid_phi || address 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/health_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class HealthRecord < ApplicationRecord 4 | belongs_to :patient_info 5 | phi_model 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/models/missing_attribute_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MissingAttributeModel < ApplicationRecord 4 | phi_model 5 | include_in_phi :non_existent_method 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/models/missing_extend_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MissingExtendModel < ApplicationRecord 4 | phi_model 5 | extend_phi_access :non_existent_model 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/models/patient_detail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PatientDetail < ApplicationRecord 4 | belongs_to :patient_info 5 | phi_model 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/models/patient_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PatientInfo < ApplicationRecord 4 | has_one :patient_detail, inverse_of: 'patient_info' 5 | has_one :address, inverse_of: 'patient_info' 6 | has_many :health_records, inverse_of: 'patient_info' 7 | 8 | phi_model 9 | 10 | extend_phi_access :patient_detail, :health_records 11 | 12 | exclude_from_phi :last_name 13 | include_in_phi :birthday 14 | 15 | def birthday 16 | Time.current 17 | end 18 | 19 | def summary_json 20 | { 21 | id: public_id, 22 | first: first_name, 23 | last: last_name 24 | } 25 | end 26 | 27 | def detail_json 28 | extra = { 29 | detail: patient_detail.detail, 30 | health_record_count: health_records.count 31 | } 32 | summary_json.merge(extra) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/dummy/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/all' 4 | 5 | require 'phi_attrs' 6 | 7 | module Dummy 8 | APP_ROOT = File.expand_path(__dir__).freeze 9 | 10 | class Application < Rails::Application 11 | config.root = APP_ROOT 12 | 13 | config.action_controller.perform_caching = false 14 | config.action_mailer.default_url_options = { host: 'dummy.example.com' } 15 | config.action_mailer.delivery_method = :test 16 | config.active_support.deprecation = :stderr 17 | config.eager_load = false 18 | 19 | config.paths['app/controllers'] << "#{APP_ROOT}/app/controllers" 20 | config.paths['app/models'] << "#{APP_ROOT}/app/models" 21 | config.paths['app/views'] << "#{APP_ROOT}/app/views" 22 | config.paths['config/database'] = "#{APP_ROOT}/config/database.yml" 23 | config.paths['log'] = 'tmp/log/development.log' 24 | config.paths.add 'config/routes.rb', with: "#{APP_ROOT}/config/routes.rb" 25 | 26 | config.active_record.sqlite3.represent_boolean_as_integer = true if Rails.version.match?(/^6.0/) 27 | 28 | def require_environment! 29 | initialize! 30 | end 31 | 32 | def initialize!(&block) 33 | super unless @initialized 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: sqlite3 3 | database: db/development.sqlite3 4 | pool: 5 5 | timeout: 5000 6 | 7 | test: 8 | adapter: sqlite3 9 | database: db/test.sqlite3 10 | pool: 5 11 | timeout: 5000 12 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/phi_attrs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | PhiAttrs.configure do |conf| 4 | conf.log_path = Rails.root.join('log/phi_access.log') 5 | 6 | # Log Rotation - disabled by default 7 | # See: https://apidock.com/ruby/Logger/new/class 8 | conf.log_shift_age = 0 # 0 == disabled - How many logs to keep of `shift_size` 9 | conf.log_shift_size = 1_048_576 # 1MB - Default from logger class 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | phi: 3 | sample: 4 | index: 5 | patient_info: "Summary View on Sample" 6 | show: 7 | patent_info: "Typoed patient info" 8 | namespace: 9 | nested: 10 | index: 11 | patent_info: "Typoed patient info" 12 | show: 13 | patient_info: "Detail View on Nested in Namespace" 14 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | # Add your own routes here, or remove this file if you don't have need for it. 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: /app/tmp/storage 4 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20170214100255_create_patient_infos.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreatePatientInfos < ActiveRecord::Migration[5.0] 4 | def change 5 | create_table :patient_infos do |t| 6 | t.string :first_name 7 | t.string :last_name 8 | t.string :public_id 9 | t.timestamps 10 | end 11 | 12 | create_table :patient_details do |t| 13 | t.belongs_to :patient_info 14 | t.string :detail 15 | t.timestamps 16 | end 17 | 18 | create_table :addresses do |t| 19 | t.belongs_to :patient_info 20 | t.string :address 21 | end 22 | 23 | create_table :health_records do |t| 24 | t.belongs_to :patient_info 25 | t.string :data 26 | end 27 | 28 | create_table :missing_attribute_models, &:timestamps 29 | 30 | create_table :missing_extend_models, &:timestamps 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.2].define(version: 2017_02_14_100255) do 14 | create_table "addresses", force: :cascade do |t| 15 | t.integer "patient_info_id" 16 | t.string "address" 17 | t.index ["patient_info_id"], name: "index_addresses_on_patient_info_id" 18 | end 19 | 20 | create_table "health_records", force: :cascade do |t| 21 | t.integer "patient_info_id" 22 | t.string "data" 23 | t.index ["patient_info_id"], name: "index_health_records_on_patient_info_id" 24 | end 25 | 26 | create_table "missing_attribute_models", force: :cascade do |t| 27 | t.datetime "created_at", precision: nil, null: false 28 | t.datetime "updated_at", precision: nil, null: false 29 | end 30 | 31 | create_table "missing_extend_models", force: :cascade do |t| 32 | t.datetime "created_at", precision: nil, null: false 33 | t.datetime "updated_at", precision: nil, null: false 34 | end 35 | 36 | create_table "patient_details", force: :cascade do |t| 37 | t.integer "patient_info_id" 38 | t.string "detail" 39 | t.datetime "created_at", precision: nil, null: false 40 | t.datetime "updated_at", precision: nil, null: false 41 | t.index ["patient_info_id"], name: "index_patient_details_on_patient_info_id" 42 | end 43 | 44 | create_table "patient_infos", force: :cascade do |t| 45 | t.string "first_name" 46 | t.string "last_name" 47 | t.string "public_id" 48 | t.datetime "created_at", precision: nil, null: false 49 | t.datetime "updated_at", precision: nil, null: false 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/dummy/factories/addresses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :address do 5 | address { '123 Little Whinging' } 6 | 7 | trait :all_random do 8 | address { Faker::Movies::HarryPotter.location } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/factories/health_records.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :health_record do 5 | data { "I'm sure this is a quote" } 6 | 7 | trait :all_random do 8 | data { Faker::Movies::HarryPotter.quote } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/factories/missing_attribute_models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :missing_attribute_model 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/factories/missing_extend_models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :missing_extend_model 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/factories/patient_details.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :patient_detail do 5 | detail { 'Generic Spell' } 6 | 7 | trait :all_random do 8 | detail { Faker::Movies::HarryPotter.spell } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/factories/patient_infos.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :patient_info do 5 | first_name { 'Joe Johnson' } 6 | last_name { 'All Houses' } 7 | association :address, factory: :address, strategy: :build 8 | association :patient_detail, factory: :patient_detail, strategy: :build 9 | 10 | trait :all_random do 11 | first_name { Faker::Movies::HarryPotter.character } 12 | last_name { Faker::Movies::HarryPotter.house } 13 | 14 | association :address, :all_random, factory: :address, strategy: :build 15 | association :patient_detail, :all_random, factory: :patient_detail, strategy: :build 16 | end 17 | 18 | trait :with_health_record do 19 | health_records { build_list(:health_record, 1, :all_random) } 20 | end 21 | 22 | trait :with_multiple_health_records do 23 | health_records { build_list(:health_record, 3, :all_random) } 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/dummy/log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log* 2 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apsislabs/phi_attrs/7f6bd8709380a023258c089dcd1f5f62d3aafa9e/spec/dummy/public/favicon.ico -------------------------------------------------------------------------------- /spec/dummy/tmp/development_secret.txt: -------------------------------------------------------------------------------- 1 | 9f9b5d3e135b4d1c73088c73e07bd8604215fd1ea7b5b9086f5086e302b302907d2f365fb1e165c89233b01428a5e3e83296f3759a851a38f6f4521ffc50fc11 -------------------------------------------------------------------------------- /spec/phi_attrs/configure_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'configure' do 6 | orig_method = nil 7 | orig_path = nil 8 | orig_age = nil 9 | orig_size = nil 10 | orig_prefix = nil 11 | 12 | before :all do 13 | orig_method = PhiAttrs.current_user_method 14 | orig_path = PhiAttrs.log_path 15 | orig_age = PhiAttrs.log_shift_age 16 | orig_size = PhiAttrs.log_shift_size 17 | orig_prefix = PhiAttrs.translation_prefix 18 | 19 | PhiAttrs.configure do |c| 20 | c.current_user_method = nil 21 | c.log_path = nil 22 | c.log_shift_age = nil 23 | c.log_shift_size = nil 24 | c.translation_prefix = nil 25 | end 26 | end 27 | 28 | after :all do 29 | PhiAttrs.configure do |c| 30 | c.current_user_method = orig_method 31 | c.log_path = orig_path 32 | c.log_shift_age = orig_age 33 | c.log_shift_size = orig_size 34 | c.translation_prefix = orig_prefix 35 | end 36 | end 37 | 38 | context 'current user' do 39 | subject(:current_user_method) { PhiAttrs.current_user_method } 40 | 41 | it { is_expected.to be_nil } 42 | 43 | it 'can be set' do 44 | PhiAttrs.configure { |c| c.current_user_method = :phi_user } 45 | expect(current_user_method).to be(:phi_user) 46 | end 47 | end 48 | 49 | context 'log_path' do 50 | subject(:log_path) { PhiAttrs.log_path } 51 | 52 | it { is_expected.to be_nil } 53 | 54 | it 'can be set' do 55 | PhiAttrs.configure { |c| c.log_path = 'deep_path' } 56 | expect(log_path).to be('deep_path') 57 | end 58 | end 59 | 60 | context 'log_age' do 61 | subject(:log_shift_age) { PhiAttrs.log_shift_age } 62 | 63 | it { is_expected.to be_nil } 64 | 65 | it 'can be set' do 66 | PhiAttrs.configure { |c| c.log_shift_age = 7 } 67 | expect(log_shift_age).to be(7) 68 | end 69 | end 70 | 71 | context 'log_size' do 72 | subject(:log_shift_size) { PhiAttrs.log_shift_size } 73 | 74 | it { is_expected.to be_nil } 75 | 76 | it 'can be set' do 77 | PhiAttrs.configure { |c| c.log_shift_size = 100.megabytes } 78 | expect(log_shift_size).to be(100.megabytes) 79 | end 80 | end 81 | 82 | context 'translation prefix' do 83 | subject(:translation_prefix) { PhiAttrs.translation_prefix } 84 | 85 | it { is_expected.to eq(nil) } 86 | 87 | it 'can be set' do 88 | PhiAttrs.configure { |c| c.translation_prefix = 'phi_gem' } 89 | expect(translation_prefix).to be('phi_gem') 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/phi_attrs/controllers/current_user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | class SampleController < ApplicationController; end 6 | 7 | RSpec.describe 'default user', type: :controller do 8 | controller SampleController do 9 | def index 10 | PatientInfo.allow_phi do 11 | render json: PatientInfo.all.map(&:summary_json) 12 | end 13 | end 14 | 15 | private 16 | 17 | def phi_user 18 | params[:phi_user] 19 | end 20 | end 21 | 22 | before :context do 23 | PhiAttrs.configure { |c| c.current_user_method = :phi_user } 24 | end 25 | 26 | after :context do 27 | PhiAttrs.configure { |c| c.current_user_method = nil } 28 | end 29 | 30 | before :each do 31 | create(:patient_info, :all_random, :with_multiple_health_records) 32 | end 33 | 34 | context 'with translation' do 35 | it 'uses the translation file for a null reason' do 36 | message = I18n.t('phi.sample.index.patient_info') 37 | allow(PhiAttrs::Logger.logger).to receive(:info) 38 | 39 | get :index, params: { phi_user: 'Madame Pomfrey' } 40 | 41 | expect(PhiAttrs::Logger.logger).to have_received(:info).with(include('Madame Pomfrey')).at_least(:once) 42 | expect(PhiAttrs::Logger.logger).to have_received(:info).with(end_with message).at_least(:once) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/phi_attrs/controllers/i18n_nested_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Namespace 6 | class NestedController < ApplicationController; end 7 | end 8 | 9 | RSpec.describe 'i18n in controller', type: :controller do 10 | controller Namespace::NestedController do 11 | def index 12 | PatientInfo.allow_phi('public_user') do 13 | render json: PatientInfo.all.map(&:summary_json) 14 | end 15 | end 16 | 17 | def show 18 | pi = PatientInfo.find(params[:id]) 19 | pi.allow_phi('public_user') do 20 | render json: pi.detail_json 21 | end 22 | end 23 | end 24 | 25 | before :each do 26 | create(:patient_info, :all_random, :with_multiple_health_records) 27 | end 28 | 29 | context 'with translation' do 30 | it 'uses the translation file for a null reason' do 31 | allow(PhiAttrs::Logger.logger).to receive(:info) 32 | 33 | get :show, params: { id: PatientInfo.first.id } 34 | 35 | message = I18n.t('phi.namespace.nested.show.patient_info') 36 | expect(PhiAttrs::Logger.logger).to have_received(:info).with(end_with message).at_least(:once) 37 | end 38 | end 39 | 40 | context 'without translation' do 41 | it 'warns the user when a translation file was not found' do 42 | message = 'No en PHI Reason found for phi.namespace.nested.index.patient_info' 43 | allow(PhiAttrs::Logger.logger).to receive(:warn) 44 | 45 | expect do 46 | get :index 47 | end.to raise_error(ArgumentError) 48 | expect(PhiAttrs::Logger.logger).to have_received(:warn).with(message) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/phi_attrs/controllers/i18n_sample_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | class SampleController < ApplicationController; end 6 | 7 | RSpec.describe 'i18n in controller', type: :controller do 8 | controller SampleController do 9 | def index 10 | PatientInfo.allow_phi('public_user') do 11 | render json: PatientInfo.all.map(&:summary_json) 12 | end 13 | end 14 | 15 | def show 16 | pi = PatientInfo.find(params[:id]) 17 | pi.allow_phi('public_user') do 18 | render json: pi.detail_json 19 | end 20 | end 21 | end 22 | 23 | before :each do 24 | create(:patient_info, :all_random, :with_multiple_health_records) 25 | end 26 | 27 | context 'with translation' do 28 | it 'uses the translation file for a null reason' do 29 | message = I18n.t('phi.sample.index.patient_info') 30 | allow(PhiAttrs::Logger.logger).to receive(:info) 31 | 32 | get :index 33 | expect(PhiAttrs::Logger.logger).to have_received(:info).with(end_with message).at_least(:once) 34 | end 35 | end 36 | 37 | context 'without translation' do 38 | it 'warns the user when a translation file was not found' do 39 | message = 'No en PHI Reason found for phi.sample.show.patient_info' 40 | allow(PhiAttrs::Logger.logger).to receive(:warn) 41 | 42 | expect do 43 | get :show, params: { id: PatientInfo.first.id } 44 | end.to raise_error(ArgumentError) 45 | expect(PhiAttrs::Logger.logger).to have_received(:warn).with(message) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/phi_attrs/delegations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'delegations' do 6 | file_name = __FILE__ 7 | 8 | let(:address) { build(:address) } 9 | let(:kwargs) { { avoid_phi: 'avoid' } } 10 | 11 | context 'authorized' do 12 | it 'delegates to default attribute' do |t| 13 | address.allow_phi(file_name, t.full_description) do 14 | expect { address.address }.not_to raise_error 15 | end 16 | end 17 | 18 | it 'delegates arguments correctly' do |t| 19 | address.allow_phi(file_name, t.full_description) do 20 | expect(address.inlined).to eq(address.address) 21 | expect(address.inlined(avoid_phi: nil)).to eq(address.address) 22 | end 23 | 24 | # These calls should never touch the PHI field if delegated correctly 25 | expect { address.inlined(avoid_phi: 'avoid') }.not_to raise_error 26 | expect { address.inlined(**kwargs) }.not_to raise_error 27 | 28 | expect(address.inlined(avoid_phi: 'avoid')).to eq('avoid') 29 | expect(address.inlined(**kwargs)).to eq('avoid') 30 | end 31 | end 32 | 33 | context 'unauthorized' do 34 | it 'raises errors with delegated arguments' do 35 | # These calls should try to try to access phi, and fail 36 | expect { address.inlined }.to raise_error(access_error) 37 | expect { address.inlined(avoid_phi: false) }.to raise_error(access_error) 38 | expect { address.inlined(avoid_phi: nil) }.to raise_error(access_error) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/phi_attrs/exceptions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'exceptions' do 6 | let(:patient_john) { build(:patient_info, first_name: 'John') } 7 | 8 | context 'unauthorized' do 9 | it 'raises an error on default attribute' do 10 | expect { patient_john.first_name }.to raise_error(access_error) 11 | end 12 | 13 | it 'raises an error on included method' do 14 | expect { patient_john.birthday }.to raise_error(access_error) 15 | end 16 | 17 | it 'does not raise an error on excluded attribute' do 18 | expect { patient_john.last_name }.not_to raise_error 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/phi_attrs/logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Logger do 6 | file_name = __FILE__ 7 | 8 | let(:patient_jane) { build(:patient_info, first_name: 'Jane') } 9 | 10 | context 'log' do 11 | context 'error' do 12 | it 'when raising an exception' do 13 | message = 'my error message' 14 | expect(PhiAttrs::Logger.logger).to receive(:error).with(message) 15 | 16 | expect do 17 | raise PhiAttrs::Exceptions::PhiAccessException, message 18 | end.to raise_error(access_error) 19 | end 20 | 21 | it 'for unauthorized access' do 22 | expect(PhiAttrs::Logger.logger).to receive(:error) 23 | expect { patient_jane.birthday }.to raise_error(access_error) 24 | end 25 | end 26 | 27 | context 'info' do 28 | it 'when granting phi to instance' do |t| 29 | expect(PhiAttrs::Logger.logger).to receive(:info) 30 | patient_jane.allow_phi!(file_name, t.full_description) 31 | end 32 | 33 | it 'when granting phi to class' do |t| 34 | expect(PhiAttrs::Logger.logger).to receive(:info) 35 | PatientInfo.allow_phi!(file_name, t.full_description) 36 | end 37 | 38 | it 'when revokes phi to class, no current access' do 39 | expect(PhiAttrs::Logger.logger).to receive(:info).with(/No class level/) 40 | PatientInfo.disallow_phi! 41 | end 42 | 43 | it 'when revokes phi to instance, no current access' do 44 | expect(PhiAttrs::Logger.logger).to receive(:info).with(/No instance level/) 45 | patient_jane.disallow_phi! 46 | end 47 | 48 | it 'when revokes phi to class, with current access' do |t| 49 | PatientInfo.allow_phi!(file_name, t.full_description) 50 | expect(PhiAttrs::Logger.logger).to receive(:info).with(Regexp.new(file_name)) 51 | PatientInfo.disallow_phi! 52 | end 53 | 54 | it 'when revokes phi to instance, with current access' do |t| 55 | patient_jane.allow_phi!(file_name, t.full_description) 56 | expect(PhiAttrs::Logger.logger).to receive(:info).with(Regexp.new(file_name)) 57 | patient_jane.disallow_phi! 58 | end 59 | 60 | it 'when accessing method' do |t| 61 | PatientInfo.allow_phi!(file_name, t.full_description) 62 | expect(PhiAttrs::Logger.logger).to receive(:info) 63 | patient_jane.first_name 64 | end 65 | end 66 | 67 | context 'identifier' do 68 | context 'allowed' do 69 | it 'object_id for unpersisted' do |t| 70 | PatientInfo.allow_phi!(file_name, t.full_description) 71 | expect(PhiAttrs::Logger.logger).to receive(:tagged).with(PhiAttrs::PHI_ACCESS_LOG_TAG, PatientInfo.name, "Object: #{patient_jane.object_id}").and_call_original 72 | expect(PhiAttrs::Logger.logger).to receive(:info) 73 | patient_jane.first_name 74 | end 75 | 76 | it 'id for persisted' do |t| 77 | PatientInfo.allow_phi!(file_name, t.full_description) 78 | patient_jane.save 79 | expect(patient_jane.persisted?).to be true 80 | expect(PhiAttrs::Logger.logger).to receive(:tagged).with(PhiAttrs::PHI_ACCESS_LOG_TAG, PatientInfo.name, "Key: #{patient_jane.id}").and_call_original 81 | expect(PhiAttrs::Logger.logger).to receive(:info) 82 | patient_jane.first_name 83 | end 84 | end 85 | 86 | context 'unauthorized' do 87 | it 'object_id for unpersisted' do 88 | expect(PhiAttrs::Logger.logger).to receive(:tagged).with(PhiAttrs::PHI_ACCESS_LOG_TAG, PatientInfo.name, "Object: #{patient_jane.object_id}").and_call_original 89 | expect(PhiAttrs::Logger.logger).to receive(:tagged).with(PhiAttrs::Exceptions::PhiAccessException::TAG).and_call_original 90 | expect(PhiAttrs::Logger.logger).to receive(:error) 91 | expect { patient_jane.first_name }.to raise_error(access_error) 92 | end 93 | 94 | it 'id for persisted' do 95 | patient_jane.save 96 | # expect(patient_jane.persisted?).to be true 97 | expect(PhiAttrs::Logger.logger).to receive(:tagged).with(PhiAttrs::PHI_ACCESS_LOG_TAG, PatientInfo.name, "Key: #{patient_jane.id}").and_call_original 98 | expect(PhiAttrs::Logger.logger).to receive(:tagged).with(PhiAttrs::Exceptions::PhiAccessException::TAG).and_call_original 99 | expect(PhiAttrs::Logger.logger).to receive(:error) 100 | expect { patient_jane.first_name }.to raise_error(access_error) 101 | end 102 | end 103 | 104 | it 'user for manual' do 105 | user = 'Test User' 106 | message = 'Access Granted Message' 107 | expect(PhiAttrs::Logger.logger).to receive(:tagged).with(PhiAttrs::PHI_ACCESS_LOG_TAG, user).and_call_original 108 | expect(PhiAttrs::Logger.logger).to receive(:info).with(message) 109 | PhiAttrs.log_phi_access(user, message) 110 | end 111 | end 112 | 113 | context 'frequency' do 114 | it 'once when accessing multiple methods' do |t| 115 | PatientInfo.allow_phi!(file_name, t.full_description) 116 | expect(PhiAttrs::Logger.logger).to receive(:info) 117 | patient_jane.first_name 118 | patient_jane.birthday 119 | end 120 | 121 | it 'multiple times for nested allow_phi calls' do |t| 122 | expect(PhiAttrs::Logger.logger).to receive(:info).exactly(6) 123 | 124 | PatientInfo.allow_phi(file_name, t.full_description) do # Logged allowed 125 | patient_jane.first_name # Logged access 126 | PatientInfo.allow_phi(file_name, t.full_description) do # Logged allowed 127 | patient_jane.birthday # Logged Access 128 | end # Logged Disallowed 129 | end # Logged Disallowed 130 | end 131 | 132 | it 'multiple times for nested allows and disallows' do |t| 133 | PatientInfo.allow_phi!("#{file_name}1", t.full_description) 134 | PatientInfo.allow_phi!("#{file_name}2", t.full_description) 135 | PatientInfo.allow_phi!("#{file_name}3", t.full_description) 136 | 137 | expect(PhiAttrs::Logger.logger).to receive(:info).exactly(1).ordered 138 | patient_jane.first_name 139 | 140 | expect(PhiAttrs::Logger.logger).to receive(:info).exactly(1).ordered 141 | PatientInfo.disallow_last_phi! 142 | 143 | expect(PhiAttrs::Logger.logger).to receive(:info).exactly(0).ordered 144 | patient_jane.birthday # Not logged again 145 | end 146 | end 147 | 148 | context 'full stack' do 149 | let(:first_allow) { 'first@allow.com' } 150 | let(:second_allow) { 'second@allow.com' } 151 | let(:regexp) { Regexp.new("#{first_allow}.+#{second_allow}|#{second_allow}.+#{first_allow}") } 152 | 153 | context 'for multiple allows' do 154 | def test_logger 155 | expect(PhiAttrs::Logger.logger).to receive(:info).with(regexp) 156 | patient_jane.first_name 157 | end 158 | 159 | def expect_disallow_message(allowed) 160 | expect(PhiAttrs::Logger.logger).to receive(:info).with("PHI access disabled for #{allowed}") 161 | end 162 | 163 | context 'first class' do 164 | it 'then class' do |t| 165 | PatientInfo.allow_phi!(first_allow, t.full_description) 166 | PatientInfo.allow_phi!(second_allow, t.full_description) 167 | test_logger 168 | end 169 | 170 | it 'then instance' do |t| 171 | PatientInfo.allow_phi!(first_allow, t.full_description) 172 | patient_jane.allow_phi!(second_allow, t.full_description) 173 | test_logger 174 | end 175 | 176 | it 'then class block' do |t| 177 | PatientInfo.allow_phi!(first_allow, t.full_description) 178 | PatientInfo.allow_phi(second_allow, t.full_description) do 179 | test_logger 180 | expect_disallow_message(second_allow) 181 | end 182 | end 183 | 184 | it 'then instance block' do |t| 185 | PatientInfo.allow_phi!(first_allow, t.full_description) 186 | patient_jane.allow_phi(second_allow, t.full_description) do 187 | test_logger 188 | expect_disallow_message(second_allow) 189 | end 190 | end 191 | 192 | it 'only one when previously revoked' do |t| 193 | PatientInfo.allow_phi!(first_allow, t.full_description) 194 | PatientInfo.disallow_phi! 195 | patient_jane.allow_phi!(second_allow, t.full_description) 196 | expect(PhiAttrs::Logger.logger).to receive(:info).with(Regexp.new(second_allow)) 197 | patient_jane.first_name 198 | end 199 | end 200 | 201 | context 'first instance' do 202 | it 'then class' do |t| 203 | patient_jane.allow_phi!(first_allow, t.full_description) 204 | PatientInfo.allow_phi!(second_allow, t.full_description) 205 | test_logger 206 | end 207 | 208 | it 'then instance' do |t| 209 | patient_jane.allow_phi!(first_allow, t.full_description) 210 | patient_jane.allow_phi!(second_allow, t.full_description) 211 | test_logger 212 | end 213 | 214 | it 'then class block' do |t| 215 | patient_jane.allow_phi!(first_allow, t.full_description) 216 | PatientInfo.allow_phi(second_allow, t.full_description) do 217 | test_logger 218 | expect_disallow_message(second_allow) 219 | end 220 | end 221 | 222 | it 'then instance block' do |t| 223 | patient_jane.allow_phi!(first_allow, t.full_description) 224 | patient_jane.allow_phi(second_allow, t.full_description) do 225 | test_logger 226 | expect_disallow_message(second_allow) 227 | end 228 | end 229 | 230 | it 'only one when previously revoked' do |t| 231 | patient_jane.allow_phi!(first_allow, t.full_description) 232 | patient_jane.disallow_phi! 233 | PatientInfo.allow_phi!(second_allow, t.full_description) 234 | expect(PhiAttrs::Logger.logger).to receive(:info).with(Regexp.new(second_allow)) 235 | patient_jane.first_name 236 | end 237 | end 238 | end 239 | 240 | context 'for disallow_phi!' do 241 | it 'class' do |t| 242 | PatientInfo.allow_phi!(first_allow, t.full_description) 243 | PatientInfo.allow_phi!(second_allow, t.full_description) 244 | expect(PhiAttrs::Logger.logger).to receive(:info).with(regexp) 245 | PatientInfo.disallow_phi! 246 | end 247 | 248 | it 'instance' do |t| 249 | patient_jane.allow_phi!(first_allow, t.full_description) 250 | patient_jane.allow_phi!(second_allow, t.full_description) 251 | expect(PhiAttrs::Logger.logger).to receive(:info).with(regexp) 252 | patient_jane.disallow_phi! 253 | end 254 | end 255 | end 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /spec/phi_attrs/phi_record/class__allow_phi_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'class allow_phi' do 6 | file_name = __FILE__ 7 | let(:patient_jane) { build(:patient_info, first_name: 'Jane') } 8 | let(:patient_detail) { build(:patient_detail) } 9 | let(:patient_with_detail) { build(:patient_info, first_name: 'Jack', patient_detail: patient_detail) } 10 | 11 | context 'authorized' do 12 | it 'allows access to any instance' do |t| 13 | expect { patient_jane.first_name }.to raise_error(access_error) 14 | PatientInfo.allow_phi(file_name, t.full_description) do 15 | expect { patient_jane.first_name }.not_to raise_error 16 | end 17 | 18 | PatientInfo.allow_phi!(file_name, t.full_description) 19 | expect { patient_jane.first_name }.not_to raise_error 20 | end 21 | 22 | it 'only allows access to the authorized class' do |t| 23 | expect { patient_detail.detail }.to raise_error(access_error) 24 | expect { patient_jane.first_name }.to raise_error(access_error) 25 | 26 | PatientInfo.allow_phi(file_name, t.full_description) do 27 | expect { patient_jane.first_name }.not_to raise_error 28 | expect { patient_detail.detail }.to raise_error(access_error) 29 | end 30 | 31 | expect { patient_detail.detail }.to raise_error(access_error) 32 | expect { patient_jane.first_name }.to raise_error(access_error) 33 | 34 | PatientInfo.allow_phi!(file_name, t.full_description) 35 | 36 | expect { patient_jane.first_name }.not_to raise_error 37 | expect { patient_detail.detail }.to raise_error(access_error) 38 | end 39 | 40 | it 'revokes access after calling disallow_phi!' do |t| 41 | expect { patient_jane.first_name }.to raise_error(access_error) 42 | 43 | PatientInfo.allow_phi!(file_name, t.full_description) 44 | 45 | expect { patient_jane.first_name }.not_to raise_error 46 | 47 | PatientInfo.disallow_phi! 48 | 49 | expect { patient_jane.first_name }.to raise_error(access_error) 50 | end 51 | 52 | it 'raises ArgumentError for allow_phi! with blank values' do 53 | expect { PatientInfo.allow_phi! '', '' }.to raise_error(ArgumentError) 54 | expect { PatientInfo.allow_phi! 'ok', '' }.to raise_error(ArgumentError) 55 | expect { PatientInfo.allow_phi! '', 'ok' }.to raise_error(ArgumentError) 56 | expect { PatientInfo.allow_phi! 'ok', 'ok' }.not_to raise_error 57 | end 58 | 59 | it 'allow_phi persists after a reload' do |t| 60 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail)) 61 | PatientInfo.allow_phi(file_name, t.full_description) do 62 | expect { dumbledore.first_name }.not_to raise_error 63 | dumbledore.reload 64 | expect { dumbledore.first_name }.not_to raise_error 65 | end 66 | end 67 | 68 | it 'allow_phi persists extended phi after a reload' do |t| 69 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail, :all_random)) 70 | expect { dumbledore.patient_detail.detail }.to raise_error(access_error) 71 | 72 | PatientInfo.allow_phi(file_name, t.full_description) do 73 | expect { dumbledore.patient_detail.detail }.not_to raise_error 74 | dumbledore.reload 75 | expect { dumbledore.patient_detail.detail }.not_to raise_error 76 | end 77 | 78 | expect { dumbledore.patient_detail.detail }.to raise_error(access_error) 79 | end 80 | 81 | it 'allow_phi persists extended phi after a reload _and_ respects previous data' do |t| 82 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail, :all_random)) 83 | PatientInfo.allow_phi!(file_name, t.full_description) 84 | expect { dumbledore.patient_detail.detail }.not_to raise_error 85 | 86 | PatientInfo.allow_phi(file_name, t.full_description) do 87 | expect { dumbledore.patient_detail.detail }.not_to raise_error 88 | dumbledore.reload 89 | expect { dumbledore.patient_detail.detail }.not_to raise_error 90 | end 91 | 92 | expect { dumbledore.patient_detail.detail }.not_to raise_error 93 | end 94 | 95 | it 'allow_phi! persists after a reload' do |t| 96 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail)) 97 | PatientInfo.allow_phi!(file_name, t.full_description) 98 | expect { dumbledore.first_name }.not_to raise_error 99 | dumbledore.reload 100 | expect { dumbledore.first_name }.not_to raise_error 101 | end 102 | 103 | it 'allow_phi! persists extended phi after a reload' do |t| 104 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail, :all_random)) 105 | PatientInfo.allow_phi!(file_name, t.full_description) 106 | expect { dumbledore.patient_detail.detail }.not_to raise_error 107 | dumbledore.reload 108 | expect { dumbledore.patient_detail.detail }.not_to raise_error 109 | end 110 | 111 | it 'get_phi with block returns value' do |t| 112 | expect(PatientInfo.get_phi(file_name, t.full_description) { patient_jane.first_name }).to eq('Jane') 113 | end 114 | 115 | it 'does not leak phi allowance if get_phi returns', :aggregate_failures do |t| 116 | def name_getter(reason, description) 117 | PatientInfo.get_phi(reason, description) { return patient_jane.first_name } 118 | end 119 | 120 | expect(patient_jane.phi_allowed?).to be false 121 | first_name = name_getter(file_name, t.full_description) 122 | expect(first_name).to eq('Jane') 123 | expect(patient_jane.phi_allowed?).to be false 124 | end 125 | end 126 | 127 | context 'extended authorization' do 128 | let(:patient_mary) { create(:patient_info, :with_multiple_health_records) } 129 | 130 | it 'extends access to associations' do |t| 131 | expect { patient_mary.patient_detail.detail }.to raise_error(access_error) 132 | 133 | PatientInfo.allow_phi!(file_name, t.full_description) 134 | expect { patient_mary.patient_detail.detail }.not_to raise_error 135 | end 136 | 137 | it 'extends access with a block' do |t| 138 | expect { patient_mary.patient_detail.detail }.to raise_error(access_error) 139 | 140 | PatientInfo.allow_phi(file_name, t.full_description) do 141 | expect { patient_mary.patient_detail.detail }.not_to raise_error 142 | end 143 | 144 | expect { patient_mary.patient_detail.detail }.to raise_error(access_error) 145 | end 146 | 147 | it 'does not revoke access for untouched associations' do |t| 148 | # Here we extend access to two different associations. 149 | # When the block terminates, it should revoke (the one frame of) the `health_records` access, 150 | # but it should NOT revoke (the only frame of) the `patient_detail` access. 151 | # In either case, the "parent" object should still be able to re-extend access. 152 | 153 | PatientInfo.allow_phi!(file_name, t.full_description) 154 | expect { patient_mary.patient_detail.detail }.not_to raise_error 155 | pd = patient_mary.patient_detail 156 | 157 | PatientInfo.allow_phi(file_name, t.full_description) do 158 | expect { patient_mary.health_records.first.data }.not_to raise_error 159 | end 160 | 161 | # The PatientInfo should re-extend access to `health_records` 162 | expect { patient_mary.health_records.first.data }.not_to raise_error 163 | 164 | # We should still be able to access this through a different handle, 165 | # as the PatientDetail model should not have been affected by the end-of-block revocation. 166 | # The separate handle is important because this does not allow the access to 167 | # be quietly re-extended by the PatientInfo record. 168 | expect { pd.detail }.not_to raise_error 169 | end 170 | end 171 | 172 | context 'nested allowances' do 173 | it 'retains outer access when disallowed at inner level' do |t| 174 | PatientInfo.allow_phi(file_name, t.full_description) do 175 | expect { patient_with_detail.first_name }.not_to raise_error 176 | 177 | PatientInfo.allow_phi(file_name, t.full_description) do 178 | expect { patient_with_detail.first_name }.not_to raise_error 179 | end # Inner permission revoked 180 | 181 | expect { patient_with_detail.first_name }.not_to raise_error 182 | expect { patient_with_detail.patient_detail.detail }.not_to raise_error 183 | end # Outer permission revoked 184 | 185 | expect { patient_with_detail.first_name }.to raise_error(access_error) 186 | expect { patient_with_detail.patient_detail.detail }.to raise_error(access_error) 187 | end 188 | end 189 | 190 | context 'block checks' do 191 | context 'allow_phi' do 192 | it 'succeeds' do 193 | expect { PatientInfo.allow_phi!('ok', 'ok') }.not_to raise_error 194 | end 195 | it 'raises ArgumentError with block' do 196 | expect { PatientInfo.allow_phi!('ok', 'ok') { do_nothing } }.to raise_error(ArgumentError) 197 | end 198 | end 199 | 200 | context 'allow_phi!' do 201 | it 'succeeds' do 202 | expect { PatientInfo.allow_phi('ok', 'ok') { do_nothing } }.not_to raise_error 203 | end 204 | it 'raises ArgumentError for allow_phi! without block' do 205 | expect { PatientInfo.allow_phi('ok', 'ok') }.to raise_error(ArgumentError) 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/phi_attrs/phi_record/class__disallow_phi_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'class disallow_phi' do 6 | file_name = __FILE__ 7 | 8 | let(:patient_jane) { build(:patient_info, first_name: 'Jane') } 9 | let(:patient_john) { build(:patient_info, first_name: 'John') } 10 | 11 | context 'block' do 12 | it 'disables all allowances within the block' do |t| 13 | PatientInfo.allow_phi!(file_name, t.full_description) 14 | expect { patient_jane.first_name }.not_to raise_error 15 | 16 | PatientInfo.disallow_phi do 17 | expect { patient_jane.first_name }.to raise_error(access_error) 18 | end 19 | end 20 | 21 | it 'returns permission after the block' do |t| 22 | PatientInfo.allow_phi!(file_name, t.full_description) 23 | expect { patient_jane.first_name }.not_to raise_error 24 | 25 | PatientInfo.disallow_phi do 26 | expect { patient_jane.first_name }.to raise_error(access_error) 27 | end 28 | 29 | expect { patient_jane.first_name }.not_to raise_error 30 | end 31 | 32 | it 'does not affect explicit instance allow' do |t| 33 | PatientInfo.allow_phi!(file_name, t.full_description) 34 | patient_john.allow_phi!(file_name, t.full_description) 35 | 36 | expect { patient_jane.first_name }.not_to raise_error 37 | expect { patient_john.first_name }.not_to raise_error 38 | 39 | PatientInfo.disallow_phi do 40 | expect { patient_jane.first_name }.to raise_error(access_error) 41 | expect { patient_john.first_name }.not_to raise_error 42 | end 43 | 44 | expect { patient_jane.first_name }.not_to raise_error 45 | expect { patient_john.first_name }.not_to raise_error 46 | end 47 | 48 | it 'raises ArgumentError without block' do 49 | expect { PatientInfo.disallow_phi }.to raise_error(ArgumentError) 50 | end 51 | end 52 | 53 | context 'disallow_phi!' do 54 | it 'disallows whole stack' do |t| 55 | PatientInfo.allow_phi!("#{file_name}1", t.full_description) 56 | expect { patient_jane.first_name }.not_to raise_error 57 | PatientInfo.allow_phi!("#{file_name}2", t.full_description) 58 | expect { patient_jane.first_name }.not_to raise_error 59 | PatientInfo.disallow_phi! 60 | expect { patient_jane.first_name }.to raise_error(access_error) 61 | end 62 | 63 | it 'disallows does not affect instance allows' do |t| 64 | PatientInfo.allow_phi!("#{file_name}1", t.full_description) 65 | expect { patient_jane.first_name }.not_to raise_error 66 | patient_jane.allow_phi!("#{file_name}2", t.full_description) 67 | expect { patient_jane.first_name }.not_to raise_error 68 | PatientInfo.disallow_phi! 69 | expect { patient_jane.first_name }.not_to raise_error 70 | end 71 | 72 | it 'allows access after disallow' do |t| 73 | PatientInfo.allow_phi!("#{file_name}1", t.full_description) 74 | expect { patient_jane.first_name }.not_to raise_error 75 | PatientInfo.disallow_phi! 76 | expect { patient_jane.first_name }.to raise_error(access_error) 77 | PatientInfo.allow_phi!("#{file_name}2", t.full_description) 78 | expect { patient_jane.first_name }.not_to raise_error 79 | end 80 | 81 | it 'raises ArgumentError with block' do 82 | expect { PatientInfo.disallow_phi! { do_nothing } }.to raise_error(ArgumentError) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/phi_attrs/phi_record/class__phi_allowed_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'class phi_allowed?' do 6 | file_name = __FILE__ 7 | let(:patient_jane) { build(:patient_info, first_name: 'Jane') } 8 | 9 | context 'authorized' do 10 | it 'works' do |t| 11 | expect(PatientInfo.phi_allowed?).to be false 12 | PatientInfo.allow_phi(file_name, t.full_description) do 13 | expect(PatientInfo.phi_allowed?).to be true 14 | end 15 | 16 | PatientInfo.allow_phi!(file_name, t.full_description) 17 | expect(PatientInfo.phi_allowed?).to be true 18 | end 19 | 20 | it 'only allows access to the authorized class' do |t| 21 | expect(PatientDetail.phi_allowed?).to be false 22 | 23 | PatientInfo.allow_phi(file_name, t.full_description) do 24 | expect(PatientInfo.phi_allowed?).to be true 25 | expect(PatientDetail.phi_allowed?).to be false 26 | end 27 | 28 | expect(PatientDetail.phi_allowed?).to be false 29 | expect(PatientInfo.phi_allowed?).to be false 30 | 31 | PatientInfo.allow_phi!(file_name, t.full_description) 32 | 33 | expect(PatientInfo.phi_allowed?).to be true 34 | expect(PatientDetail.phi_allowed?).to be false 35 | end 36 | 37 | it 'revokes access after calling disallow_phi!' do |t| 38 | expect(PatientInfo.phi_allowed?).to be false 39 | 40 | PatientInfo.allow_phi!(file_name, t.full_description) 41 | 42 | expect(PatientInfo.phi_allowed?).to be true 43 | 44 | PatientInfo.disallow_phi! 45 | 46 | expect(PatientInfo.phi_allowed?).to be false 47 | end 48 | 49 | it 'revokes access for disallow_phi block' do |t| 50 | expect(PatientInfo.phi_allowed?).to be false 51 | 52 | PatientInfo.allow_phi!(file_name, t.full_description) 53 | 54 | expect(PatientInfo.phi_allowed?).to be true 55 | 56 | PatientInfo.disallow_phi do 57 | expect(PatientInfo.phi_allowed?).to be false 58 | end 59 | 60 | expect(PatientInfo.phi_allowed?).to be true 61 | end 62 | end 63 | 64 | context 'nested allowances' do 65 | it 'retains outer access when disallowed at inner level' do |t| 66 | PatientInfo.allow_phi(file_name, t.full_description) do 67 | expect(PatientInfo.phi_allowed?).to be true 68 | 69 | PatientInfo.allow_phi(file_name, t.full_description) do 70 | expect(PatientInfo.phi_allowed?).to be true 71 | end # Inner permission revoked 72 | 73 | expect(PatientInfo.phi_allowed?).to be true 74 | end # Outer permission revoked 75 | 76 | expect(PatientInfo.phi_allowed?).to be false 77 | end 78 | 79 | it 'retains outer access when disallow block at inner level' do |t| 80 | PatientInfo.allow_phi(file_name, t.full_description) do 81 | expect(PatientInfo.phi_allowed?).to be true 82 | 83 | PatientInfo.disallow_phi do 84 | expect(PatientInfo.phi_allowed?).to be false 85 | end # Inner disallow removed 86 | 87 | expect(PatientInfo.phi_allowed?).to be true 88 | end # Outer permission revoked 89 | 90 | expect(PatientInfo.phi_allowed?).to be false 91 | end 92 | 93 | it 'retains outer access with nested disallow blocks' do |t| 94 | PatientInfo.allow_phi!(file_name, t.full_description) 95 | 96 | PatientInfo.disallow_phi do 97 | expect(PatientInfo.phi_allowed?).to be false 98 | 99 | PatientInfo.disallow_phi do 100 | expect(PatientInfo.phi_allowed?).to be false 101 | end # Inner disallow removed 102 | 103 | expect(PatientInfo.phi_allowed?).to be false 104 | end # Outer disallow removed 105 | 106 | expect(PatientInfo.phi_allowed?).to be true 107 | end 108 | end 109 | 110 | context 'with instance' do 111 | it 'allow_phi does not change status' do |t| 112 | expect(PatientInfo.phi_allowed?).to be false 113 | patient_jane.allow_phi(file_name, t.full_description) do 114 | expect(PatientInfo.phi_allowed?).to be false 115 | end 116 | end 117 | 118 | it 'disallow_phi does not change status' do |t| 119 | expect(PatientInfo.phi_allowed?).to be false 120 | PatientInfo.allow_phi!(file_name, t.full_description) 121 | expect(PatientInfo.phi_allowed?).to be true 122 | 123 | patient_jane.disallow_phi do 124 | expect(PatientInfo.phi_allowed?).to be true 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/phi_attrs/phi_record/instance__allow_phi_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'instance allow_phi' do 6 | file_name = __FILE__ 7 | 8 | let(:patient_john) { build(:patient_info, first_name: 'John') } 9 | let(:patient_jane) { build(:patient_info, first_name: 'Jane') } 10 | let(:patient_detail) { build(:patient_detail) } 11 | let(:patient_with_detail) { build(:patient_info, first_name: 'Jack', patient_detail: patient_detail) } 12 | 13 | context 'authorized' do 14 | context 'single record' do 15 | it 'allows access to an authorized instance' do |t| 16 | expect { patient_jane.first_name }.to raise_error(access_error) 17 | 18 | patient_jane.allow_phi(file_name, t.full_description) do 19 | expect { patient_jane.first_name }.not_to raise_error 20 | end 21 | 22 | expect { patient_jane.first_name }.to raise_error(access_error) 23 | 24 | patient_jane.allow_phi!(file_name, t.full_description) 25 | 26 | expect { patient_jane.first_name }.not_to raise_error 27 | end 28 | 29 | it 'only allows access to the authorized instance' do |t| 30 | patient_jane.allow_phi(file_name, t.full_description) do 31 | expect { patient_jane.first_name }.not_to raise_error 32 | expect { patient_john.first_name }.to raise_error(access_error) 33 | end 34 | 35 | patient_jane.allow_phi!(file_name, t.full_description) 36 | 37 | expect { patient_jane.first_name }.not_to raise_error 38 | expect { patient_john.first_name }.to raise_error(access_error) 39 | end 40 | 41 | it 'revokes access after calling disallow_phi!' do |t| 42 | expect { patient_jane.first_name }.to raise_error(access_error) 43 | 44 | patient_jane.allow_phi!(file_name, t.full_description) 45 | 46 | expect { patient_jane.first_name }.not_to raise_error 47 | 48 | patient_jane.disallow_phi! 49 | 50 | expect { patient_jane.first_name }.to raise_error(access_error) 51 | end 52 | 53 | it 'allows access on an instance that already exists' do |t| 54 | john = create(:patient_info, first_name: 'John') 55 | expect { john.first_name }.to raise_error(access_error) 56 | 57 | john_id = john.id 58 | john = nil 59 | 60 | john = PatientInfo.find(john_id) 61 | expect { john.first_name }.to raise_error(access_error) 62 | 63 | john.allow_phi!(file_name, t.full_description) 64 | expect { john.first_name }.not_to raise_error 65 | expect(john.first_name).to eq 'John' 66 | end 67 | 68 | it 'rejects calls to allow_phi! with blank values' do 69 | expect { patient_jane.allow_phi! '', '' }.to raise_error(ArgumentError) 70 | expect { patient_jane.allow_phi! 'ok', '' }.to raise_error(ArgumentError) 71 | expect { patient_jane.allow_phi! '', 'ok' }.to raise_error(ArgumentError) 72 | expect { patient_jane.allow_phi! 'ok', 'ok' }.not_to raise_error 73 | end 74 | 75 | it 'allow_phi persists after a reload' do |t| 76 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail)) 77 | dumbledore.allow_phi(file_name, t.full_description) do 78 | expect { dumbledore.first_name }.not_to raise_error 79 | dumbledore.reload 80 | expect { dumbledore.first_name }.not_to raise_error 81 | end 82 | end 83 | 84 | it 'allow_phi persists extended phi after a reload' do |t| 85 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail, :all_random)) 86 | dumbledore.allow_phi(file_name, t.full_description) do 87 | expect { dumbledore.patient_detail.detail }.not_to raise_error 88 | dumbledore.reload 89 | expect { dumbledore.patient_detail.detail }.not_to raise_error 90 | end 91 | expect { dumbledore.patient_detail.detail }.to raise_error(access_error) 92 | end 93 | 94 | it 'allow_phi persists extended phi after a reload _and_ respects previous data' do |t| 95 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail, :all_random)) 96 | dumbledore.allow_phi!(file_name, t.full_description) 97 | expect { dumbledore.patient_detail.detail }.not_to raise_error 98 | 99 | dumbledore.allow_phi(file_name, t.full_description) do 100 | expect { dumbledore.patient_detail.detail }.not_to raise_error 101 | dumbledore.reload 102 | expect { dumbledore.patient_detail.detail }.not_to raise_error 103 | end 104 | 105 | expect { dumbledore.patient_detail.detail }.not_to raise_error 106 | end 107 | 108 | it 'allow_phi! persists after a reload' do |t| 109 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail)) 110 | dumbledore.allow_phi!(file_name, t.full_description) 111 | expect { dumbledore.first_name }.not_to raise_error 112 | dumbledore.reload 113 | expect { dumbledore.first_name }.not_to raise_error 114 | end 115 | 116 | it 'allow_phi! persists extended phi after a reload' do |t| 117 | dumbledore = create(:patient_info, first_name: 'Albus', patient_detail: build(:patient_detail, :all_random)) 118 | dumbledore.allow_phi!(file_name, t.full_description) 119 | expect { dumbledore.patient_detail.detail }.not_to raise_error 120 | dumbledore.reload 121 | expect { dumbledore.patient_detail.detail }.not_to raise_error 122 | end 123 | 124 | it 'get_phi with block returns value' do |t| 125 | expect(patient_jane.get_phi(file_name, t.full_description) { patient_jane.first_name }).to eq('Jane') 126 | end 127 | 128 | it 'does not leak phi allowance if get_phi returns', :aggregate_failures do |t| 129 | def name_getter(reason, description) 130 | patient_jane.get_phi(reason, description) { return patient_jane.first_name } 131 | end 132 | 133 | expect(patient_jane.phi_allowed?).to be false 134 | first_name = name_getter(file_name, t.full_description) 135 | expect(first_name).to eq('Jane') 136 | expect(patient_jane.phi_allowed?).to be false 137 | end 138 | end 139 | 140 | context 'collection' do 141 | let(:jay) { create(:patient_info, first_name: 'Jay') } 142 | let(:bob) { create(:patient_info, first_name: 'Bob') } 143 | let(:moe) { create(:patient_info, first_name: 'Moe') } 144 | let(:patients) { [jay, bob, moe] } 145 | 146 | it 'allows access when fetched as a collection' do |t| 147 | expect(patients).to contain_exactly(jay, bob, moe) 148 | expect { patients.map(&:first_name) }.to raise_error(access_error) 149 | 150 | patients.map { |p| p.allow_phi!(file_name, t.full_description) } 151 | expect { patients.map(&:first_name) }.not_to raise_error 152 | end 153 | 154 | context 'with targets' do 155 | let(:non_target) { create(:patient_info, first_name: 'Private') } 156 | 157 | it 'allow_phi allows access to all members of a collection' do |t| 158 | patients.each do |patient| 159 | expect { patient.first_name }.to raise_error(access_error) 160 | end 161 | 162 | expect do 163 | PatientInfo.allow_phi(file_name, t.full_description, allow_only: patients) do 164 | expect(patients.map(&:first_name)).to contain_exactly('Jay', 'Bob', 'Moe') 165 | end 166 | end.not_to raise_error 167 | end 168 | 169 | it 'allow_phi does not allow access to non-targets' do |t| 170 | expect { non_target.first_name }.to raise_error(access_error) 171 | 172 | expect do 173 | PatientInfo.allow_phi(file_name, t.full_description, allow_only: patients) do 174 | expect { non_target.first_name }.to raise_error(access_error) 175 | end 176 | end.not_to raise_error 177 | end 178 | 179 | context 'invalid targets' do 180 | it 'raises exception when targeting an unexpected class' do |t| 181 | address = create(:address) 182 | 183 | expect do 184 | PatientInfo.allow_phi(file_name, t.full_description, allow_only: [jay, address]) do 185 | jay.first_name 186 | end 187 | end.to raise_error(ArgumentError) 188 | end 189 | 190 | it 'raises exception when given a non-iterable' do |t| 191 | expect do 192 | PatientInfo.allow_phi(file_name, t.full_description, allow_only: jay) do 193 | jay.first_name 194 | end 195 | end.to raise_error(ArgumentError) 196 | end 197 | end 198 | end 199 | end 200 | end 201 | 202 | context 'extended authorization' do 203 | let(:patient_mary) { create(:patient_info, :with_multiple_health_records) } 204 | 205 | context 'plain access' do 206 | it 'extends access to extended association' do |t| 207 | expect { patient_mary.first_name }.to raise_error(access_error) 208 | expect { patient_mary.patient_detail.detail }.to raise_error(access_error) 209 | 210 | patient_mary.allow_phi!(file_name, t.full_description) 211 | 212 | expect { patient_mary.first_name }.not_to raise_error 213 | expect { patient_mary.patient_detail.detail }.not_to raise_error 214 | expect(patient_mary.patient_detail.detail).to eq 'Generic Spell' 215 | end 216 | 217 | it 'does not extend to unextended association' do |t| 218 | expect { patient_mary.first_name }.to raise_error(access_error) 219 | expect { patient_mary.address.address }.to raise_error(access_error) 220 | 221 | patient_mary.allow_phi!(file_name, t.full_description) 222 | expect { patient_mary.first_name }.not_to raise_error 223 | expect { patient_mary.address.address }.to raise_error(access_error) 224 | 225 | patient_mary.address.allow_phi!(file_name, t.full_description) 226 | expect { patient_mary.address.address }.not_to raise_error 227 | expect(patient_mary.address.address).to eq '123 Little Whinging' 228 | end 229 | 230 | it 'extends access to :has_many associations' do |t| 231 | expect { patient_mary.health_records.first.data }.to raise_error(access_error) 232 | 233 | patient_mary.allow_phi!(file_name, t.full_description) 234 | expect { patient_mary.health_records.first.data }.not_to raise_error 235 | end 236 | end 237 | 238 | context 'block access' do 239 | it 'extends access to extended association' do |t| 240 | expect { patient_mary.first_name }.to raise_error(access_error) 241 | expect { patient_mary.patient_detail.detail }.to raise_error(access_error) 242 | 243 | patient_mary.allow_phi(file_name, t.full_description) do 244 | expect { patient_mary.first_name }.not_to raise_error 245 | expect { patient_mary.patient_detail.detail }.not_to raise_error 246 | expect(patient_mary.patient_detail.detail).to eq('Generic Spell') 247 | end 248 | end 249 | 250 | it 'does not extend to unextended association' do |t| 251 | expect { patient_mary.first_name }.to raise_error(access_error) 252 | expect { patient_mary.address.address }.to raise_error(access_error) 253 | 254 | patient_mary.allow_phi(file_name, t.full_description) do 255 | expect { patient_mary.first_name }.not_to raise_error 256 | expect { patient_mary.address.address }.to raise_error(access_error) 257 | end 258 | 259 | patient_mary.address.allow_phi!(file_name, t.full_description) 260 | expect { patient_mary.address.address }.not_to raise_error 261 | expect(patient_mary.address.address).to eq('123 Little Whinging') 262 | end 263 | 264 | it 'extends access to :has_many associations' do |t| 265 | expect { patient_mary.health_records.first.data }.to raise_error(access_error) 266 | 267 | patient_mary.allow_phi(file_name, t.full_description) do 268 | expect { patient_mary.health_records.first.data }.not_to raise_error 269 | end 270 | end 271 | 272 | it 'revokes access after block' do |t| 273 | patient_mary.allow_phi(file_name, t.full_description) do 274 | expect { patient_mary.patient_detail.detail }.not_to raise_error 275 | expect(patient_mary.patient_detail.detail).to eq('Generic Spell') 276 | end 277 | 278 | expect { patient_mary.first_name }.to raise_error(access_error) 279 | expect { patient_mary.patient_detail.detail }.to raise_error(access_error) 280 | expect { patient_mary.health_records.first.data }.to raise_error(access_error) 281 | end 282 | 283 | it 'does not revoke access for untouched associations' do |t| 284 | # Here we extend access to two different associations. 285 | # When the block terminates, it should revoke (the one frame of) the `health_records` access, 286 | # but it should NOT revoke (the only frame of) the `patient_detail` access. 287 | # In either case, the "parent" object should still be able to re-extend access. 288 | 289 | patient_mary.allow_phi!(file_name, t.full_description) 290 | expect { patient_mary.patient_detail.detail }.not_to raise_error 291 | pd = patient_mary.patient_detail 292 | 293 | patient_mary.allow_phi(file_name, t.full_description) do 294 | expect { patient_mary.health_records.first.data }.not_to raise_error 295 | end 296 | 297 | # The PatientInfo should re-extend access to `health_records` 298 | expect { patient_mary.health_records.first.data }.not_to raise_error 299 | 300 | # We should still be able to access this through a different handle, 301 | # as the PatientDetail model should not have been affected by the end-of-block revocation. 302 | # The separate handle is important because this does not allow the access to 303 | # be quietly re-extended by the PatientInfo record. 304 | expect { pd.detail }.not_to raise_error 305 | end 306 | end 307 | end 308 | 309 | context 'nested allowances' do 310 | it 'retains outer access when disallowed at inner level' do |t| 311 | patient_with_detail.allow_phi(file_name, t.full_description) do 312 | expect { patient_with_detail.first_name }.not_to raise_error 313 | 314 | patient_with_detail.allow_phi(file_name, t.full_description) do 315 | expect { patient_with_detail.first_name }.not_to raise_error 316 | end # Inner permission revoked 317 | 318 | expect { patient_with_detail.first_name }.not_to raise_error 319 | expect { patient_with_detail.patient_detail.detail }.not_to raise_error 320 | end # Outer permission revoked 321 | 322 | expect { patient_with_detail.first_name }.to raise_error(access_error) 323 | expect { patient_with_detail.patient_detail.detail }.to raise_error(access_error) 324 | end 325 | end 326 | 327 | context 'block checks' do 328 | context 'allow_phi' do 329 | it 'succeeds' do 330 | expect { patient_jane.allow_phi!('ok', 'ok') }.not_to raise_error 331 | end 332 | 333 | it 'raises ArgumentError with block' do 334 | expect { patient_jane.allow_phi!('ok', 'ok') { do_nothing } }.to raise_error(ArgumentError) 335 | end 336 | end 337 | 338 | context 'allow_phi!' do 339 | it 'succeeds' do 340 | expect { patient_jane.allow_phi('ok', 'ok') { do_nothing } }.not_to raise_error 341 | end 342 | it 'raises ArgumentError for allow_phi! without block' do 343 | expect { patient_jane.allow_phi('ok', 'ok') }.to raise_error(ArgumentError) 344 | end 345 | end 346 | end 347 | end 348 | -------------------------------------------------------------------------------- /spec/phi_attrs/phi_record/instance__disallow_phi_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'instance disallow_phi' do 6 | file_name = __FILE__ 7 | 8 | let(:patient_jane) { build(:patient_info, first_name: 'Jane') } 9 | let(:patient_john) { build(:patient_info, first_name: 'John') } 10 | 11 | context 'block' do 12 | it 'disables all allowances within the block' do |t| 13 | patient_john.allow_phi!(file_name, t.full_description) 14 | expect { patient_john.first_name }.not_to raise_error 15 | 16 | patient_john.disallow_phi do 17 | expect { patient_john.first_name }.to raise_error(access_error) 18 | end 19 | end 20 | 21 | it 'returns permission after the block' do |t| 22 | patient_john.allow_phi!(file_name, t.full_description) 23 | expect { patient_john.first_name }.not_to raise_error 24 | 25 | patient_john.disallow_phi do 26 | expect { patient_john.first_name }.to raise_error(access_error) 27 | end 28 | 29 | expect { patient_john.first_name }.not_to raise_error 30 | end 31 | 32 | it 'allows other patient access' do |t| 33 | patient_john.allow_phi!(file_name, t.full_description) 34 | patient_jane.allow_phi!(file_name, t.full_description) 35 | expect { patient_john.first_name }.not_to raise_error 36 | expect { patient_jane.first_name }.not_to raise_error 37 | 38 | patient_john.disallow_phi do 39 | expect { patient_john.first_name }.to raise_error(access_error) 40 | expect { patient_jane.first_name }.not_to raise_error 41 | end 42 | 43 | expect { patient_john.first_name }.not_to raise_error 44 | expect { patient_jane.first_name }.not_to raise_error 45 | end 46 | 47 | it 'raises ArgumentError without block' do 48 | expect { patient_john.disallow_phi }.to raise_error(ArgumentError) 49 | end 50 | end 51 | 52 | context 'disallow_phi!' do 53 | it 'disallows whole stack' do |t| 54 | patient_john.allow_phi!("#{file_name}1", t.full_description) 55 | expect { patient_john.first_name }.not_to raise_error 56 | patient_john.allow_phi!("#{file_name}2", t.full_description) 57 | expect { patient_john.first_name }.not_to raise_error 58 | patient_john.disallow_phi! 59 | expect { patient_john.first_name }.to raise_error(access_error) 60 | end 61 | 62 | it 'disallows does not affect Class allows' do |t| 63 | PatientInfo.allow_phi!(file_name, t.full_description) 64 | expect { patient_john.first_name }.not_to raise_error 65 | patient_john.allow_phi!("#{file_name}2", t.full_description) 66 | expect { patient_john.first_name }.not_to raise_error 67 | patient_john.disallow_phi! 68 | expect { patient_john.first_name }.not_to raise_error 69 | end 70 | 71 | it 'allows access after disallow' do |t| 72 | patient_john.allow_phi!("#{file_name}1", t.full_description) 73 | expect { patient_john.first_name }.not_to raise_error 74 | patient_john.disallow_phi! 75 | expect { patient_john.first_name }.to raise_error(access_error) 76 | patient_john.allow_phi!("#{file_name}2", t.full_description) 77 | expect { patient_john.first_name }.not_to raise_error 78 | end 79 | 80 | it 'raises ArgumentError with block' do 81 | expect { patient_john.disallow_phi! { do_nothing } }.to raise_error(ArgumentError) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/phi_attrs/phi_record/instance__phi_allowed_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'instance phi_allowed?' do 6 | file_name = __FILE__ 7 | 8 | let(:patient_john) { build(:patient_info, first_name: 'John') } 9 | let(:patient_jane) { build(:patient_info, first_name: 'Jane') } 10 | let(:patient_detail) { build(:patient_detail) } 11 | let(:patient_with_detail) { build(:patient_info, first_name: 'Jack', patient_detail: patient_detail) } 12 | 13 | context 'with instance allow_phi' do 14 | context 'authorized' do 15 | context 'single record' do 16 | it 'allows access to an authorized instance' do |t| 17 | expect(patient_jane.phi_allowed?).to be false 18 | 19 | patient_jane.allow_phi(file_name, t.full_description) do 20 | expect(patient_jane.phi_allowed?).to be true 21 | end 22 | 23 | expect(patient_jane.phi_allowed?).to be false 24 | 25 | patient_jane.allow_phi!(file_name, t.full_description) 26 | 27 | expect(patient_jane.phi_allowed?).to be true 28 | end 29 | 30 | it 'only allows access to the authorized instance' do |t| 31 | patient_jane.allow_phi(file_name, t.full_description) do 32 | expect(patient_jane.phi_allowed?).to be true 33 | expect(patient_john.phi_allowed?).to be false 34 | end 35 | 36 | patient_jane.allow_phi!(file_name, t.full_description) 37 | 38 | expect(patient_jane.phi_allowed?).to be true 39 | expect(patient_john.phi_allowed?).to be false 40 | end 41 | 42 | it 'revokes access after calling disallow_phi!' do |t| 43 | expect(patient_jane.phi_allowed?).to be false 44 | 45 | patient_jane.allow_phi!(file_name, t.full_description) 46 | 47 | expect(patient_jane.phi_allowed?).to be true 48 | 49 | patient_jane.disallow_phi! 50 | 51 | expect(patient_jane.phi_allowed?).to be false 52 | end 53 | 54 | it 'allows access on an instance that already exists' do |t| 55 | john = create(:patient_info, first_name: 'John') 56 | expect(john.phi_allowed?).to be false 57 | 58 | john_id = john.id 59 | 60 | john = PatientInfo.find(john_id) 61 | expect(john.phi_allowed?).to be false 62 | 63 | john.allow_phi!(file_name, t.full_description) 64 | expect(john.phi_allowed?).to be true 65 | end 66 | 67 | it 'revokes access for disallow_phi block' do |t| 68 | expect(patient_jane.phi_allowed?).to be false 69 | 70 | patient_jane.allow_phi!(file_name, t.full_description) 71 | 72 | expect(patient_jane.phi_allowed?).to be true 73 | 74 | patient_jane.disallow_phi do 75 | expect(patient_jane.phi_allowed?).to be false 76 | end 77 | 78 | expect(patient_jane.phi_allowed?).to be true 79 | end 80 | end 81 | 82 | context 'collection' do 83 | let(:jay) { create(:patient_info, first_name: 'Jay') } 84 | let(:bob) { create(:patient_info, first_name: 'Bob') } 85 | let(:moe) { create(:patient_info, first_name: 'Moe') } 86 | let(:patients) { [jay, bob, moe] } 87 | 88 | it 'allows access when fetched as a collection' do |t| 89 | expect(patients).to contain_exactly(jay, bob, moe) 90 | expect(patients.map(&:phi_allowed?)).to contain_exactly(false, false, false) 91 | 92 | patients.map { |p| p.allow_phi!(file_name, t.full_description) } 93 | expect(patients.map(&:phi_allowed?)).to contain_exactly(true, true, true) 94 | end 95 | 96 | context 'with targets' do 97 | let(:non_target) { create(:patient_info, first_name: 'Private') } 98 | 99 | it 'allow_phi allows access to all members of a collection' do |t| 100 | patients.each do |patient| 101 | expect(patient.phi_allowed?).to be false 102 | end 103 | 104 | expect do 105 | PatientInfo.allow_phi(file_name, t.full_description, allow_only: patients) do 106 | expect(patients.map(&:phi_allowed?)).to contain_exactly(true, true, true) 107 | end 108 | end.not_to raise_error 109 | end 110 | 111 | it 'allow_phi does not allow access to non-targets' do |t| 112 | expect(non_target.phi_allowed?).to be false 113 | 114 | expect do 115 | PatientInfo.allow_phi(file_name, t.full_description, allow_only: patients) do 116 | expect(non_target.phi_allowed?).to be false 117 | end 118 | end.not_to raise_error 119 | end 120 | end 121 | end 122 | end 123 | 124 | context 'extended authorization' do 125 | let(:patient_mary) { create(:patient_info, :with_multiple_health_records) } 126 | 127 | context 'plain access' do 128 | it 'extends access to extended association' do |t| 129 | expect(patient_mary.phi_allowed?).to be false 130 | expect(patient_mary.patient_detail.phi_allowed?).to be false 131 | 132 | patient_mary.allow_phi!(file_name, t.full_description) 133 | 134 | expect(patient_mary.phi_allowed?).to be true 135 | expect(patient_mary.patient_detail.phi_allowed?).to be true 136 | end 137 | 138 | it 'does not extend to unextended association' do |t| 139 | expect(patient_mary.phi_allowed?).to be false 140 | expect(patient_mary.address.phi_allowed?).to be false 141 | 142 | patient_mary.allow_phi!(file_name, t.full_description) 143 | expect(patient_mary.phi_allowed?).to be true 144 | expect(patient_mary.address.phi_allowed?).to be false 145 | 146 | patient_mary.address.allow_phi!(file_name, t.full_description) 147 | expect(patient_mary.address.phi_allowed?).to be true 148 | end 149 | 150 | it 'extends access to :has_many associations' do |t| 151 | expect(patient_mary.health_records.first.phi_allowed?).to be false 152 | 153 | patient_mary.allow_phi!(file_name, t.full_description) 154 | expect(patient_mary.health_records.first.phi_allowed?).to be true 155 | end 156 | end 157 | 158 | context 'block access' do 159 | it 'extends access to extended association' do |t| 160 | expect(patient_mary.phi_allowed?).to be false 161 | expect(patient_mary.patient_detail.phi_allowed?).to be false 162 | 163 | patient_mary.allow_phi(file_name, t.full_description) do 164 | expect(patient_mary.phi_allowed?).to be true 165 | expect(patient_mary.patient_detail.phi_allowed?).to be true 166 | end 167 | end 168 | 169 | it 'does not extend to unextended association' do |t| 170 | expect(patient_mary.phi_allowed?).to be false 171 | expect(patient_mary.address.phi_allowed?).to be false 172 | 173 | patient_mary.allow_phi(file_name, t.full_description) do 174 | expect(patient_mary.phi_allowed?).to be true 175 | expect(patient_mary.address.phi_allowed?).to be false 176 | end 177 | 178 | patient_mary.address.allow_phi!(file_name, t.full_description) 179 | expect(patient_mary.address.phi_allowed?).to be true 180 | end 181 | 182 | it 'extends access to :has_many associations' do |t| 183 | expect(patient_mary.health_records.first.phi_allowed?).to be false 184 | 185 | patient_mary.allow_phi(file_name, t.full_description) do 186 | expect(patient_mary.health_records.first.phi_allowed?).to be true 187 | end 188 | end 189 | 190 | it 'revokes access after block' do |t| 191 | patient_mary.allow_phi(file_name, t.full_description) do 192 | expect(patient_mary.patient_detail.phi_allowed?).to be true 193 | end 194 | 195 | expect(patient_mary.phi_allowed?).to be false 196 | expect(patient_mary.patient_detail.phi_allowed?).to be false 197 | expect(patient_mary.health_records.first.phi_allowed?).to be false 198 | end 199 | 200 | it 'does not revoke access for untouched associations' do |t| 201 | # Here we extend access to two different associations. 202 | # When the block terminates, it should revoke (the one frame of) the `health_records` access, 203 | # but it should NOT revoke (the only frame of) the `patient_detail` access. 204 | # In either case, the "parent" object should still be able to re-extend access. 205 | 206 | patient_mary.allow_phi!(file_name, t.full_description) 207 | expect(patient_mary.patient_detail.phi_allowed?).to be true 208 | pd = patient_mary.patient_detail 209 | 210 | patient_mary.allow_phi(file_name, t.full_description) do 211 | expect(patient_mary.health_records.first.phi_allowed?).to be true 212 | end 213 | 214 | # The PatientInfo should re-extend access to `health_records` 215 | expect(patient_mary.health_records.first.phi_allowed?).to be true 216 | 217 | # We should still be able to access this through a different handle, 218 | # as the PatientDetail model should not have been affected by the end-of-block revocation. 219 | # The separate handle is important because this does not allow the access to 220 | # be quietly re-extended by the PatientInfo record. 221 | expect(pd.phi_allowed?).to be true 222 | end 223 | end 224 | 225 | context 'block disallow' do 226 | it 'disallows access to extended association' do |t| 227 | expect(patient_mary.phi_allowed?).to be false 228 | expect(patient_mary.patient_detail.phi_allowed?).to be false 229 | 230 | patient_mary.allow_phi!(file_name, t.full_description) 231 | 232 | expect(patient_mary.phi_allowed?).to be true 233 | expect(patient_mary.patient_detail.phi_allowed?).to be true 234 | 235 | patient_mary.disallow_phi do 236 | expect(patient_mary.phi_allowed?).to be false 237 | expect(patient_mary.patient_detail.phi_allowed?).to be false 238 | end 239 | end 240 | 241 | it 'disallows access to :has_many associations' do |t| 242 | expect(patient_mary.health_records.first.phi_allowed?).to be false 243 | 244 | patient_mary.allow_phi!(file_name, t.full_description) 245 | 246 | expect(patient_mary.health_records.first.phi_allowed?).to be true 247 | 248 | patient_mary.disallow_phi do 249 | expect(patient_mary.health_records.first.phi_allowed?).to be false 250 | end 251 | end 252 | end 253 | end 254 | 255 | context 'nested allowances' do 256 | it 'retains outer access when disallowed at inner level' do |t| 257 | patient_with_detail.allow_phi(file_name, t.full_description) do 258 | expect(patient_with_detail.phi_allowed?).to be true 259 | 260 | patient_with_detail.allow_phi(file_name, t.full_description) do 261 | expect(patient_with_detail.phi_allowed?).to be true 262 | end # Inner permission revoked 263 | 264 | expect(patient_with_detail.phi_allowed?).to be true 265 | expect(patient_with_detail.patient_detail.phi_allowed?).to be true 266 | end # Outer permission revoked 267 | 268 | expect(patient_with_detail.phi_allowed?).to be false 269 | expect(patient_with_detail.patient_detail.phi_allowed?).to be false 270 | end 271 | end 272 | 273 | it 'retains outer access when disallow block at inner level' do |t| 274 | patient_jane.allow_phi(file_name, t.full_description) do 275 | expect(patient_jane.phi_allowed?).to be true 276 | 277 | patient_jane.disallow_phi do 278 | expect(patient_jane.phi_allowed?).to be false 279 | end # Inner disallow removed 280 | 281 | expect(patient_jane.phi_allowed?).to be true 282 | end # Outer permission revoked 283 | 284 | expect(patient_jane.phi_allowed?).to be false 285 | end 286 | 287 | it 'retains outer access with nested disallow blocks' do |t| 288 | patient_jane.allow_phi!(file_name, t.full_description) 289 | 290 | patient_jane.disallow_phi do 291 | expect(patient_jane.phi_allowed?).to be false 292 | 293 | patient_jane.disallow_phi do 294 | expect(patient_jane.phi_allowed?).to be false 295 | end # Inner disallow removed 296 | 297 | expect(patient_jane.phi_allowed?).to be false 298 | end # Outer disallow removed 299 | 300 | expect(patient_jane.phi_allowed?).to be true 301 | end 302 | end 303 | 304 | context 'with class allow_phi' do 305 | context 'authorized' do 306 | it 'allows access to any instance' do |t| 307 | expect(patient_jane.phi_allowed?).to be false 308 | PatientInfo.allow_phi(file_name, t.full_description) do 309 | expect(patient_jane.phi_allowed?).to be true 310 | end 311 | expect(patient_jane.phi_allowed?).to be false 312 | PatientInfo.allow_phi!(file_name, t.full_description) 313 | expect(patient_jane.phi_allowed?).to be true 314 | end 315 | 316 | it 'only allows for the authorized class' do |t| 317 | expect(patient_detail.phi_allowed?).to be false 318 | expect(patient_jane.phi_allowed?).to be false 319 | 320 | PatientInfo.allow_phi(file_name, t.full_description) do 321 | expect(patient_detail.phi_allowed?).to be false 322 | expect(patient_jane.phi_allowed?).to be true 323 | end 324 | 325 | expect(patient_detail.phi_allowed?).to be false 326 | expect(patient_jane.phi_allowed?).to be false 327 | 328 | PatientInfo.allow_phi!(file_name, t.full_description) 329 | 330 | expect(patient_detail.phi_allowed?).to be false 331 | expect(patient_jane.phi_allowed?).to be true 332 | end 333 | 334 | it 'revokes access after calling disallow_phi!' do |t| 335 | expect(patient_jane.phi_allowed?).to be false 336 | 337 | PatientInfo.allow_phi!(file_name, t.full_description) 338 | 339 | expect(patient_jane.phi_allowed?).to be true 340 | 341 | PatientInfo.disallow_phi! 342 | 343 | expect(patient_jane.phi_allowed?).to be false 344 | end 345 | 346 | it 'disallow_phi does not change status' do |t| 347 | expect(patient_jane.phi_allowed?).to be false 348 | patient_jane.allow_phi!(file_name, t.full_description) 349 | expect(patient_jane.phi_allowed?).to be true 350 | 351 | PatientInfo.disallow_phi do 352 | expect(patient_jane.phi_allowed?).to be true 353 | end 354 | end 355 | end 356 | 357 | context 'extended authorization' do 358 | let(:patient_mary) { create(:patient_info, :with_multiple_health_records) } 359 | 360 | it 'does not revoke access for untouched associations' do |t| 361 | # Here we extend access to two different associations. 362 | # When the block terminates, it should revoke (the one frame of) the `health_records` access, 363 | # but it should NOT revoke (the only frame of) the `patient_detail` access. 364 | # In either case, the "parent" object should still be able to re-extend access. 365 | 366 | PatientInfo.allow_phi!(file_name, t.full_description) 367 | expect(patient_mary.patient_detail.phi_allowed?).to be true 368 | 369 | pd = patient_mary.patient_detail 370 | 371 | PatientInfo.allow_phi(file_name, t.full_description) do 372 | expect(patient_mary.health_records.first.phi_allowed?).to be true 373 | end 374 | 375 | # The PatientInfo should re-extend access to `health_records` 376 | expect(patient_mary.health_records.first.phi_allowed?).to be true 377 | 378 | # We should still be able to access this through a different handle, 379 | # as the PatientDetail model should not have been affected by the end-of-block revocation. 380 | # The separate handle is important because this does not allow the access to 381 | # be quietly re-extended by the PatientInfo record. 382 | expect(pd.phi_allowed?).to be true 383 | end 384 | end 385 | 386 | context 'nested allowances' do 387 | it 'retains outer access when disallowed at inner level' do |t| 388 | PatientInfo.allow_phi(file_name, t.full_description) do 389 | expect(patient_with_detail.phi_allowed?).to be true 390 | 391 | PatientInfo.allow_phi(file_name, t.full_description) do 392 | expect(patient_with_detail.phi_allowed?).to be true 393 | end # Inner permission revoked 394 | 395 | expect(patient_with_detail.phi_allowed?).to be true 396 | expect(patient_with_detail.patient_detail.phi_allowed?).to be true 397 | end # Outer permission revoked 398 | 399 | expect(patient_with_detail.phi_allowed?).to be false 400 | expect(patient_with_detail.patient_detail.phi_allowed?).to be false 401 | end 402 | end 403 | end 404 | end 405 | -------------------------------------------------------------------------------- /spec/phi_attrs/phi_record/phi_wrapping_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'phi_wrapping' do 6 | let(:missing_attribute_model) { build(:missing_attribute_model) } 7 | let(:missing_extend_model) { build(:missing_extend_model) } 8 | 9 | context 'non existent attributes' do 10 | it 'wrapping a method' do 11 | expect { missing_attribute_model }.not_to raise_error 12 | end 13 | 14 | it 'extending a model' do 15 | expect { missing_extend_model }.to raise_error(NameError) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/phi_attrs/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe PhiAttrs do 6 | context 'gem' do 7 | it 'has a version number' do 8 | expect(PhiAttrs::VERSION).not_to be nil 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/phi_attrs_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Try to spread out test from all running in one large file, 4 | # but if there isn't a good spot put it here for now. 5 | RSpec.describe PhiAttrs do 6 | context 'global' do 7 | # Nothing here for now. 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start 5 | 6 | require 'rails/all' 7 | require 'dummy/application' 8 | 9 | require 'byebug' 10 | require 'rspec/rails' 11 | require 'factory_bot_rails' 12 | require 'faker' 13 | require 'phi_attrs' 14 | 15 | Dummy::Application.initialize! 16 | 17 | # Adds all support files 18 | Dir[File.join(Gem::Specification.find_by_name('phi_attrs').gem_dir.to_s, 'spec', 'support', '**', '*.rb')].sort.each { |f| require f } 19 | 20 | RSpec.configure do |config| 21 | config.use_transactional_fixtures = true 22 | 23 | # Enable flags like --only-failures and --next-failure 24 | config.example_status_persistence_file_path = '.rspec_status' 25 | 26 | # Disable RSpec exposing methods globally on `Module` and `main` 27 | config.disable_monkey_patching! 28 | 29 | config.expect_with :rspec do |c| 30 | c.syntax = :expect 31 | end 32 | 33 | config.after(:each) do 34 | RequestStore.end! 35 | RequestStore.clear! 36 | end 37 | 38 | # So we don't have to prefix everything with `FactoryBot.` 39 | config.include FactoryBot::Syntax::Methods 40 | end 41 | -------------------------------------------------------------------------------- /spec/support/block_access_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module BlockAccessHelpers 4 | def do_nothing 5 | # dummy method to put inside "empty" blocks for block-based access specs 6 | end 7 | end 8 | 9 | RSpec.configure do |config| 10 | config.include BlockAccessHelpers 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/error_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ErrorHelpers 4 | def access_error 5 | PhiAttrs::Exceptions::PhiAccessException 6 | end 7 | end 8 | 9 | RSpec.configure do |config| 10 | config.include ErrorHelpers 11 | end 12 | --------------------------------------------------------------------------------