diff --git a/config/presets/g10_reference_server_preset.json b/config/presets/g10_reference_server_preset.json index cc4dfc48..b4881f64 100644 --- a/config/presets/g10_reference_server_preset.json +++ b/config/presets/g10_reference_server_preset.json @@ -523,6 +523,50 @@ "description": "Client Secret provided during registration of Inferno as an EHR launch application", "value": "SAMPLE_CONFIDENTIAL_CLIENT_SECRET" }, + { + "name": "granular_scope_selection_client_auth_type", + "value": "confidential_symmetric", + "_title": "Client Authentication Method", + "_type": "radio", + "_options": { + "list_options": [ + { + "label": "Public", + "value": "public" + }, + { + "label": "Confidential Symmetric", + "value": "confidential_symmetric" + }, + { + "label": "Confidential Asymmetric", + "value": "confidential_asymmetric" + } + ] + } + }, + { + "name": "granular_scope_selection_v2_client_id", + "value": "SAMPLE_CONFIDENTIAL_CLIENT_ID", + "_title": "Granular Scope Selection w/v2 Scopes Client ID", + "_description": "Client ID provided during registration of Inferno as a standalone application", + "_type": "text" + }, + { + "name": "granular_scope_selection_v2_requested_scopes", + "value": "launch/patient openid fhirUser offline_access patient/Condition.rs patient/Observation.rs patient/Patient.rs", + "_title": "Granular Scope Selection v2 Scopes", + "_description": "OAuth 2.0 scope provided by system to enable all required functionality", + "_type": "textarea" + }, + { + "name": "granular_scope_selection_v2_client_secret", + "value": "SAMPLE_CONFIDENTIAL_CLIENT_SECRET", + "_title": "Granular Scope Selection w/v2 Scopes Client Secret", + "_description": "Client Secret provided during registration of Inferno as a standalone application. Only for clients using confidential symmetric authentication.", + "_type": "text", + "_optional": true + }, { "name": "token_revocation_attestation", "type": "radio", diff --git a/lib/onc_certification_g10_test_kit.rb b/lib/onc_certification_g10_test_kit.rb index edc06896..0f15115a 100644 --- a/lib/onc_certification_g10_test_kit.rb +++ b/lib/onc_certification_g10_test_kit.rb @@ -15,17 +15,18 @@ require_relative 'onc_certification_g10_test_kit/single_patient_us_core_6_api_group' require_relative 'onc_certification_g10_test_kit/smart_app_launch_invalid_aud_group' require_relative 'onc_certification_g10_test_kit/smart_asymmetric_launch_group' -require_relative 'onc_certification_g10_test_kit/smart_ehr_patient_launch_group' -require_relative 'onc_certification_g10_test_kit/smart_ehr_patient_launch_group_stu2' -require_relative 'onc_certification_g10_test_kit/smart_ehr_practitioner_app_group' -require_relative 'onc_certification_g10_test_kit/smart_fine_grained_scopes_group' -require_relative 'onc_certification_g10_test_kit/smart_invalid_pkce_group' +require_relative 'onc_certification_g10_test_kit/smart_granular_scope_selection_group' require_relative 'onc_certification_g10_test_kit/smart_invalid_token_group' require_relative 'onc_certification_g10_test_kit/smart_invalid_token_group_stu2' +require_relative 'onc_certification_g10_test_kit/smart_invalid_pkce_group' require_relative 'onc_certification_g10_test_kit/smart_limited_app_group' +require_relative 'onc_certification_g10_test_kit/smart_standalone_patient_app_group' require_relative 'onc_certification_g10_test_kit/smart_public_standalone_launch_group' require_relative 'onc_certification_g10_test_kit/smart_public_standalone_launch_group_stu2' -require_relative 'onc_certification_g10_test_kit/smart_standalone_patient_app_group' +require_relative 'onc_certification_g10_test_kit/smart_ehr_patient_launch_group' +require_relative 'onc_certification_g10_test_kit/smart_ehr_patient_launch_group_stu2' +require_relative 'onc_certification_g10_test_kit/smart_ehr_practitioner_app_group' +require_relative 'onc_certification_g10_test_kit/smart_fine_grained_scopes_group' require_relative 'onc_certification_g10_test_kit/smart_v1_scopes_group' require_relative 'onc_certification_g10_test_kit/terminology_binding_validator' require_relative 'onc_certification_g10_test_kit/token_introspection_group' @@ -387,6 +388,9 @@ def self.well_known_route_handler group from: :g10_smart_fine_grained_scopes, required_suite_options: G10Options::SMART_2_REQUIREMENT.merge(G10Options::US_CORE_6_REQUIREMENT), exclude_optional: true + + group from: :g10_smart_granular_scope_selection, + required_suite_options: G10Options::SMART_2_REQUIREMENT.merge(G10Options::US_CORE_6_REQUIREMENT) end group from: :g10_visual_inspection_and_attestations diff --git a/lib/onc_certification_g10_test_kit/onc_program_procedure.yml b/lib/onc_certification_g10_test_kit/onc_program_procedure.yml index 52c30f0f..47ce5f50 100644 --- a/lib/onc_certification_g10_test_kit/onc_program_procedure.yml +++ b/lib/onc_certification_g10_test_kit/onc_program_procedure.yml @@ -519,6 +519,7 @@ procedure: - 9.14.1.1.2.08 - 9.14.2.1.2.08 - 9.10.18 + - 9.15.2.05 inferno_notes: | This step refers to only the receipt of these scopes, which is covered in Inferno in one step in each the EHR and Standalone launch cases. However, diff --git a/lib/onc_certification_g10_test_kit/short_id_map.yml b/lib/onc_certification_g10_test_kit/short_id_map.yml index 46a7da0a..1c8effdb 100644 --- a/lib/onc_certification_g10_test_kit/short_id_map.yml +++ b/lib/onc_certification_g10_test_kit/short_id_map.yml @@ -2298,20 +2298,35 @@ g10_certification-Group06-g10_smart_fine_grained_scopes-Group02-us_core_v610_obs : 9.14.2.3.03 ? g10_certification-Group06-g10_smart_fine_grained_scopes-Group02-us_core_v610_observation_granular_scope_2_group-us_core_v610_Observation_granular_scope_read_test : 9.14.2.3.04 +g10_certification-Group06-g10_smart_granular_scope_selection: '9.15' +g10_certification-Group06-g10_smart_granular_scope_selection-smart_discovery_stu2: 9.15.1 +g10_certification-Group06-g10_smart_granular_scope_selection-smart_discovery_stu2-well_known_endpoint: 9.15.1.01 +g10_certification-Group06-g10_smart_granular_scope_selection-smart_discovery_stu2-well_known_capabilities_stu2: 9.15.1.02 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes: 9.15.2 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-standalone_auth_tls: 9.15.2.01 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-smart_app_redirect_stu2: 9.15.2.02 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-smart_code_received: 9.15.2.03 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-standalone_token_tls: 9.15.2.04 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-smart_token_exchange: 9.15.2.05 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-smart_token_response_body: 9.15.2.06 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-smart_token_response_headers: 9.15.2.07 +g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-g10_smart_scopes: 9.15.2.08 +? g10_certification-Group06-g10_smart_granular_scope_selection-g10_granular_scope_selection_v2_scopes-g10_smart_granular_scope_selection +: 9.15.2.09 g10_certification-g10_visual_inspection_and_attestations: '11' g10_certification-g10_visual_inspection_and_attestations-Test01: '11.01' g10_certification-g10_visual_inspection_and_attestations-Test02: '11.02' g10_certification-g10_visual_inspection_and_attestations-Test03: '11.03' g10_certification-g10_visual_inspection_and_attestations-Test04: '11.04' g10_certification-g10_visual_inspection_and_attestations-Test05: '11.05' -g10_certification-g10_visual_inspection_and_attestations-Test07: '11.06' -g10_certification-g10_visual_inspection_and_attestations-Test08: '11.07' -g10_certification-g10_visual_inspection_and_attestations-Test09: '11.08' -g10_certification-g10_visual_inspection_and_attestations-Test10: '11.09' -g10_certification-g10_visual_inspection_and_attestations-Test11: '11.10' -g10_certification-g10_visual_inspection_and_attestations-Test13: '11.11' -g10_certification-g10_visual_inspection_and_attestations-g10_public_url_attestation: '11.12' -g10_certification-g10_visual_inspection_and_attestations-g10_tls_version_attestation: '11.13' -g10_certification-g10_visual_inspection_and_attestations-g10_refresh_token_refresh_attestation: '11.14' -g10_certification-g10_visual_inspection_and_attestations-g10_bulk_v2_since_attestation: '11.15' -g10_certification-g10_visual_inspection_and_attestations-g10_clinical_test_scope_attestation: '11.16' +g10_certification-g10_visual_inspection_and_attestations-Test07: '11.07' +g10_certification-g10_visual_inspection_and_attestations-Test08: '11.08' +g10_certification-g10_visual_inspection_and_attestations-Test09: '11.09' +g10_certification-g10_visual_inspection_and_attestations-Test10: '11.10' +g10_certification-g10_visual_inspection_and_attestations-Test11: '11.11' +g10_certification-g10_visual_inspection_and_attestations-Test13: '11.13' +g10_certification-g10_visual_inspection_and_attestations-g10_public_url_attestation: '11.14' +g10_certification-g10_visual_inspection_and_attestations-g10_tls_version_attestation: '11.15' +g10_certification-g10_visual_inspection_and_attestations-g10_refresh_token_refresh_attestation: '11.16' +g10_certification-g10_visual_inspection_and_attestations-g10_bulk_v2_since_attestation: '11.17' +g10_certification-g10_visual_inspection_and_attestations-g10_clinical_test_scope_attestation: '11.18' diff --git a/lib/onc_certification_g10_test_kit/smart_granular_scope_selection_group.rb b/lib/onc_certification_g10_test_kit/smart_granular_scope_selection_group.rb new file mode 100644 index 00000000..ba791841 --- /dev/null +++ b/lib/onc_certification_g10_test_kit/smart_granular_scope_selection_group.rb @@ -0,0 +1,121 @@ +require_relative 'smart_scopes_test' +require_relative 'smart_granular_scope_selection_test' + +module ONCCertificationG10TestKit + class SmartGranularScopeSelectionGroup < Inferno::TestGroup + title 'SMART Granular Scope Selection' + short_title 'SMART Granular Scope Selection' + id :g10_smart_granular_scope_selection + + description <<~DESCRIPTION + These tests verify that when resource-level scopes are requested for + Condition and Observation resources, the user is presented with the option + of approving sub-resource scopes rather than the resource-level scope. + + The tests request v2 resource-level Condition and Observation scopes. In + each instance, the user must unselect the resource-level scopes and + instead approve sub-resource scopes for Condition and Observation. It is + also required that a resource-level Patient scope be granted. + + > As part of supporting the SMART App Launch “permission-v2” capability + for the purposes of certification, if an app requests authorization for + a resource level scope for the “Condition” or “Observation” resources, + then for patient authorization purposes a Health IT Module must support + presentation of the required sub-resource scopes to the patient for + authorization. Specifically, sub-resource scopes must be presented for + patient authorization as follows: + + > * “Condition” sub-resource scopes “Encounter Diagnosis”, “Problem List”, + and “Health Concern” if a “Condition” resource level scope is + requested + > * “Observation” sub-resource scopes “Clinical Test”, “Laboratory”, + “Social History”, “SDOH”, “Survey”, and “Vital Signs” if an + “Observation” resource level scope is requested + DESCRIPTION + + run_as_group + + config( + inputs: { + use_pkce: { + default: 'true', + locked: true + }, + pkce_code_challenge_method: { + locked: true + }, + granular_scope_selection_authorization_method: { + name: :granular_scope_selection_authorization_method, + default: 'get' + }, + client_auth_type: { + name: :granular_scope_selection_client_auth_type, + default: 'confidential_asymmetric' + } + } + ) + + group from: :smart_discovery_stu2 + + group from: :smart_standalone_launch_stu2 do + id :g10_granular_scope_selection_v2_scopes + title 'Granular Scope Selection with v2 Scopes' + + config( + inputs: { + client_id: { + name: :granular_scope_selection_v2_client_id, + title: 'Granular Scope Selection w/v2 Scopes Client ID' + }, + client_secret: { + name: :granular_scope_selection_v2_client_secret, + title: 'Granular Scope Selection w/v2 Scopes Client Secret', + default: nil, + optional: true + }, + requested_scopes: { + name: :granular_scope_selection_v2_requested_scopes, + title: 'Granular Scope Selection v2 Scopes', + default: %( + launch/patient openid fhirUser offline_access patient/Condition.rs + patient/Observation.rs patient/Patient.rs + ).gsub(/\s{2,}/, ' ').strip + }, + received_scopes: { name: :granular_scope_selection_v2_received_scopes } + }, + outputs: { + requested_scopes: { name: :granular_scope_selection_v2_requested_scopes }, + received_scopes: { name: :granular_scope_selection_v2_received_scopes } + }, + options: { + redirect_message_proc: proc do |auth_url| + %( + ### #{self.class.parent&.parent&.title} + + [Follow this link to authorize with the SMART server](#{auth_url}). + + Tests will resume once Inferno receives a request at + `#{config.options[:redirect_uri]}` with a state of `#{state}`. + ) + end + } + ) + + test from: :g10_smart_scopes do + config( + options: { + scope_version: :v2, + required_scope_type: 'patient', + required_scopes: ['openid', 'fhirUser', 'launch/patient', 'offline_access'] + } + ) + + def patient_compartment_resource_types + ['Patient', 'Condition', 'Observation'] + end + end + + test from: :g10_smart_granular_scope_selection + end + end +end diff --git a/lib/onc_certification_g10_test_kit/smart_granular_scope_selection_test.rb b/lib/onc_certification_g10_test_kit/smart_granular_scope_selection_test.rb new file mode 100644 index 00000000..167504b6 --- /dev/null +++ b/lib/onc_certification_g10_test_kit/smart_granular_scope_selection_test.rb @@ -0,0 +1,53 @@ +module ONCCertificationG10TestKit + class SMARTGranularScopeSelectionTest < Inferno::Test + title 'Granular Scope Selection' + description %( + This test verifies that granular scopes have been issued for Condition and + Observation resources, and that a v2 read scope has been issued for the + Patient resource. + ) + id :g10_smart_granular_scope_selection + input :requested_scopes, :received_scopes + + def resources_with_granular_scopes + ['Condition', 'Observation'] + end + + def resource_level_scope_regex(resource_type) + /#{resource_type}\.(\*|read|c?ru?d?s?)\z/ + end + + def v2_resource_level_scope_regex(resource_type) + /#{resource_type}\.(\*|c?ru?d?s?)\z/ + end + + def granular_scope_regex(resource_type) + /#{resource_type}\.(\*|c?ru?d?s?)\?.+=.+/ + end + + run do + assert requested_scopes.present? + requested_scopes = self.requested_scopes.split + (resources_with_granular_scopes + ['Patient']).each do |resource_type| + assert requested_scopes.any? { |scope| scope.match(resource_level_scope_regex(resource_type)) }, + "No resource-level scope was requested for #{resource_type}" + + granular_scope = requested_scopes.find { |scope| scope.match(granular_scope_regex(resource_type)) } + assert granular_scope.nil?, "Granular scope was requested: #{granular_scope}" + end + + assert received_scopes.present? + received_scopes = self.received_scopes.split + + resources_with_granular_scopes.each do |resource_type| + resource_level_scope = received_scopes.find { |scope| scope.match?(resource_level_scope_regex(resource_type)) } + assert resource_level_scope.nil?, "Resource-level scope was granted: #{resource_level_scope}" + assert received_scopes.any? { |scope| scope.match?(granular_scope_regex(resource_type)) }, + "No granular scopes were granted for #{resource_type}" + end + + assert received_scopes.any? { |scope| scope.match?(v2_resource_level_scope_regex('Patient')) }, + 'No v2 resource-level scope was granted for Patient' + end + end +end diff --git a/onc_certification_g10_matrix.xlsx b/onc_certification_g10_matrix.xlsx index 20b390ec..ebbc11f1 100644 Binary files a/onc_certification_g10_matrix.xlsx and b/onc_certification_g10_matrix.xlsx differ diff --git a/spec/onc_certification_g10_test_kit/smart_granular_scope_selection_test_spec.rb b/spec/onc_certification_g10_test_kit/smart_granular_scope_selection_test_spec.rb new file mode 100644 index 00000000..aa77b557 --- /dev/null +++ b/spec/onc_certification_g10_test_kit/smart_granular_scope_selection_test_spec.rb @@ -0,0 +1,98 @@ +RSpec.describe ONCCertificationG10TestKit::SMARTGranularScopeSelectionTest do + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + let(:test) { described_class } + let(:test_session) { repo_create(:test_session, test_suite_id: 'g10_certification') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:requested_scopes) do + [ + 'launch', + 'openid', + 'fhirUser', + 'patient/Patient.read', + 'patient/Condition.read', + 'patient/Observation.read' + ].join(' ') + end + let(:received_scopes) do + [ + 'launch', + 'openid', + 'fhirUser', + 'patient/Patient.rs', + 'patient/Condition.rs?category=http://terminology.hl7.org/CodeSystem/condition-category|problem-list-item', + 'patient/Observation.rs?category=http://terminology.hl7.org/CodeSystem/observation-category|survey' + ].join(' ') + end + + it 'fails if a required resource-level scope is not requsted' do + result = run(test, requested_scopes: 'patient/Patient.read', received_scopes:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/No resource-level scope was requested/) + end + + it 'fails if a granular scope is requested' do + scopes_with_granular = "#{requested_scopes} patient/Observation.rs?category=" \ + 'http://terminology.hl7.org/CodeSystem/observation-category|survey' + + result = run(test, requested_scopes: scopes_with_granular, received_scopes:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Granular scope was requested/) + end + + it 'fails if a resource-level Condition/Observation scope is received' do + scopes_with_resource = "#{received_scopes} patient/Condition.rs" + + result = run(test, requested_scopes:, received_scopes: scopes_with_resource) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Resource-level scope was granted/) + end + + it 'fails if no granular Condition/Observation scope is received' do + scopes_without_granular = 'launch openid fhirUser patient/Patient.rs' + + result = run(test, requested_scopes:, received_scopes: scopes_without_granular) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/No granular scopes were granted/) + end + + it 'fails if no Patient read scope is received' do + scopes_without_patient = received_scopes.sub('patient/Patient.rs ', '') + + result = run(test, requested_scopes:, received_scopes: scopes_without_patient) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/No v2 resource-level scope was granted for Patient/) + end + + it 'fails if a v1 Patient read scope is received' do + scopes_with_v1_patient = received_scopes.sub('patient/Patient.rs', 'patient/Patient.read') + + result = run(test, requested_scopes:, received_scopes: scopes_with_v1_patient) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/No v2 resource-level scope was granted for Patient/) + end + + it 'passes if resource-level scopes are requested, and granular Condition/Observation scopes are received' do + result = run(test, requested_scopes:, received_scopes:) + + expect(result.result).to eq('pass') + end +end