diff --git a/docs/rest/Data/SmartCompartmentResources.json b/docs/rest/Data/SmartCompartmentResources.json index 1938bbd54a..5717eaa817 100644 --- a/docs/rest/Data/SmartCompartmentResources.json +++ b/docs/rest/Data/SmartCompartmentResources.json @@ -4192,6 +4192,128 @@ "method": "PUT", "url": "Observation/smart-observation-smartUserClient" } + }, + { + "resource": { + "resourceType": "Observation", + "id": "smart-observation-2-smartUserClient", + "text": { + "status": "extensions", + "div": "

Generated Narrative

Resource "example-genetics-1"

Gene: EGFR (HUGO Gene Nomenclature#3236)

DNARegionName: Exon 21

GenomicSourceClass: somatic (LOINC#LA6684-0)

status: final

code: The material on this page will be removed in a future release. This content is deprecated and SHOULD NOT be used. Implementers are instead directed to the ([Genomics Reporting Implementation Guide](http://hl7.org/fhir/uv/genomics-reporting/index.html)) for guidance. Genetic analysis master panel-- This is the parent OBR for the panel holding all of the associated observations that can be reported with a molecular genetics analysis result. (LOINC#55233-1)

subject: Patient/example: Molecular Lab Patient ID: HOSP-23456 "Peter CHALMERS"

effective: 2013-04-03T15:30:10+01:00

performer: Practitioner/example: Molecular Diagnostics Laboratory "Adam CAREFUL"

value: Positive (SNOMED CT#10828004)

device: Device/example

" + }, + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/observation-geneticsGene", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://www.genenames.org", + "code": "3236", + "display": "EGFR" + } + ] + } + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/observation-geneticsDNARegionName", + "valueString": "Exon 21" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/observation-geneticsGenomicSourceClass", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://loinc.org", + "code": "LA6684-0", + "display": "somatic" + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "55233-1", + "display": "The material on this page will be removed in a future release. This content is deprecated and SHOULD NOT be used. Implementers are instead directed to the ([Genomics Reporting Implementation Guide](http://hl7.org/fhir/uv/genomics-reporting/index.html)) for guidance. Genetic analysis master panel-- This is the parent OBR for the panel holding all of the associated observations that can be reported with a molecular genetics analysis result." + } + ] + }, + "subject": { + "reference": "Patient/smartUserClient", + "display": "Molecular Lab Patient ID: HOSP-23456" + }, + "effectiveDateTime": "2013-04-03T15:30:10+01:00", + "performer": [ + { + "reference": "Practitioner/smart-practitioner-B", + "display": "Molecular Diagnostics Laboratory" + } + ], + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "10828004", + "display": "Positive" + } + ] + } + }, + "request": { + "method": "PUT", + "url": "Observation/smart-observation-2-smartUserClient" + } + }, + { + "resource": { + "resourceType": "Encounter", + "id": "smart-Encounter-1-smartUserClient", + "text": { + "status": "generated", + "div": "

Generated Narrative

Resource "xcda"

identifier: id: 1234213.52345873 (OFFICIAL)

status: finished

class: ambulatory (Details: http://terminology.hl7.org/CodeSystem/v3-ActCode code AMB = 'ambulatory', stated as 'ambulatory')

subject: Patient/xcda "Henry LEVIN"

Participants

-Individual
*Practitioner/xcda1 "Sherry DOPPLEMEYER"

reasonCode: Arm (eventCodes#T-D8200)

" + }, + "identifier": [ + { + "use": "official", + "system": "http://healthcare.example.org/identifiers/enocunter", + "value": "1234213.52345873" + } + ], + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB", + "display": "ambulatory" + }, + "subject": { + "reference": "Patient/smartUserClient" + }, + "participant": [ + { + "individual": { + "reference": "Practitioner/smart-practitioner-B" + } + } + ], + "reasonCode": [ + { + "coding": [ + { + "system": "http://ihe.net/xds/connectathon/eventCodes", + "code": "T-D8200", + "display": "Arm" + } + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "Encounter/smart-Encounter-1-smartUserClient" + } } ] } \ No newline at end of file diff --git a/docs/rest/SMARTv2ScopesExample.http b/docs/rest/SMARTv2ScopesExample.http new file mode 100644 index 0000000000..e8589547ef --- /dev/null +++ b/docs/rest/SMARTv2ScopesExample.http @@ -0,0 +1,473 @@ +# SMART v2 Scopes Example +# This file demonstrates the new SMART v2 granular permission model +# Please note that to use this file for local testing +# you must make an update in the appsettings.json +# FhirServer:Security:Authorization:ScopesClaim = "scope" +# Due to the in-memory Identity Provider using "scope" +# as the claim name for scopes, which is not the default + +@hostname = localhost:44348 + +### Test rest client - verify server is running +https://{{hostname}}/metadata + +### Get the globalAdminServicePrincipal to verify scopes not enforced, and to be able to POST test data +# @name adminBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=globalAdminServicePrincipal +&client_secret=globalAdminServicePrincipal +&scope=fhir-api + +### POST test data for SMART v2 testing +POST https://{{hostname}} +content-type: application/json +Authorization: Bearer {{adminBearer.response.body.access_token}} + +< ./Data/SmartCompartmentResources.json + +################################################################## +### SMART v2 SEARCH Permission Tests +################################################################## + +### Get token with SMART v2 Search permission +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.s patient/Observation.s + +### Test Search permission - should succeed +GET https://{{hostname}}/Patient?name=smartUserClient +Authorization: Bearer {{searchBearer.response.body.access_token}} + +### Test Search permission - allowed but doesn't find patient +GET https://{{hostname}}/Patient?name=smart-patient-C +Authorization: Bearer {{searchBearer.response.body.access_token}} + +### Test Search permission with chained search - should succeed +GET https://{{hostname}}/Observation?subject:Patient.name=smartUserClient +Authorization: Bearer {{searchBearer.response.body.access_token}} + +### Test direct read with Search permission only - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{searchBearer.response.body.access_token}} + +################################################################## +### SMART v2 READ Permission Tests +################################################################## + +### Get token with SMART v2 Read permission +# @name readBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smart-patient-A +&client_secret=smart-patient-A +&scope=patient/Patient.r fhir-api + +### Test Read permission - should succeed +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{readBearer.response.body.access_token}} + +### Test Read permission - allowed but won't find Patient +GET https://{{hostname}}/Patient/smart-patient-B +Authorization: Bearer {{readBearer.response.body.access_token}} + +### Test search with Read permission only - should fail (403) +GET https://{{hostname}}/Patient?name=smart-patient-A +Authorization: Bearer {{readBearer.response.body.access_token}} + +################################################################## +### SMART v2 CREATE Permission Tests +################################################################## + +### Get token with SMART v2 Create permission +# @name createBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.c + +### Test Create permission - should succeed +POST https://{{hostname}}/Patient +content-type: application/json +Authorization: Bearer {{createBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["SMART", "v2"], + "family": "TestPatient" + } + ] +} + +### Test read with Create permission only - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{createBearer.response.body.access_token}} + +################################################################## +### SMART v2 UPDATE Permission Tests +################################################################## + +### Get token with SMART v2 Update permission +# @name updateBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.u + +### Test Update permission - should succeed +PUT https://{{hostname}}/Patient/smart-patient-A +content-type: application/json +Authorization: Bearer {{updateBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "id": "smart-patient-A", + "name": [ + { + "given": ["SMART", "Updated"], + "family": "TestPatient" + } + ] +} + +### Test read with Update permission only - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{updateBearer.response.body.access_token}} + +################################################################## +### SMART v2 DELETE Permission Tests +################################################################## + +### Get token with SMART v2 Delete permission +# @name deleteBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.d + +### Test Delete permission (soft delete) - should succeed +DELETE https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{deleteBearer.response.body.access_token}} + +### Test Hard Delete permission - should fao; +DELETE https://{{hostname}}/Patient/smart-patient-A?hardDelete=true +Authorization: Bearer {{deleteBearer.response.body.access_token}} + +### Test read with Delete permission only - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{deleteBearer.response.body.access_token}} + +################################################################## +### SMART v2 Combined Permissions Tests +################################################################## + +### Get token with SMART v2 Search + Read permissions +# @name searchReadBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.rs + +### Test Search + Read - both should succeed +GET https://{{hostname}}/Patient?name=SMARTGivenName1 +Authorization: Bearer {{searchReadBearer.response.body.access_token}} + +### Test direct read with Search + Read +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{searchReadBearer.response.body.access_token}} + +### Get token with SMART v2 CRUD permissions +# @name crudBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smart-patient-A +&client_secret=smart-patient-A +&scope=patient/Patient.cruds + +### Test full CRUDS operations +# Create +POST https://{{hostname}}/Patient +content-type: application/json +Authorization: Bearer {{crudBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["SMART", "v2", "CRUD"], + "family": "TestPatient" + } + ] +} + +# Read - should succeed with cruds scope +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{crudBearer.response.body.access_token}} + +# Search - should succeed with cruds scope +GET https://{{hostname}}/Patient?name=SMARTGivenName1 +Authorization: Bearer {{crudBearer.response.body.access_token}} + +# Update - should succeed with cruds scope +PUT https://{{hostname}}/Patient/smart-patient-A +content-type: application/json +Authorization: Bearer {{crudBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "id": "smart-patient-A", + "name": [ + { + "given": ["SMART", "v2", "CRUD", "Updated"], + "family": "TestPatient" + } + ] +} + +# Delete - should succeed with cruds scope +DELETE https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{crudBearer.response.body.access_token}} + +################################################################## +### SMART v2 Cross-Resource Type Tests +################################################################## + +### Get token with mixed resource permissions +# @name mixedBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smart-patient-A +&client_secret=smart-patient-A +&scope=patient/Patient.read patient/Observation.search patient/Encounter.create + +### Test Patient read - should succeed +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{mixedBearer.response.body.access_token}} + +### Test Observation search - should succeed +GET https://{{hostname}}/Observation?subject=smart-patient-A +Authorization: Bearer {{mixedBearer.response.body.access_token}} + +### Test Encounter create - should succeed +POST https://{{hostname}}/Encounter +content-type: application/json +Authorization: Bearer {{mixedBearer.response.body.access_token}} + +{ + "resourceType": "Encounter", + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB" + }, + "subject": { + "reference": "Patient/smart-patient-A" + } +} + +### Test Patient search - should fail (403, no search permission) +GET https://{{hostname}}/Patient?name=SMARTGivenName1 +Authorization: Bearer {{mixedBearer.response.body.access_token}} + +################################################################## +### SMART v2 Conditional Operations Tests +################################################################## + +### Get token with Search + Update for conditional operations +# @name conditionalBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.search patient/Patient.update + +### Test conditional update - should succeed (requires both search and update) +PUT https://{{hostname}}/Patient?name=SMARTGivenName1 +content-type: application/json +Authorization: Bearer {{conditionalBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["SMART", "Conditional"], + "family": "Updated" + } + ] +} + +### Get token with Search + Delete for conditional delete +# @name conditionalDeleteBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.search patient/Patient.delete + +### Test conditional delete - should succeed (requires both search and delete) +DELETE https://{{hostname}}/Patient?name=SMARTConditional +Authorization: Bearer {{conditionalDeleteBearer.response.body.access_token}} + +################################################################## +### SMART v2 Bundle Operations Tests +################################################################## + +### Get token with mixed permissions for bundle testing +# @name bundleBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.read patient/Patient.create patient/Observation.read + +### Test bundle with mixed permissions - expect partial success/failure +POST https://{{hostname}} +content-type: application/json +Authorization: Bearer {{bundleBearer.response.body.access_token}} + +{ + "resourceType": "Bundle", + "type": "batch", + "entry": [ + { + "request": { + "method": "GET", + "url": "Patient/smart-patient-A" + } + }, + { + "request": { + "method": "POST", + "url": "Patient" + }, + "resource": { + "resourceType": "Patient", + "name": [ + { + "given": ["Bundle"], + "family": "Test" + } + ] + } + }, + { + "request": { + "method": "GET", + "url": "Observation?subject=smart-patient-A" + } + } + ] +} + +################################################################## +### SMART v2 Wildcard vs Granular Permission Comparison +################################################################## + +### Get token with wildcard permission (legacy style) +# @name wildcardBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/*.* + +### Test wildcard access - should succeed for all operations +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{wildcardBearer.response.body.access_token}} + +### Search with wildcard +GET https://{{hostname}}/Patient?name=SMARTGivenName1 +Authorization: Bearer {{wildcardBearer.response.body.access_token}} + +### Create with wildcard +POST https://{{hostname}}/Patient +content-type: application/json +Authorization: Bearer {{wildcardBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["Wildcard"], + "family": "Test" + } + ] +} + +################################################################## +### SMART v2 Error Scenarios +################################################################## + +### Get token with no permissions +# @name noPermBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=fhirUser + +### Test with no resource permissions - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{noPermBearer.response.body.access_token}} + +### Test with insufficient permissions for conditional operations +# @name insufficientBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.update + +### Test conditional update without search permission - should fail (403) +PUT https://{{hostname}}/Patient?name=SMARTGivenName1 +content-type: application/json +Authorization: Bearer {{insufficientBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["Should"], + "family": "Fail" + } + ] +} diff --git a/docs/rest/SMARTv2ScopesWithSearchParametersExample.http b/docs/rest/SMARTv2ScopesWithSearchParametersExample.http new file mode 100644 index 0000000000..c1cd409fde --- /dev/null +++ b/docs/rest/SMARTv2ScopesWithSearchParametersExample.http @@ -0,0 +1,720 @@ +# SMART v2 Scopes Example +# This file demonstrates the new SMART v2 granular permission model +# Please note that to use this file for local testing +# you must make an update in the appsettings.json +# FhirServer:Security:Authorization:ScopesClaim = "scope" +# Due to the in-memory Identity Provider using "scope" +# as the claim name for scopes, which is not the default + +@hostname = localhost:44348 + +### Test rest client - verify server is running +https://{{hostname}}/metadata + +### Get the globalAdminServicePrincipal to verify scopes not enforced, and to be able to POST test data +# @name adminBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=globalAdminServicePrincipal +&client_secret=globalAdminServicePrincipal +&scope=fhir-api + +### POST test data for SMART v2 testing +POST https://{{hostname}} +content-type: application/json +Authorization: Bearer {{adminBearer.response.body.access_token}} + +< ./Data/SmartCompartmentResources.json + +################################################################## +### SMART v2 SEARCH Permission Tests +################################################################## + +### Get token with SMART v2 Search permission for all resource types +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/*.s + +### Should succeed and return compartment resources for smartUserClient +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Observation.s + +### Should succeed and return Observations for smartUserClient +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation and Encounter +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Observation.s patient/Encounter.s + +### Should succeed and return Observations and Encounters for smartUserClient +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation and all resource types +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Observation.s patient/*.s + +### Should succeed and return all compartment resources for smartUserClient +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for all types with _tag search parameter 12345 +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Observation.s patient/*.s?_tag=12345 + +### Should succeed and return all compartment resources for smartUserClient with tag 12345 +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation with status=final +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Observation.s?code=loinc patient/*.s?_type=Encounter,Observation + +### Test Search permission with chained search - should succeed and return Observations for smartUserClient with status=final +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation with status=final +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Observation.s?code=loinc patient/*.s?_type=Encounter,Practitioner + +### Test Search permission with chained search - should succeed and return Observations for smartUserClient with status=final +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation with status=final +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Observation.s?code=loinc patient/Practitioner.s?city=seattle + +### Test Search permission with chained search - should succeed and return Observations for smartUserClient with status=final +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation with status=final +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Observation.s?code=loinc&status=active patient/Practitioner.s?city=seattle + +### Test Search permission with chained search - should succeed and return Observations for smartUserClient with status=final +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation with status=final +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/*.s?_type=Observation,Encounter + +### Test Search permission with chained search - should succeed and return Observations for smartUserClient with status=final +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation with status=final +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/*.s?_type=Observation,Encounter&tag=12345 + +### Test Search permission with chained search - should succeed and return Observations for smartUserClient with status=final +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation with status=final +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.s patient/Observation.s?status=final + +### Test Search permission with chained search - should succeed and return Observations for smartUserClient with status=final +GET https://{{hostname}}/Observation?subject:Patient.name=smartUserClient +Authorization: Bearer {{searchBearer.response.body.access_token}} + +######################################################################################################################################## +### Get token with SMART v2 Search permission for Observation with status=registered +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.s patient/Observation.s?status=registered + +### Test Search permission with chained search - should succeed and would not return any Observation as none have status=registered +GET https://{{hostname}}/Observation?subject:Patient.name=smartUserClient +Authorization: Bearer {{searchBearer.response.body.access_token}} + +########################################################################################################################################## +### Get token with SMART v2 Search permission for all resource types +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.s patient/*.s + +##Issue +### Test Search permission - should return all resource types in the patient compartment plus universal resources like Practitioner +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +########################################################################################################################################### +### Get token with SMART v2 Search permission for all resource types +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.s patient/*.s?_type=Patient,Practitioner,Observation,Encounter + +### Test Search permission - should return only Patient,Practitioner,Observation,Encounter resource types in the patient compartment +GET https://{{hostname}} +Authorization: Bearer {{searchBearer.response.body.access_token}} + +############################################################################################################################################# +### Get token with SMART v2 Search permission +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.s patient/*.s + +### Test Search permission - should include patient smartUserClient, and all the resources that reference the patient directly such as Observation, Encounter. +GET https://{{hostname}}/Patient?_revinclude=*&_id=smartUserClient +Authorization: Bearer {{searchBearer.response.body.access_token}} + +################################################################################################################################################# +### Get token with SMART v2 Search permission +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.s patient/Encounter.s + +# check with Jared why its different from above +# should at least return encounters? should it return universal resources? +# is this compartment search? +### Test Search permission - should include patient smartUserClient, and Encounter that references this user. +GET https://{{hostname}}/Patient?_revinclude=*&_id=smartUserClient +Authorization: Bearer {{searchBearer.response.body.access_token}} + +################################################################################################################################################# +### Get token with SMART v2 Search permission +# @name searchBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.s patient/Observation.s + +### Test Search permission - should include patient smartUserClient, Practitioner smart-practitioner-B, and Organization smart-organization-C1 +GET https://{{hostname}}/Observation?_include=* +Authorization: Bearer {{adminBearer.response.body.access_token}} +### SMART v2 READ Permission Tests +################################################################################################################################################# + +### Get token with SMART v2 Read permission +# @name readBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smart-patient-A +&client_secret=smart-patient-A +&scope=patient/Patient.r fhir-api + +### Test Read permission - should succeed +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{readBearer.response.body.access_token}} + +### Test Read permission - allowed but won't find Patient +GET https://{{hostname}}/Patient/smart-patient-B +Authorization: Bearer {{readBearer.response.body.access_token}} + +### Test search with Read permission only - should fail (403) +GET https://{{hostname}}/Patient?name=smart-patient-A +Authorization: Bearer {{readBearer.response.body.access_token}} + +################################################################## +### SMART v2 CREATE Permission Tests +################################################################## + +### Get token with SMART v2 Create permission +# @name createBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.c + +### Test Create permission - should succeed +POST https://{{hostname}}/Patient +content-type: application/json +Authorization: Bearer {{createBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["SMART", "v2"], + "family": "TestPatient" + } + ] +} + +### Test read with Create permission only - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{createBearer.response.body.access_token}} + +################################################################## +### SMART v2 UPDATE Permission Tests +################################################################## + +### Get token with SMART v2 Update permission +# @name updateBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.u + +### Test Update permission - should succeed +PUT https://{{hostname}}/Patient/smart-patient-A +content-type: application/json +Authorization: Bearer {{updateBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "id": "smart-patient-A", + "name": [ + { + "given": ["SMART", "Updated"], + "family": "TestPatient" + } + ] +} + +### Test read with Update permission only - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{updateBearer.response.body.access_token}} + +################################################################## +### SMART v2 DELETE Permission Tests +################################################################## + +### Get token with SMART v2 Delete permission +# @name deleteBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.d + +### Test Delete permission (soft delete) - should succeed +DELETE https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{deleteBearer.response.body.access_token}} + +### Test Hard Delete permission - should fao; +DELETE https://{{hostname}}/Patient/smart-patient-A?hardDelete=true +Authorization: Bearer {{deleteBearer.response.body.access_token}} + +### Test read with Delete permission only - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{deleteBearer.response.body.access_token}} + +################################################################## +### SMART v2 Combined Permissions Tests +################################################################## + +### Get token with SMART v2 Search + Read permissions +# @name searchReadBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.rs + +### Test Search + Read - both should succeed +GET https://{{hostname}}/Patient?name=SMARTGivenName1 +Authorization: Bearer {{searchReadBearer.response.body.access_token}} + +### Test direct read with Search + Read +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{searchReadBearer.response.body.access_token}} + +### Get token with SMART v2 CRUD permissions +# @name crudBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smart-patient-A +&client_secret=smart-patient-A +&scope=patient/Patient.cruds + +### Test full CRUDS operations +# Create +POST https://{{hostname}}/Patient +content-type: application/json +Authorization: Bearer {{crudBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["SMART", "v2", "CRUD"], + "family": "TestPatient" + } + ] +} + +# Read - should succeed with cruds scope +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{crudBearer.response.body.access_token}} + +# Search - should succeed with cruds scope +GET https://{{hostname}}/Patient?name=SMARTGivenName1 +Authorization: Bearer {{crudBearer.response.body.access_token}} + +# Update - should succeed with cruds scope +PUT https://{{hostname}}/Patient/smart-patient-A +content-type: application/json +Authorization: Bearer {{crudBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "id": "smart-patient-A", + "name": [ + { + "given": ["SMART", "v2", "CRUD", "Updated"], + "family": "TestPatient" + } + ] +} + +# Delete - should succeed with cruds scope +DELETE https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{crudBearer.response.body.access_token}} + +################################################################## +### SMART v2 Cross-Resource Type Tests +################################################################## + +### Get token with mixed resource permissions +# @name mixedBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smart-patient-A +&client_secret=smart-patient-A +&scope=patient/Patient.read patient/Observation.search patient/Encounter.create + +### Test Patient read - should succeed +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{mixedBearer.response.body.access_token}} + +### Test Observation search - should succeed +GET https://{{hostname}}/Observation?subject=smart-patient-A +Authorization: Bearer {{mixedBearer.response.body.access_token}} + +### Test Encounter create - should succeed +POST https://{{hostname}}/Encounter +content-type: application/json +Authorization: Bearer {{mixedBearer.response.body.access_token}} + +{ + "resourceType": "Encounter", + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB" + }, + "subject": { + "reference": "Patient/smart-patient-A" + } +} + +### Test Patient search - should fail (403, no search permission) +GET https://{{hostname}}/Patient?name=SMARTGivenName1 +Authorization: Bearer {{mixedBearer.response.body.access_token}} + +################################################################## +### SMART v2 Conditional Operations Tests +################################################################## + +### Get token with Search + Update for conditional operations +# @name conditionalBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.search patient/Patient.update + +### Test conditional update - should succeed (requires both search and update) +PUT https://{{hostname}}/Patient?name=SMARTGivenName1 +content-type: application/json +Authorization: Bearer {{conditionalBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["SMART", "Conditional"], + "family": "Updated" + } + ] +} + +### Get token with Search + Delete for conditional delete +# @name conditionalDeleteBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.search patient/Patient.delete + +### Test conditional delete - should succeed (requires both search and delete) +DELETE https://{{hostname}}/Patient?name=SMARTConditional +Authorization: Bearer {{conditionalDeleteBearer.response.body.access_token}} + +################################################################## +### SMART v2 Bundle Operations Tests +################################################################## + +### Get token with mixed permissions for bundle testing +# @name bundleBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.read patient/Patient.create patient/Observation.read + +### Test bundle with mixed permissions - expect partial success/failure +POST https://{{hostname}} +content-type: application/json +Authorization: Bearer {{bundleBearer.response.body.access_token}} + +{ + "resourceType": "Bundle", + "type": "batch", + "entry": [ + { + "request": { + "method": "GET", + "url": "Patient/smart-patient-A" + } + }, + { + "request": { + "method": "POST", + "url": "Patient" + }, + "resource": { + "resourceType": "Patient", + "name": [ + { + "given": ["Bundle"], + "family": "Test" + } + ] + } + }, + { + "request": { + "method": "GET", + "url": "Observation?subject=smart-patient-A" + } + } + ] +} + +################################################################## +### SMART v2 Wildcard vs Granular Permission Comparison +################################################################## + +### Get token with wildcard permission (legacy style) +# @name wildcardBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/*.* + +### Test wildcard access - should succeed for all operations +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{wildcardBearer.response.body.access_token}} + +### Search with wildcard +GET https://{{hostname}}/Patient?name=SMARTGivenName1 +Authorization: Bearer {{wildcardBearer.response.body.access_token}} + +### Create with wildcard +POST https://{{hostname}}/Patient +content-type: application/json +Authorization: Bearer {{wildcardBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["Wildcard"], + "family": "Test" + } + ] +} + +################################################################## +### SMART v2 Error Scenarios +################################################################## + +### Get token with no permissions +# @name noPermBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=fhirUser + +### Test with no resource permissions - should fail (403) +GET https://{{hostname}}/Patient/smart-patient-A +Authorization: Bearer {{noPermBearer.response.body.access_token}} + +### Test with insufficient permissions for conditional operations +# @name insufficientBearer +POST https://{{hostname}}/connect/token +content-type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=smartUserClient +&client_secret=smartUserClient +&scope=patient/Patient.update + +### Test conditional update without search permission - should fail (403) +PUT https://{{hostname}}/Patient?name=SMARTGivenName1 +content-type: application/json +Authorization: Bearer {{insufficientBearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "name": [ + { + "given": ["Should"], + "family": "Fail" + } + ] +} diff --git a/src/Microsoft.Health.Fhir.Api.OpenIddict/Controllers/OpenIddictAuthorizationController.cs b/src/Microsoft.Health.Fhir.Api.OpenIddict/Controllers/OpenIddictAuthorizationController.cs index 4f6fbfeb3f..7c96167b33 100644 --- a/src/Microsoft.Health.Fhir.Api.OpenIddict/Controllers/OpenIddictAuthorizationController.cs +++ b/src/Microsoft.Health.Fhir.Api.OpenIddict/Controllers/OpenIddictAuthorizationController.cs @@ -45,7 +45,9 @@ public OpenIddictAuthorizationController( [AllowAnonymous] public async Task Token() { - var request = HttpContext.Features.Get()?.Transaction?.Request; + var feature = HttpContext.Features.Get(); + var transaction = feature?.Transaction; + var request = transaction?.Request; if (request == null) { throw new RequestNotValidException("Invalid request: null"); @@ -71,6 +73,7 @@ public async Task Token() // Add the claims that will be persisted in the tokens (use the client_id as the subject identifier). identity.SetClaim(Claims.Subject, await _applicationManager.GetClientIdAsync(application)); identity.SetClaim(Claims.Name, await _applicationManager.GetDisplayNameAsync(application)); + identity.SetClaim("fhirUser", CreateFhirUserClaim(request.ClientId, HttpContext.Request.Host.ToString())); var permissions = await _applicationManager.GetPermissionsAsync(application); var roles = permissions.Where(x => x.StartsWith($"{_authorizationConfiguration.RolesClaim}:", StringComparison.Ordinal)); @@ -85,7 +88,16 @@ public async Task Token() // Set the list of scopes granted to the client application in access_token. identity.SetScopes(request.GetScopes()); - identity.SetResources(await ToListAsync(_scopeManager.ListResourcesAsync(identity.GetScopes()))); + var resources = await ToListAsync(_scopeManager.ListResourcesAsync(identity.GetScopes())); + resources.Add("fhir-api"); + identity.SetResources(resources); + + // Add a custom claim for the raw scope with dynamic query parameters. + if (transaction.Properties.TryGetValue("raw_scope", out var rawScopeObj) && rawScopeObj is string rawScope) + { + identity.SetClaim("raw_scope", rawScope); + } + identity.SetDestinations(GetDestinations); return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); @@ -123,5 +135,29 @@ async Task> ExecuteAsync() return list; } } + + private static string CreateFhirUserClaim(string userId, string host) + { + string userType = null; + + if (userId.Contains("patient", StringComparison.OrdinalIgnoreCase)) + { + userType = "Patient"; + } + else if (userId.Contains("practitioner", StringComparison.OrdinalIgnoreCase)) + { + userType = "Practitioner"; + } + else if (userId.Contains("system", StringComparison.OrdinalIgnoreCase)) + { + userType = "System"; + } + else if (userId.Contains("smartUserClient", StringComparison.OrdinalIgnoreCase)) + { + userType = "Patient"; + } + + return $"https://{host}/{userType}/" + userId; + } } } diff --git a/src/Microsoft.Health.Fhir.Api.OpenIddict/Extensions/DevelopmentIdentityProviderRegistrationExtensions.cs b/src/Microsoft.Health.Fhir.Api.OpenIddict/Extensions/DevelopmentIdentityProviderRegistrationExtensions.cs index 224f422e62..640002ac9e 100644 --- a/src/Microsoft.Health.Fhir.Api.OpenIddict/Extensions/DevelopmentIdentityProviderRegistrationExtensions.cs +++ b/src/Microsoft.Health.Fhir.Api.OpenIddict/Extensions/DevelopmentIdentityProviderRegistrationExtensions.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Configuration.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Health.Fhir.Api.OpenIddict.Configuration; using Microsoft.Health.Fhir.Api.OpenIddict.Controllers; @@ -28,6 +29,7 @@ using OpenIddict.Abstractions; using OpenIddict.Server; using OpenIddict.Validation.AspNetCore; +using static OpenIddict.Server.OpenIddictServerEvents; namespace Microsoft.Health.Fhir.Api.OpenIddict.Extensions { @@ -118,9 +120,44 @@ public static IServiceCollection AddDevelopmentIdentityProvider(this IServiceCol options.RegisterScopes(AllowedScopes); options.RegisterScopes(smartScopes.ToArray()); + options.RegisterClaims("fhirUser"); + // Enable this line if we choose to disable some of the default validation handler OpenIddict uses. // options.EnableDegradedMode(); + // Custom event handlers to replace dynamic search parameters in SMART v2 scopes with wildcards. + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + if (!string.IsNullOrEmpty(context.Request.Scope)) + { + // Store the original scope value. + context.Transaction.Properties["raw_scope"] = context.Request.Scope; + + var originalScopes = context.Request.Scope.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var normalizedScopes = new List(); + + foreach (var scope in originalScopes) + { + if (scope.Contains('?', StringComparison.CurrentCultureIgnoreCase)) + { + int index = scope.IndexOf('?', StringComparison.CurrentCultureIgnoreCase); + + // Replace the dynamic query part with a fixed wildcard. + normalizedScopes.Add(string.Concat(scope.AsSpan(0, index), "?*")); + } + else + { + normalizedScopes.Add(scope); + } + } + + context.Request.Scope = string.Join(" ", normalizedScopes); + } + + return default; + }).SetOrder(int.MinValue)); // Ensure this runs early + // Note: OpenIddict has a default token validation handler that does more granular validation // including checking the cliend Id and secret. So, we may not need this event handler. // https://github.com/openiddict/openiddict-core/blob/38e84b862dc4ac765ee90d673999f6dc97354815/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs#L24 @@ -210,29 +247,123 @@ public static IConfigurationBuilder AddDevelopmentAuthEnvironmentIfConfigured(th return configurationBuilder.Add(new DevelopmentAuthEnvironmentConfigurationSource(testEnvironmentFilePath, existingConfiguration)); } + private static IEnumerable GeneratePermissionCombinations() + { + // Basic permission letters: create, read, update, delete, search. + char[] ops = new[] { 'c', 'r', 'u', 'd', 's' }; + int n = ops.Length; + var sb = new StringBuilder(); + + // There are 2^n - 1 non-empty combinations. + for (int mask = 1; mask < (1 << n); mask++) + { + sb.Clear(); + for (int j = 0; j < n; j++) + { + if ((mask & (1 << j)) != 0) + { + sb.Append(ops[j]); + } + } + + // Do not sort the combination, so the order remains as "cruds". + yield return sb.ToString(); + } + } + internal static List GenerateSmartClinicalScopes() { - var resourceTypes = ModelInfoProvider.Instance.GetResourceTypeNames(); + // Generating the full ist of scopes for all resource types is slow and + // consumes an excessive amount of memory. Instead, we will generate the + // scopes for a subset of the resource types + // var resourceTypes = ModelInfoProvider.Instance.GetResourceTypeNames(); + + var resourceTypes = new[] + { + "AllergyIntolerance", + "Appointment", + "AuditEvent", + "Bundle", + "CarePlan", + "Condition", + "Device", + "DiagnosticReport", + "Encounter", + "Group", + "Immunization", + "MedicationRequest", + "Observation", + "Patient", + "Practitioner", + "Procedure", + "Sequence", + "ServiceRequest", + "ValueSet", + "StructureDefinition", + "Specimen", + "SearchParameter", + "Organization", + "Location", + "Provenance", + "Composition", + "Medication", + "MedicationAdministration", + }; + var scopes = new List(); + // Global wildcard scopes for all resources (SMART v1) scopes.Add("patient/*.*"); - scopes.Add("user/*.*"); + scopes.Add("patient/*.read"); + scopes.Add("patient/*.write"); scopes.Add("system/*.*"); scopes.Add("system/*.read"); - scopes.Add("patient/*.read"); - scopes.Add("user/*.write"); + scopes.Add("system/*.write"); + scopes.Add("user/*.*"); scopes.Add("user/*.read"); + scopes.Add("user/*.write"); foreach (var resourceType in resourceTypes) { + // SMART v1 scopes scopes.Add($"patient/{resourceType}.*"); - scopes.Add($"user/{resourceType}.*"); scopes.Add($"patient/{resourceType}.read"); - scopes.Add($"user/{resourceType}.read"); scopes.Add($"patient/{resourceType}.write"); - scopes.Add($"user/{resourceType}.write"); + scopes.Add($"system/{resourceType}.*"); scopes.Add($"system/{resourceType}.write"); scopes.Add($"system/{resourceType}.read"); + scopes.Add($"user/{resourceType}.*"); + scopes.Add($"user/{resourceType}.read"); + scopes.Add($"user/{resourceType}.write"); + + // SMART v2 granular permission scopes for patient, user, and system contexts. + foreach (var prefix in new[] { "patient", "user", "system" }) + { + foreach (var combo in GeneratePermissionCombinations()) + { + scopes.Add($"{prefix}/{resourceType}.{combo}"); + } + } + + // SMART v2 scopes could have any dynamic search parameter + // We would replace them with wildcard * in auth layer + foreach (var prefix in new[] { "patient", "user", "system" }) + { + foreach (var combo in GeneratePermissionCombinations()) + { + scopes.Add($"{prefix}/{resourceType}.{combo}?*"); + } + } + } + + // SMART v2 granular permission scopes for all resource types with wildcard * + foreach (var prefix in new[] { "patient", "user", "system" }) + { + foreach (var combo in GeneratePermissionCombinations()) + { + scopes.Add($"{prefix}/*.{combo}"); + scopes.Add($"{prefix}/*.{combo}?*"); + } } return scopes; @@ -256,30 +387,6 @@ internal static string Sha256(this string input) return Convert.ToBase64String(hash); } - private static Claim[] CreateFhirUserClaims(string userId, string host) - { - string userType = null; - - if (userId.Contains("patient", StringComparison.OrdinalIgnoreCase)) - { - userType = "Patient"; - } - else if (userId.Contains("practitioner", StringComparison.OrdinalIgnoreCase)) - { - userType = "Practitioner"; - } - else if (userId.Contains("system", StringComparison.OrdinalIgnoreCase)) - { - userType = "System"; - } - - return new[] - { - new Claim("appid", userId), - new Claim("fhirUser", $"{host}{userType}/" + userId), - }; - } - private sealed class DevelopmentAuthEnvironmentConfigurationSource : IConfigurationSource { private readonly string _filePath; diff --git a/src/Microsoft.Health.Fhir.Api.OpenIddict/Services/OpenIddictApplicationCreater.cs b/src/Microsoft.Health.Fhir.Api.OpenIddict/Services/OpenIddictApplicationCreater.cs index aeb86736f3..48bb7a5eb5 100644 --- a/src/Microsoft.Health.Fhir.Api.OpenIddict/Services/OpenIddictApplicationCreater.cs +++ b/src/Microsoft.Health.Fhir.Api.OpenIddict/Services/OpenIddictApplicationCreater.cs @@ -17,6 +17,7 @@ using Microsoft.Health.Fhir.Api.OpenIddict.Extensions; using Microsoft.Health.Fhir.Core.Configs; using OpenIddict.Abstractions; +using static System.Net.Mime.MediaTypeNames; using static OpenIddict.Abstractions.OpenIddictConstants; namespace Microsoft.Health.Fhir.Api.OpenIddict.Services @@ -68,6 +69,22 @@ private Task RegisterApplicationsAsync( EnsureArg.IsNotNull(applicationManager, nameof(applicationManager)); EnsureArg.IsNotNull(applications, nameof(applications)); + var permissionsSet = new HashSet(StringComparer.OrdinalIgnoreCase); + permissionsSet.Add(Permissions.Endpoints.Authorization); + permissionsSet.Add(Permissions.Endpoints.Token); + permissionsSet.Add(Permissions.ResponseTypes.Code); + permissionsSet.Add(Permissions.Scopes.Roles); + + foreach (var grantType in DevelopmentIdentityProviderRegistrationExtensions.AllowedGrantTypes) + { + permissionsSet.Add($"{Permissions.Prefixes.GrantType}{grantType}"); + } + + foreach (var scope in DevelopmentIdentityProviderRegistrationExtensions.AllowedScopes.Concat(DevelopmentIdentityProviderRegistrationExtensions.GenerateSmartClinicalScopes())) + { + permissionsSet.Add($"{Permissions.Prefixes.Scope}{scope}"); + } + applications.ToList().ForEach( async application => { @@ -78,25 +95,10 @@ private Task RegisterApplicationsAsync( // TODO: encoding the client secret will cause the token validator to fail, need to investigate why... ClientId = application.Id, ClientSecret = application.Id, - Permissions = - { - Permissions.Endpoints.Authorization, - Permissions.Endpoints.Token, - Permissions.ResponseTypes.Code, - Permissions.Scopes.Roles, - }, RedirectUris = { new Uri("http://localhost") }, }; - foreach (var grantType in DevelopmentIdentityProviderRegistrationExtensions.AllowedGrantTypes) - { - applicationDescriptor.Permissions.Add($"{Permissions.Prefixes.GrantType}{grantType}"); - } - - foreach (var scope in DevelopmentIdentityProviderRegistrationExtensions.AllowedScopes.Concat(DevelopmentIdentityProviderRegistrationExtensions.GenerateSmartClinicalScopes())) - { - applicationDescriptor.Permissions.Add($"{Permissions.Prefixes.Scope}{scope}"); - } + applicationDescriptor.Permissions.UnionWith(permissionsSet); foreach (var role in application.Roles) { @@ -127,15 +129,15 @@ private static Task RegisterScopesAsync( if (await scopeManager.FindByNameAsync(scope) is null) { await scopeManager.CreateAsync( - new OpenIddictScopeDescriptor - { - Name = scope, - Resources = - { + new OpenIddictScopeDescriptor + { + Name = scope, + Resources = + { scope, - }, - }, - cancellationToken); + }, + }, + cancellationToken); } }); diff --git a/src/Microsoft.Health.Fhir.Api/Features/SMART/SmartClinicalScopesMiddleware.cs b/src/Microsoft.Health.Fhir.Api/Features/SMART/SmartClinicalScopesMiddleware.cs index 4905eeaa98..def41363fa 100644 --- a/src/Microsoft.Health.Fhir.Api/Features/SMART/SmartClinicalScopesMiddleware.cs +++ b/src/Microsoft.Health.Fhir.Api/Features/SMART/SmartClinicalScopesMiddleware.cs @@ -4,11 +4,14 @@ // ------------------------------------------------------------------------------------------------- using System; +using System.Collections.Generic; +using System.Linq; using System.Security.Claims; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using EnsureThat; +using Hl7.Fhir.Rest; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -28,11 +31,14 @@ namespace Microsoft.Health.Fhir.Api.Features.Smart public class SmartClinicalScopesMiddleware { private readonly RequestDelegate _next; - private const string AllDataActions = "all"; private readonly ILogger _logger; - // Regex based on SMART on FHIR clinical scopes v1.0, http://hl7.org/fhir/smart-app-launch/1.0.0/scopes-and-launch-context/index.html#clinical-scope-syntax - private static readonly Regex ClinicalScopeRegEx = new Regex(@"(^|\s+)(?patient|user|system)(/|\$|\.)(?\*|([a-zA-Z]*)|all)\.(?read|write|\*|all)", RegexOptions.Compiled); + // Regex based on SMART on FHIR clinical scopes v1.0 and v2.0 + // v1: http://hl7.org/fhir/smart-app-launch/1.0.0/scopes-and-launch-context/index.html#clinical-scope-syntax + // v2: http://hl7.org/fhir/smart-app-launch/scopes-and-launch-context/index.html#scopes-for-requesting-fhir-resources + private static readonly Regex ClinicalScopeRegEx = new Regex( + @"(^|\s+)(?patient|user|system)(/|\$|\.)(?\*|[a-zA-Z]*|all)\.(?read|write|\*|all|[cruds]+)(?:\?(?([a-zA-Z0-9_\-]+=[^&\s]+)(&[a-zA-Z0-9_\-]+=[^&\s]+)*))?", + RegexOptions.Compiled); public SmartClinicalScopesMiddleware(RequestDelegate next, ILogger logger) { @@ -43,6 +49,66 @@ public SmartClinicalScopesMiddleware(RequestDelegate next, ILogger + /// Parse SMART scope permissions supporting both v1 and v2 formats. + /// v1: read, write, *, all + /// v2: c (create), r (read), u (update), d (delete), s (search) + /// + /// The access level from the scope (e.g., "read", "rs", "cruds") + /// DataActions representing the permissions + private static DataActions ParseScopePermissions(string accessLevel) + { + if (string.IsNullOrEmpty(accessLevel)) + { + return DataActions.None; + } + + // Handle v1 scope formats first for backward compatibility + switch (accessLevel.ToLowerInvariant()) + { + case "read": + // v1 read includes both read and search permissions + return DataActions.Read | DataActions.Export | DataActions.Search; + case "write": + // v1 write includes create, update, delete, and legacy write permissions + return DataActions.Write | DataActions.Create | DataActions.Update | DataActions.Delete; + case "*": + case "all": + // Full access includes all permissions + return DataActions.Read | DataActions.Write | DataActions.Export | DataActions.Search | + DataActions.Create | DataActions.Update | DataActions.Delete; + } + + // Handle v2 scope format (e.g., "rs", "cruds") + var permissions = DataActions.None; + foreach (char permission in accessLevel.ToLowerInvariant()) + { + switch (permission) + { + case 'c': + permissions |= DataActions.Create; // SMART v2 granular create permission + break; + case 'r': + permissions |= DataActions.ReadById; // SMART v2 read-only (no search) + break; + case 'u': + permissions |= DataActions.Update; // SMART v2 granular update permission + break; + case 'd': + permissions |= DataActions.Delete; // SMART v2 granular delete permission + break; + case 's': + permissions |= DataActions.Search | DataActions.Export; // Search is a separate permission in v2 + break; + default: + // Unknown permission character - log warning but continue + break; + } + } + + return permissions; + } + public async Task Invoke( HttpContext context, RequestContextAccessor fhirRequestContextAccessor, @@ -77,39 +143,74 @@ public async Task Invoke( // examine the scopes claim for any SMART on FHIR clinical scopes DataActions permittedDataActions = 0; + var scopeClaimsBuilder = new StringBuilder(); string scopeClaims = string.Empty; foreach (string singleScope in authorizationConfiguration.ScopesClaim) { - foreach (Claim claim in principal.FindAll(singleScope)) + // To support SMART V2 Finer-grained resource constraints using search parameters in OpenIdDict we are replacing the search parameters with wild card * + // For example Patient/Observation.rd?category=blah will be Patient/Observation.rd?* + // We are storing the original scopes in raw_Scope + // If the raw_Scope is non empty then use that as a scopeClaims + // In all the other cases (including anything other than OpenIdDict) keep reading from all the possible scopes like scp, scope, roles + if (!string.IsNullOrEmpty(principal.FindFirstValue("raw_scope"))) + { + scopeClaims = principal.FindFirstValue("raw_scope"); + break; + } + else { - scopeClaims += " " + string.Join(" ", claim.Value); + foreach (Claim claim in principal.FindAll(singleScope)) + { + scopeClaimsBuilder.Append(' ').Append(claim.Value); + } } } + if (string.IsNullOrEmpty(scopeClaims)) + { + scopeClaims = scopeClaimsBuilder.ToString(); + } + var matches = ClinicalScopeRegEx.Matches(scopeClaims); + bool smartV1AccessLevelUsed = false; + bool smartV2AccessLevelUsed = false; foreach (Match match in matches) { - fhirRequestContext.AccessControlContext.ClinicalScopes.Add(match.Value); - - var id = match.Groups["id"]?.Value; - var resource = match.Groups["resource"]?.Value; var accessLevel = match.Groups["accessLevel"]?.Value; + if (string.IsNullOrEmpty(accessLevel)) + { + continue; + } + + // Detect v1 vs v2 based on the accessLevel value. + // v1 uses: "read", "write", "*", "all" + // v2 uses: letters from "cruds" (e.g., "c", "r", "u", "d", "s" or any combination) + if (accessLevel.Equals("read", StringComparison.OrdinalIgnoreCase) || + accessLevel.Equals("write", StringComparison.OrdinalIgnoreCase) || + accessLevel.Equals("*", StringComparison.OrdinalIgnoreCase) || + accessLevel.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + smartV1AccessLevelUsed = true; + } + else + { + smartV2AccessLevelUsed = true; + } - switch (accessLevel) + // If both types are detected, throw an error. + if (smartV1AccessLevelUsed && smartV2AccessLevelUsed) { - case "read": - permittedDataActions = DataActions.Read | DataActions.Export; - break; - case "write": - permittedDataActions = DataActions.Write; - break; - case "*": - case AllDataActions: - permittedDataActions = DataActions.Read | DataActions.Write | DataActions.Export; - break; + throw new BadHttpRequestException(string.Format(Api.Resources.MixedSMARTV1AndV2ScopesAreNotAllowed)); } + fhirRequestContext.AccessControlContext.ClinicalScopes.Add(match.Value); + SearchParams smartScopeSearchParameters = new SearchParams(); + + var id = match.Groups["id"]?.Value; + var resource = match.Groups["resource"]?.Value; + permittedDataActions = ParseScopePermissions(accessLevel); + if (!string.IsNullOrEmpty(resource) && !string.IsNullOrEmpty(id)) { @@ -118,7 +219,26 @@ public async Task Invoke( resource = KnownResourceTypes.All; } - fhirRequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(resource, permittedDataActions, id)); + // If Finer-grained resource constraints using search parameters present + if (match.Groups["searchParams"].Success) + { + smartScopeSearchParameters = new SearchParams(); + var searchParamsString = match.Groups["searchParams"].Value; + var searchParamsPairs = searchParamsString.Split('&'); + + // iterate through each key-value pair and add them to the SearchParams + foreach (var parts in searchParamsPairs.Select(kvPair => kvPair.Split('=')).Where(parts => parts.Length == 2)) + { + smartScopeSearchParameters.Add(parts[0], parts[1]); + } + + if (smartScopeSearchParameters.Parameters.Count > 0) + { + fhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControlWithSearchParameters = true; + } + } + + fhirRequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(resource, permittedDataActions, id, smartScopeSearchParameters)); scopeRestrictions.Append($" ( {resource}-{permittedDataActions} ) "); diff --git a/src/Microsoft.Health.Fhir.Api/Resources.Designer.cs b/src/Microsoft.Health.Fhir.Api/Resources.Designer.cs index cf302bab4f..7ba1ff09f6 100644 --- a/src/Microsoft.Health.Fhir.Api/Resources.Designer.cs +++ b/src/Microsoft.Health.Fhir.Api/Resources.Designer.cs @@ -555,6 +555,15 @@ public static string MissingInputParams { } } + /// + /// Looks up a localized string similar to Mixed SMART v1 and v2 scopes are not allowed. Please use either SMART v1 or SMART v2 scopes. . + /// + public static string MixedSMARTV1AndV2ScopesAreNotAllowed { + get { + return ResourceManager.GetString("MixedSMARTV1AndV2ScopesAreNotAllowed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Only one profile can be provided between a Parameters resource and the URL. /// diff --git a/src/Microsoft.Health.Fhir.Api/Resources.resx b/src/Microsoft.Health.Fhir.Api/Resources.resx index 28e7a3cd12..2ae5c580ea 100644 --- a/src/Microsoft.Health.Fhir.Api/Resources.resx +++ b/src/Microsoft.Health.Fhir.Api/Resources.resx @@ -400,6 +400,9 @@ fhirUser claim value of {0} is not valid. fhirUser claim must be a valid URL. {0} is the provided value in the fhirUser claim. + + Mixed SMART v1 and v2 scopes are not allowed. Please use either SMART v1 or SMART v2 scopes. + No Search Parameters found. @@ -438,4 +441,4 @@ The resource already exists. - \ No newline at end of file + diff --git a/src/Microsoft.Health.Fhir.Core/Features/Compartment/SearchCompartmentHandler.cs b/src/Microsoft.Health.Fhir.Core/Features/Compartment/SearchCompartmentHandler.cs index 03e15099e7..de886d9fdb 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Compartment/SearchCompartmentHandler.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Compartment/SearchCompartmentHandler.cs @@ -50,7 +50,13 @@ public async Task Handle(SearchCompartmentRequest req { EnsureArg.IsNotNull(request, nameof(request)); - if (await _authorizationService.CheckAccess(DataActions.Read, cancellationToken) != DataActions.Read) + // For SMART v2 compliance, search operations require the Search permission. + // SMART v2 scopes like "patient/Patient.r" allow read-only access without search capability, + // while "patient/Patient.s" or "patient/Patient.rs" include search permissions. + // Users with only read permission can access resources directly by ID but cannot search. + // We continue to allow DataActions.Read for legacy support + var grantedAccess = await _authorizationService.CheckAccess(DataActions.Search | DataActions.Read, cancellationToken); + if ((grantedAccess & (DataActions.Search | DataActions.Read)) == 0) { throw new UnauthorizedFhirActionException(); } diff --git a/src/Microsoft.Health.Fhir.Core/Features/Conformance/GetSmartConfigurationHandler.cs b/src/Microsoft.Health.Fhir.Core/Features/Conformance/GetSmartConfigurationHandler.cs index 13d0773167..063ba9766a 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Conformance/GetSmartConfigurationHandler.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Conformance/GetSmartConfigurationHandler.cs @@ -54,7 +54,19 @@ protected GetSmartConfigurationResponse Handle(GetSmartConfigurationRequest requ "permission-user", }; - return new GetSmartConfigurationResponse(authorizationEndpoint, tokenEndpoint, capabilities); + // Add SMART v2 scope support - these are the core scopes supported natively by the FHIR service + ICollection scopesSupported = new List + { + // Standard OAuth/OIDC scopes + "openid", + "fhirUser", + "launch", + "launch/patient", + "offline_access", + "online_access", + }; + + return new GetSmartConfigurationResponse(authorizationEndpoint, tokenEndpoint, capabilities, scopesSupported); } catch (Exception e) when (e is ArgumentNullException || e is UriFormatException) { diff --git a/src/Microsoft.Health.Fhir.Core/Features/Context/AccessControlContext.cs b/src/Microsoft.Health.Fhir.Core/Features/Context/AccessControlContext.cs index 989d187dab..ee023d9dc7 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Context/AccessControlContext.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Context/AccessControlContext.cs @@ -15,6 +15,11 @@ public class AccessControlContext : ICloneable /// public bool ApplyFineGrainedAccessControl { get; set; } = false; + /// + /// Value indicates whether or not fine grained access control with Search Parameters policies should be applied + /// + public bool ApplyFineGrainedAccessControlWithSearchParameters { get; set; } = false; + /// /// the string values that were passed in as scopes /// @@ -46,6 +51,7 @@ public object Clone() AccessControlContext clone = new AccessControlContext() { ApplyFineGrainedAccessControl = ApplyFineGrainedAccessControl, + ApplyFineGrainedAccessControlWithSearchParameters = ApplyFineGrainedAccessControlWithSearchParameters, FhirUserClaim = FhirUserClaim, CompartmentResourceType = CompartmentResourceType, CompartmentId = CompartmentId, diff --git a/src/Microsoft.Health.Fhir.Core/Features/Context/ScopeRestriction.cs b/src/Microsoft.Health.Fhir.Core/Features/Context/ScopeRestriction.cs index 7a098f78ce..639b711219 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Context/ScopeRestriction.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Context/ScopeRestriction.cs @@ -5,19 +5,21 @@ using System; using EnsureThat; +using Hl7.Fhir.Rest; using Microsoft.Health.Fhir.Core.Features.Security; namespace Microsoft.Health.Fhir.Core.Features.Context { public class ScopeRestriction : IEquatable { - public ScopeRestriction(string resource, DataActions allowedAction, string user) + public ScopeRestriction(string resource, DataActions allowedAction, string user, SearchParams searchParameters = null) { EnsureArg.IsNotNull(resource, nameof(resource)); Resource = resource; AllowedDataAction |= allowedAction; User = user; + SearchParameters = searchParameters; } public string Resource { get; } @@ -28,6 +30,9 @@ public ScopeRestriction(string resource, DataActions allowedAction, string user) // read, write or both public DataActions AllowedDataAction { get; } + // Finer-grained resource constraints using search parameters for SMART V2 compliance + public SearchParams SearchParameters { get; } + public bool Equals(ScopeRestriction other) { if (other == null) diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/SmartConfigurationResult.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/SmartConfigurationResult.cs index 0fe9da82e9..57a30b311f 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Operations/SmartConfigurationResult.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/SmartConfigurationResult.cs @@ -26,6 +26,18 @@ public SmartConfigurationResult(Uri authorizationEndpoint, Uri tokenEndpoint, IC Capabilities = capabilities; } + public SmartConfigurationResult(Uri authorizationEndpoint, Uri tokenEndpoint, ICollection capabilities, ICollection scopesSupported) + { + EnsureArg.IsNotNull(authorizationEndpoint, nameof(authorizationEndpoint)); + EnsureArg.IsNotNull(tokenEndpoint, nameof(tokenEndpoint)); + EnsureArg.IsNotNull(capabilities, nameof(capabilities)); + + AuthorizationEndpoint = authorizationEndpoint; + TokenEndpoint = tokenEndpoint; + Capabilities = capabilities; + ScopesSupported = scopesSupported; + } + [JsonConstructor] public SmartConfigurationResult() { @@ -39,5 +51,8 @@ public SmartConfigurationResult() [JsonProperty("capabilities")] public ICollection Capabilities { get; private set; } + + [JsonProperty("scopes_supported")] + public ICollection ScopesSupported { get; private set; } } } diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/Parsers/ExpressionParser.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/Parsers/ExpressionParser.cs index e09a478536..b43ce0ac2e 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/Parsers/ExpressionParser.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/Parsers/ExpressionParser.cs @@ -65,6 +65,35 @@ public Expression Parse(string[] resourceTypes, string key, string value) return ParseImpl(resourceTypes, key.AsSpan(), value); } + /// + /// Checks if the given key contains either the reverse chain parameter or a chain parameter. + /// + /// The key to check. + /// True if the key contains either parameter; otherwise, false. + public static bool ContainsChainOrReverseParameter(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + return false; + } + + ReadOnlySpan keySpan = key.AsSpan(); + + // If the key starts with the reverse chain parameter, return true. + if (TryConsume(ReverseChainParameter.AsSpan(), ref keySpan)) + { + return true; + } + + // If a chain parameter ('.') is found, return true. + if (TrySplit(ChainParameter, ref keySpan, out _)) + { + return true; + } + + return false; + } + public IncludeExpression ParseInclude(string[] resourceTypes, string includeValue, bool isReversed, bool iterate, IReadOnlyCollection allowedResourceTypesByScope) { var valueSpan = includeValue.AsSpan(); diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/SmartCompartmentSearchRewriter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/SmartCompartmentSearchRewriter.cs index 38346665cf..fa3445749e 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/SmartCompartmentSearchRewriter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/SmartCompartmentSearchRewriter.cs @@ -37,7 +37,7 @@ public override Expression VisitSmartCompartment(SmartCompartmentSearchExpressio // The smart user has access to 3 things: // 1 - any resource which refers to them // 2 - their own resource - // 3 - any "uniersal" resources, such as Locations and Medications + // 3 - any "universal" resources, such as Locations and Medications // First a collection of any resources which refer to the smart user // we use the CompartmentSearchRewriter to get this list as it matches what we want diff --git a/src/Microsoft.Health.Fhir.Core/Messages/Get/GetSmartConfigurationResponse.cs b/src/Microsoft.Health.Fhir.Core/Messages/Get/GetSmartConfigurationResponse.cs index fc5d20ca78..7debdc63a0 100644 --- a/src/Microsoft.Health.Fhir.Core/Messages/Get/GetSmartConfigurationResponse.cs +++ b/src/Microsoft.Health.Fhir.Core/Messages/Get/GetSmartConfigurationResponse.cs @@ -22,10 +22,24 @@ public GetSmartConfigurationResponse(Uri authorizationEndpoint, Uri tokenEndpoin Capabilities = capabilities; } + public GetSmartConfigurationResponse(Uri authorizationEndpoint, Uri tokenEndpoint, ICollection capabilities, ICollection scopesSupported) + { + EnsureArg.IsNotNull(authorizationEndpoint, nameof(authorizationEndpoint)); + EnsureArg.IsNotNull(tokenEndpoint, nameof(tokenEndpoint)); + EnsureArg.IsNotNull(capabilities, nameof(capabilities)); + + AuthorizationEndpoint = authorizationEndpoint; + TokenEndpoint = tokenEndpoint; + Capabilities = capabilities; + ScopesSupported = scopesSupported; + } + public Uri AuthorizationEndpoint { get; } public Uri TokenEndpoint { get; } public ICollection Capabilities { get; } + + public ICollection ScopesSupported { get; } } } diff --git a/src/Microsoft.Health.Fhir.Core/Resources.Designer.cs b/src/Microsoft.Health.Fhir.Core/Resources.Designer.cs index f942eec450..2add365da9 100644 --- a/src/Microsoft.Health.Fhir.Core/Resources.Designer.cs +++ b/src/Microsoft.Health.Fhir.Core/Resources.Designer.cs @@ -682,6 +682,16 @@ internal static string IncludeMissingType { } } + /// + /// Looks up a localized string similar to Include, RevInclude and Chained searches do not support SMART V2 finer-grained resource constraints using search parameters.. + /// + internal static string IncludeRevIncludeChainedSearchesDoNotSupportFinerGrainedResourceConstraintsUsingSearchParameters { + get { + return ResourceManager.GetString("IncludeRevIncludeChainedSearchesDoNotSupportFinerGrainedResourceConstraintsUsingS" + + "earchParameters", resourceCulture); + } + } + /// /// Looks up a localized string similar to The target resource type cannot be empty.. /// diff --git a/src/Microsoft.Health.Fhir.Core/Resources.resx b/src/Microsoft.Health.Fhir.Core/Resources.resx index 7d17d3b009..08290e2eb3 100644 --- a/src/Microsoft.Health.Fhir.Core/Resources.resx +++ b/src/Microsoft.Health.Fhir.Core/Resources.resx @@ -248,6 +248,9 @@ The target resource type cannot be empty. TargetResourceType is an optional parameter + + Include, RevInclude and Chained searches do not support SMART V2 finer-grained resource constraints using search parameters. + Field '{0}' with value '{1}' is not supported. diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/SMART/SmartClinicalScopesMiddlewareTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/SMART/SmartClinicalScopesMiddlewareTests.cs index ebc4253cd2..a2c218abea 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/SMART/SmartClinicalScopesMiddlewareTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/SMART/SmartClinicalScopesMiddlewareTests.cs @@ -127,6 +127,78 @@ public async Task GivenSmartScope_WhenInvoked_ThenScopeParsedandAddedtoContext(s } } + [Theory] + [MemberData(nameof(GetTestScopes))] + public async Task GivenSmartRawScope_WhenInvoked_ThenScopeParsedandAddedtoContext(string scopes, ICollection expectedScopeRestrictions) + { + var fhirRequestContextAccessor = Substitute.For>(); + + var fhirRequestContext = new DefaultFhirRequestContext(); + + fhirRequestContextAccessor.RequestContext.Returns(fhirRequestContext); + + HttpContext httpContext = new DefaultHttpContext(); + + var fhirConfiguration = new FhirServerConfiguration(); + fhirConfiguration.Security.Enabled = true; + var authorizationConfiguration = fhirConfiguration.Security.Authorization; + authorizationConfiguration.Enabled = true; + await LoadRoles(authorizationConfiguration); + + var fhirUserClaim = new Claim(authorizationConfiguration.FhirUserClaim, "https://fhirServer/Patient/foo"); + var rolesClaim = new Claim(authorizationConfiguration.RolesClaim, "smartUser"); + + var rawScopesClaim = new Claim("raw_scope", scopes); + var claimsIdentity = new ClaimsIdentity(new List() { rawScopesClaim, rolesClaim, fhirUserClaim }); + var expectedPrincipal = new ClaimsPrincipal(claimsIdentity); + + httpContext.User = expectedPrincipal; + fhirRequestContext.Principal = expectedPrincipal; + + _authorizationService = new RoleBasedFhirAuthorizationService(authorizationConfiguration, fhirRequestContextAccessor); + + await _smartClinicalScopesMiddleware.Invoke(httpContext, fhirRequestContextAccessor, Options.Create(fhirConfiguration.Security), _authorizationService); + + Assert.Equal(expectedScopeRestrictions, fhirRequestContext.AccessControlContext.AllowedResourceActions); + } + + [Theory] + [MemberData(nameof(GetMixedTestScopes))] + public async Task GivenMixedSmartScope_WhenInvoked_ThenBadRequestIsThrown(string scopes) + { + HttpContext httpContext = new DefaultHttpContext(); + + var fhirConfiguration = new FhirServerConfiguration(); + fhirConfiguration.Security.Enabled = true; + var authorizationConfiguration = fhirConfiguration.Security.Authorization; + authorizationConfiguration.Enabled = true; + await LoadRoles(authorizationConfiguration); + + var fhirUserClaim = new Claim(authorizationConfiguration.FhirUserClaim, "https://fhirServer/Patient/foo"); + var rolesClaim = new Claim(authorizationConfiguration.RolesClaim, "smartUser"); + + foreach (string singleClaim in authorizationConfiguration.ScopesClaim) + { + var fhirRequestContextAccessor = Substitute.For>(); + + var fhirRequestContext = new DefaultFhirRequestContext(); + + fhirRequestContextAccessor.RequestContext.Returns(fhirRequestContext); + + var scopesClaim = new Claim(singleClaim, scopes); + var claimsIdentity = new ClaimsIdentity(new List() { scopesClaim, rolesClaim, fhirUserClaim }); + var expectedPrincipal = new ClaimsPrincipal(claimsIdentity); + + httpContext.User = expectedPrincipal; + fhirRequestContext.Principal = expectedPrincipal; + + _authorizationService = new RoleBasedFhirAuthorizationService(authorizationConfiguration, fhirRequestContextAccessor); + + await Assert.ThrowsAsync(() => + _smartClinicalScopesMiddleware.Invoke(httpContext, fhirRequestContextAccessor, Options.Create(fhirConfiguration.Security), _authorizationService)); + } + } + [Theory] [InlineData("smartUser", true, true)] [InlineData("globalAdmin", true, false)] @@ -313,8 +385,8 @@ public static IEnumerable GetTestScopesAndRoles() "patient/Observation.read", new List() { - new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient"), - new ScopeRestriction("Observation", DataActions.Read | DataActions.Export, "patient"), + new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), + new ScopeRestriction("Observation", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), }, }; yield return new object[] @@ -323,8 +395,8 @@ public static IEnumerable GetTestScopesAndRoles() "user.Observation.write", new List() { - new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient"), - new ScopeRestriction("Observation", DataActions.Write, "user"), + new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), + new ScopeRestriction("Observation", DataActions.Write | DataActions.Create | DataActions.Delete | DataActions.Update, "user"), }, }; yield return new object[] @@ -333,7 +405,7 @@ public static IEnumerable GetTestScopesAndRoles() "practitioner/Observation.write", new List() { - new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient"), + new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), }, }; yield return new object[] @@ -342,20 +414,21 @@ public static IEnumerable GetTestScopesAndRoles() "practitioner/Observation.wr", new List() { + new ScopeRestriction("Patient", DataActions.ReadById | DataActions.Delete, "patient"), }, }; } public static IEnumerable GetTestScopes() { - yield return new object[] { "patient/Patient.read", new List() { new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient") } }; + yield return new object[] { "patient/Patient.read", new List() { new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient") } }; yield return new object[] { "patient/Patient.read patient/Observation.read", new List() { - new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient"), - new ScopeRestriction("Observation", DataActions.Read | DataActions.Export, "patient"), + new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), + new ScopeRestriction("Observation", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), }, }; yield return new object[] @@ -363,8 +436,8 @@ public static IEnumerable GetTestScopes() "patient.Patient.read user.Observation.write", new List() { - new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient"), - new ScopeRestriction("Observation", DataActions.Write, "user"), + new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), + new ScopeRestriction("Observation", DataActions.Write | DataActions.Create | DataActions.Update | DataActions.Delete, "user"), }, }; @@ -373,26 +446,34 @@ public static IEnumerable GetTestScopes() "user.VisionPrescription.write user.all.read", new List() { - new ScopeRestriction("VisionPrescription", DataActions.Write, "user"), - new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Export, "user"), + new ScopeRestriction("VisionPrescription", DataActions.Write | DataActions.Create | DataActions.Update | DataActions.Delete, "user"), + new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Export | DataActions.Search, "user"), + }, + }; + + yield return new object[] + { + "user/*.*", + new List() + { + new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Search | DataActions.Write | DataActions.Export | DataActions.Create | DataActions.Update | DataActions.Delete, "user"), }, }; - yield return new object[] { "user/*.*", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Write | DataActions.Export, "user") } }; - yield return new object[] { "user/Encounter.*", new List() { new ScopeRestriction("Encounter", DataActions.Read | DataActions.Write | DataActions.Export, "user") } }; - yield return new object[] { "user/all.*", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Write | DataActions.Export, "user") } }; - yield return new object[] { "user/all.all", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Write | DataActions.Export, "user") } }; - yield return new object[] { "system.all.all", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Write | DataActions.Export, "system") } }; - yield return new object[] { "patient.Patient.read", new List() { new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient") } }; - yield return new object[] { "patient.Patient.all", new List() { new ScopeRestriction("Patient", DataActions.Read | DataActions.Write | DataActions.Export, "patient") } }; - yield return new object[] { "patient.*.read", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Export, "patient") } }; - yield return new object[] { "patient.all.read", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Export, "patient") } }; + yield return new object[] { "user/Encounter.*", new List() { new ScopeRestriction("Encounter", DataActions.Read | DataActions.Search | DataActions.Write | DataActions.Export | DataActions.Create | DataActions.Update | DataActions.Delete, "user") } }; + yield return new object[] { "user/all.*", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Search | DataActions.Write | DataActions.Export | DataActions.Create | DataActions.Update | DataActions.Delete, "user") } }; + yield return new object[] { "user/all.all", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Search | DataActions.Write | DataActions.Export | DataActions.Create | DataActions.Update | DataActions.Delete, "user") } }; + yield return new object[] { "system.all.all", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Search | DataActions.Write | DataActions.Export | DataActions.Create | DataActions.Update | DataActions.Delete, "system") } }; + yield return new object[] { "patient.Patient.read", new List() { new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient") } }; + yield return new object[] { "patient.Patient.all", new List() { new ScopeRestriction("Patient", DataActions.Read | DataActions.Search | DataActions.Write | DataActions.Export | DataActions.Create | DataActions.Update | DataActions.Delete, "patient") } }; + yield return new object[] { "patient.*.read", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Export | DataActions.Search, "patient") } }; + yield return new object[] { "patient.all.read", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Read | DataActions.Export | DataActions.Search, "patient") } }; yield return new object[] { "patient$Patient.read practitioner/Observation.write", new List() { - new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient"), + new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), }, }; yield return new object[] @@ -400,25 +481,84 @@ public static IEnumerable GetTestScopes() "patient$Patient.rd practitioner/Observation.wr", new List() { + new ScopeRestriction("Patient", DataActions.ReadById | DataActions.Delete, "patient"), }, }; yield return new object[] { - "User$Patient.read patient/Observation.wr", + "patient/Patient.read launch/patient user/Observation.read offline_access openid user/Encounter.* fhirUser", new List() { + new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient"), + new ScopeRestriction("Observation", DataActions.Read | DataActions.Export | DataActions.Search, "user"), + new ScopeRestriction("Encounter", DataActions.Read | DataActions.Search | DataActions.Write | DataActions.Export | DataActions.Create | DataActions.Update | DataActions.Delete, "user"), }, }; + + // SMART v2 scope format tests + yield return new object[] { "patient/Patient.rs", new List() { new ScopeRestriction("Patient", DataActions.ReadById | DataActions.Search | DataActions.Export, "patient") } }; + yield return new object[] { "patient/Patient.r", new List() { new ScopeRestriction("Patient", DataActions.ReadById, "patient") } }; + yield return new object[] { "patient/Patient.s", new List() { new ScopeRestriction("Patient", DataActions.Search | DataActions.Export, "patient") } }; + yield return new object[] { "patient/Patient.c", new List() { new ScopeRestriction("Patient", DataActions.Create, "patient") } }; + yield return new object[] { "patient/all.c", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Create, "patient") } }; + yield return new object[] { "patient.all.c", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Create, "patient") } }; + yield return new object[] { "patient/Patient.u", new List() { new ScopeRestriction("Patient", DataActions.Update, "patient") } }; + yield return new object[] { "patient/Patient.d", new List() { new ScopeRestriction("Patient", DataActions.Delete, "patient") } }; + yield return new object[] { "patient/Patient.cruds", new List() { new ScopeRestriction("Patient", DataActions.Create | DataActions.Update | DataActions.Delete | DataActions.ReadById | DataActions.Search | DataActions.Export, "patient") } }; + yield return new object[] { "user/*.rs", new List() { new ScopeRestriction(KnownResourceTypes.All, DataActions.ReadById | DataActions.Search | DataActions.Export, "user") } }; yield return new object[] { - "patient/Patient.read launch/patient user/Observation.read offline_access openid user/Encounter.* fhirUser", + "patient/Patient.rs user/Observation.cud", + new List() + { + new ScopeRestriction("Patient", DataActions.ReadById | DataActions.Search | DataActions.Export, "patient"), + new ScopeRestriction("Observation", DataActions.Create | DataActions.Update | DataActions.Delete, "user"), + }, + }; + + // Test v1 vs v2 behavior: v1 .read includes search, v2 .r does not include search + yield return new object[] { "patient/Patient.read", new List() { new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient") } }; + yield return new object[] + { + "patient/Patient.s patient/Observation.r", new List() { - new ScopeRestriction("Patient", DataActions.Read | DataActions.Export, "patient"), - new ScopeRestriction("Observation", DataActions.Read | DataActions.Export, "user"), - new ScopeRestriction("Encounter", DataActions.Read | DataActions.Write | DataActions.Export, "user"), + new ScopeRestriction("Patient", DataActions.Export | DataActions.Search, "patient"), + new ScopeRestriction("Observation", DataActions.ReadById, "patient"), }, }; + + // Test v1 vs v2 write behavior: v1 .write includes all write operations, v2 granular permissions + yield return new object[] { "patient/Patient.write", new List() { new ScopeRestriction("Patient", DataActions.Write | DataActions.Create | DataActions.Update | DataActions.Delete, "patient") } }; + yield return new object[] + { + "patient/Patient.c user/Observation.cu", + new List() + { + new ScopeRestriction("Patient", DataActions.Create, "patient"), + new ScopeRestriction("Observation", DataActions.Create | DataActions.Update, "user"), + }, + }; + } + + public static IEnumerable GetMixedTestScopes() + { + yield return new object[] { "patient/Patient.read patient/Observation.r" }; + yield return new object[] { "patient.Patient.read user.Observation.cr" }; + yield return new object[] { "patient$Patient.rd patient/Observation.write" }; + yield return new object[] { "patient$Patient.read patient/Observation.cr" }; + yield return new object[] { "patient/Patient.read launch/patient user/Observation.read offline_access openid user/Encounter.r fhirUser" }; + yield return new object[] { "patient/Patient.rs user/Observation.cud user/Encounter.write" }; + yield return new object[] { "patient/Patient.read user/Observation.c" }; + yield return new object[] { "patient/Patient.all user/Observation.r" }; + yield return new object[] { "patient/Patient.write user/Observation.u" }; + yield return new object[] { "patient/Patient.read user/Observation.d" }; + yield return new object[] { "patient/Patient.read patient/Patient.cruds" }; + yield return new object[] { "system/Patient.write user/Patient.r" }; + yield return new object[] { "patient/Patient.* user/Observation.d" }; + yield return new object[] { "patient/Patient.all patient/Patient.cruds" }; + yield return new object[] { "system/Patient.all user/Patient.r" }; + yield return new object[] { "system/Patient.* user/Patient.r" }; } private static async Task LoadRoles(AuthorizationConfiguration authConfig) diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/Bundle/BundleHandler.cs b/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/Bundle/BundleHandler.cs index 13f87b6ba4..88d216548d 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/Bundle/BundleHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/Bundle/BundleHandler.cs @@ -863,6 +863,7 @@ private static void SetupContexts( // Propagate Fine Grained Access Control to the new FHIR Request Context. newFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = requestContext.AccessControlContext.ApplyFineGrainedAccessControl; + newFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControlWithSearchParameters = requestContext.AccessControlContext.ApplyFineGrainedAccessControlWithSearchParameters; // Propagate bundle context information to inner requests. BundleResourceContext bundleResourceExecutionContext = new BundleResourceContext( diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Conformance/GetSmartConfigurationHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Conformance/GetSmartConfigurationHandlerTests.cs index f7fcfba309..fb2c257a50 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Conformance/GetSmartConfigurationHandlerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Conformance/GetSmartConfigurationHandlerTests.cs @@ -64,6 +64,9 @@ public async Task GivenASmartConfigurationHandler_WhenSecurityConfigurationEnabl "permission-patient", "permission-user", }); + + // Verify SMART v2 scopes are included + Assert.NotNull(response.ScopesSupported); } [Fact] diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Create/ConditionalCreateResourceHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Create/ConditionalCreateResourceHandlerTests.cs new file mode 100644 index 0000000000..9e1634e8c5 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Create/ConditionalCreateResourceHandlerTests.cs @@ -0,0 +1,177 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Hl7.Fhir.Model; +using MediatR; +using Microsoft.Extensions.Logging; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Exceptions; +using Microsoft.Health.Fhir.Core.Features.Conformance; +using Microsoft.Health.Fhir.Core.Features.Persistence; +using Microsoft.Health.Fhir.Core.Features.Resources.Create; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.Core.Features.Security; +using Microsoft.Health.Fhir.Core.Messages.Create; +using Microsoft.Health.Fhir.Core.Messages.Upsert; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.Health.Fhir.Shared.Core.UnitTests.Features.Resources.Create; + +[Trait(Traits.OwningTeam, OwningTeam.Fhir)] +[Trait(Traits.Category, Categories.Create)] +[Trait(Traits.Category, Categories.ConditionalOperations)] +public class ConditionalCreateResourceHandlerTests +{ + private readonly ConditionalCreateResourceHandler _conditionalCreateHandler; + private readonly IAuthorizationService _authService; + private readonly ISearchService _searchService; + private readonly IMediator _mediator; + + public ConditionalCreateResourceHandlerTests() + { + _authService = Substitute.For>(); + IFhirDataStore fhirDataStore = Substitute.For(); + _searchService = Substitute.For(); + _mediator = Substitute.For(); + Lazy conformanceProvider = Substitute.For>(); + IResourceWrapperFactory resourceWrapperFactory = Substitute.For(); + ResourceIdProvider resourceIdProvider = Substitute.For(); + ILogger logger = Substitute.For>(); + + _conditionalCreateHandler = new ConditionalCreateResourceHandler( + fhirDataStore, + conformanceProvider, + resourceWrapperFactory, + _searchService, + _mediator, + resourceIdProvider, + _authService, + logger); + + // Setup search service to return no matches (for create scenarios) + var searchResults = SearchResult.Empty(); + searchResults.Results = new List(); + + _searchService.ConditionalSearchAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any(), + Arg.Any()) + .Returns(searchResults); + } + + [Fact] + public async Task GivenAConditionalCreateResourceHandler_WhenUserHasSearchAndCreatePermissions_ThenCreateShouldSucceed() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Create, CancellationToken.None) + .Returns(DataActions.Search | DataActions.Create); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalCreateResourceRequest(patient, conditionalParameters, null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + await _conditionalCreateHandler.Handle(request, CancellationToken.None); + + await _mediator + .Received() + .Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GivenAConditionalCreateResourceHandler_WhenUserHasLegacyReadAndWritePermissions_ThenCreateShouldSucceed() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Create, CancellationToken.None) + .Returns(DataActions.Read | DataActions.Write); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalCreateResourceRequest(patient, conditionalParameters, null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + await _conditionalCreateHandler.Handle(request, CancellationToken.None); + + await _mediator + .Received() + .Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GivenAConditionalCreateResourceHandler_WhenUserHasOnlySearchPermission_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Create, CancellationToken.None) + .Returns(DataActions.Search); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalCreateResourceRequest(patient, conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalCreateHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAConditionalCreateResourceHandler_WhenUserHasOnlyCreatePermission_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Create, CancellationToken.None) + .Returns(DataActions.Create); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalCreateResourceRequest(patient, conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalCreateHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAConditionalCreateResourceHandler_WhenUserHasOnlyReadPermission_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Create, CancellationToken.None) + .Returns(DataActions.Read); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalCreateResourceRequest(patient, conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalCreateHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAConditionalCreateResourceHandler_WhenUserLacksAllPermissions_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Create, CancellationToken.None) + .Returns(DataActions.None); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalCreateResourceRequest(patient, conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalCreateHandler.Handle(request, CancellationToken.None)); + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Get/GetResourceHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Get/GetResourceHandlerTests.cs new file mode 100644 index 0000000000..4877832404 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Get/GetResourceHandlerTests.cs @@ -0,0 +1,238 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Microsoft.Health.Core.Features.Context; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Exceptions; +using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.Conformance; +using Microsoft.Health.Fhir.Core.Features.Context; +using Microsoft.Health.Fhir.Core.Features.Persistence; +using Microsoft.Health.Fhir.Core.Features.Resources.Get; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.Core.Features.Search.Filters; +using Microsoft.Health.Fhir.Core.Features.Security; +using Microsoft.Health.Fhir.Core.Messages.Get; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.Health.Fhir.Shared.Core.UnitTests.Features.Resources.Get; + +[Trait(Traits.OwningTeam, OwningTeam.Fhir)] +[Trait(Traits.Category, Categories.Get)] +public class GetResourceHandlerTests +{ + private readonly IFhirDataStore _fhirDataStore; + private readonly Lazy _conformanceProvider; + private readonly IResourceWrapperFactory _resourceWrapperFactory; + private readonly ResourceIdProvider _resourceIdProvider; + private readonly IDataResourceFilter _dataResourceFilter; + private readonly RequestContextAccessor _contextAccessor; + private readonly ISearchService _searchService; + + public GetResourceHandlerTests() + { + _fhirDataStore = Substitute.For(); + _conformanceProvider = Substitute.For>(); + _resourceWrapperFactory = Substitute.For(); + _resourceIdProvider = Substitute.For(); + _dataResourceFilter = new DataResourceFilter(MissingDataFilterCriteria.Default); + _contextAccessor = Substitute.For>(); + _searchService = Substitute.For(); + + // Setup default behavior for data store + var patient = Samples.GetDefaultPatient(); + var rawResource = new RawResource(patient.ToJson(), FhirResourceFormat.Json, false); + var wrapper = new ResourceWrapper( + patient, + rawResource, + new ResourceRequest(System.Net.Http.HttpMethod.Get), + false, + null, + null, + null); + + _fhirDataStore.GetAsync(Arg.Any(), Arg.Any()) + .Returns(wrapper); + + // Setup context accessor default behavior + _contextAccessor.RequestContext.Returns(Substitute.For()); + _contextAccessor.RequestContext.AccessControlContext.Returns((AccessControlContext)null); + } + + [Fact] + public async Task GivenAGetResourceRequest_WhenUserHasReadPermission_ThenGetShouldSucceed() + { + // Arrange + var authService = Substitute.For>(); + var getResourceHandler = new GetResourceHandler( + _fhirDataStore, + _conformanceProvider, + _resourceWrapperFactory, + _resourceIdProvider, + _dataResourceFilter, + authService, + _contextAccessor, + _searchService); + + authService + .CheckAccess(DataActions.Read | DataActions.ReadV2, CancellationToken.None) + .Returns(DataActions.Read); + + var request = new GetResourceRequest(new ResourceKey("Patient", "123"), bundleResourceContext: null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + var result = await getResourceHandler.Handle(request, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Resource); + } + + [Fact] + public async Task GivenAGetResourceRequest_WhenUserHasReadV2Permission_ThenGetShouldSucceed() + { + // Arrange + var authService = Substitute.For>(); + var getResourceHandler = new GetResourceHandler( + _fhirDataStore, + _conformanceProvider, + _resourceWrapperFactory, + _resourceIdProvider, + _dataResourceFilter, + authService, + _contextAccessor, + _searchService); + + authService + .CheckAccess(DataActions.Read | DataActions.ReadV2, CancellationToken.None) + .Returns(DataActions.ReadV2); + + var request = new GetResourceRequest(new ResourceKey("Patient", "123"), bundleResourceContext: null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + var result = await getResourceHandler.Handle(request, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Resource); + } + + [Fact] + public async Task GivenAGetResourceRequest_WhenUserLacksPermissions_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + var authService = Substitute.For>(); + var getResourceHandler = new GetResourceHandler( + _fhirDataStore, + _conformanceProvider, + _resourceWrapperFactory, + _resourceIdProvider, + _dataResourceFilter, + authService, + _contextAccessor, + _searchService); + + authService + .CheckAccess(DataActions.Read | DataActions.ReadV2, CancellationToken.None) + .Returns(DataActions.None); + + var request = new GetResourceRequest(new ResourceKey("Patient", "123"), bundleResourceContext: null); + + // Act & Assert + await Assert.ThrowsAsync(() => + getResourceHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAGetResourceRequest_WhenUserHasOnlyWritePermission_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + var authService = Substitute.For>(); + var getResourceHandler = new GetResourceHandler( + _fhirDataStore, + _conformanceProvider, + _resourceWrapperFactory, + _resourceIdProvider, + _dataResourceFilter, + authService, + _contextAccessor, + _searchService); + + authService + .CheckAccess(DataActions.Read | DataActions.ReadV2, CancellationToken.None) + .Returns(DataActions.Write); + + var request = new GetResourceRequest(new ResourceKey("Patient", "123"), bundleResourceContext: null); + + // Act & Assert + await Assert.ThrowsAsync(() => + getResourceHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAGetResourceRequest_WhenUserHasOnlySearchPermission_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + var authService = Substitute.For>(); + var getResourceHandler = new GetResourceHandler( + _fhirDataStore, + _conformanceProvider, + _resourceWrapperFactory, + _resourceIdProvider, + _dataResourceFilter, + authService, + _contextAccessor, + _searchService); + + authService + .CheckAccess(DataActions.Read | DataActions.ReadV2, CancellationToken.None) + .Returns(DataActions.Search); + + var request = new GetResourceRequest(new ResourceKey("Patient", "123"), bundleResourceContext: null); + + // Act & Assert + await Assert.ThrowsAsync(() => + getResourceHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAGetResourceRequest_WhenResourceNotFound_ThenResourceNotFoundExceptionIsThrown() + { + // Arrange + var authService = Substitute.For>(); + var getResourceHandler = new GetResourceHandler( + _fhirDataStore, + _conformanceProvider, + _resourceWrapperFactory, + _resourceIdProvider, + _dataResourceFilter, + authService, + _contextAccessor, + _searchService); + + authService + .CheckAccess(DataActions.Read | DataActions.ReadV2, CancellationToken.None) + .Returns(DataActions.Read); + + // Setup data store to return null (resource not found) + _fhirDataStore.GetAsync(Arg.Any(), Arg.Any()) + .Returns((ResourceWrapper)null); + + var request = new GetResourceRequest(new ResourceKey("Patient", "notfound"), bundleResourceContext: null); + + // Act & Assert + await Assert.ThrowsAsync(() => + getResourceHandler.Handle(request, CancellationToken.None)); + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Patch/ConditionalPatchResourceHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Patch/ConditionalPatchResourceHandlerTests.cs new file mode 100644 index 0000000000..61b69c9b0a --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Patch/ConditionalPatchResourceHandlerTests.cs @@ -0,0 +1,173 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Hl7.Fhir.Model; +using MediatR; +using Microsoft.Extensions.Logging; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Exceptions; +using Microsoft.Health.Fhir.Core.Features.Conformance; +using Microsoft.Health.Fhir.Core.Features.Persistence; +using Microsoft.Health.Fhir.Core.Features.Resources.Patch; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.Core.Features.Security; +using Microsoft.Health.Fhir.Core.Messages.Patch; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.Health.Fhir.Shared.Core.UnitTests.Features.Resources.Patch; + +[Trait(Traits.OwningTeam, OwningTeam.Fhir)] +[Trait(Traits.Category, Categories.Patch)] +[Trait(Traits.Category, Categories.ConditionalOperations)] +public class ConditionalPatchResourceHandlerTests +{ + private readonly ConditionalPatchResourceHandler _conditionalPatchHandler; + private readonly IAuthorizationService _authService; + private readonly ISearchService _searchService; + private readonly IMediator _mediator; + + public ConditionalPatchResourceHandlerTests() + { + _authService = Substitute.For>(); + IFhirDataStore fhirDataStore = Substitute.For(); + _searchService = Substitute.For(); + _mediator = Substitute.For(); + Lazy conformanceProvider = Substitute.For>(); + IResourceWrapperFactory resourceWrapperFactory = Substitute.For(); + ResourceIdProvider resourceIdProvider = Substitute.For(); + ILogger logger = Substitute.For>(); + + _conditionalPatchHandler = new ConditionalPatchResourceHandler( + _searchService, + fhirDataStore, + conformanceProvider, + resourceWrapperFactory, + resourceIdProvider, + _authService, + _mediator, + logger); + + // Setup search service to return one match + var searchResults = SearchResult.Empty(); + searchResults.Results = new List + { + new SearchResultEntry(Samples.GetDefaultPatient(), SearchEntryMode.Match) + }; + + _searchService.ConditionalSearchAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any(), + Arg.Any()) + .Returns(searchResults); + } + + [Fact] + public async Task GivenAConditionalPatchResourceHandler_WhenUserHasSearchAndUpdatePermissions_ThenPatchShouldSucceed() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.Search | DataActions.Update); + + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalPatchResourceRequest("Patient", new FhirPathPatchPayload(new Parameters()), conditionalParameters, null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + await _conditionalPatchHandler.Handle(request, CancellationToken.None); + + await _mediator + .Received() + .Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GivenAConditionalPatchResourceHandler_WhenUserHasLegacyReadAndWritePermissions_ThenPatchShouldSucceed() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.Read | DataActions.Write); + + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalPatchResourceRequest("Patient", new FhirPathPatchPayload(new Parameters()), conditionalParameters, null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + await _conditionalPatchHandler.Handle(request, CancellationToken.None); + + await _mediator + .Received() + .Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GivenAConditionalPatchResourceHandler_WhenUserHasOnlySearchPermission_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.Search); + + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalPatchResourceRequest("Patient", new FhirPathPatchPayload(new Parameters()), conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalPatchHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAConditionalPatchResourceHandler_WhenUserHasOnlyUpdatePermission_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.Update); + + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalPatchResourceRequest("Patient", new FhirPathPatchPayload(new Parameters()), conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalPatchHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAConditionalPatchResourceHandler_WhenUserHasOnlyReadPermission_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.Read); + + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalPatchResourceRequest("Patient", new FhirPathPatchPayload(new Parameters()), conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalPatchHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAConditionalPatchResourceHandler_WhenUserLacksAllPermissions_ThenUnauthorizedExceptionIsThrown() + { + // Arrange + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.None); + + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalPatchResourceRequest("Patient", new FhirPathPatchPayload(new Parameters()), conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalPatchHandler.Handle(request, CancellationToken.None)); + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Patch/PatchResourceHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Patch/PatchResourceHandlerTests.cs index 263c9970ff..3d7984887e 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Patch/PatchResourceHandlerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Patch/PatchResourceHandlerTests.cs @@ -82,4 +82,66 @@ public async Task GivenAPatchResourceHandler_WhenHandlingAPatchResourceRequestWi await Assert.ThrowsAsync(async () => await _patchHandler.Handle(request, CancellationToken.None)); } + + [Theory] + [InlineData(DataActions.Update)] + [InlineData(DataActions.Read | DataActions.Write)] + public async Task GivenAPatchResourceHandler_WhenUserHasSufficientPermissions_ThenPatchShouldSucceed(DataActions returnedDataAction) + { + // Arrange + IAuthorizationService authService = Substitute.For>(); + IFhirDataStore fhirDataStore = Substitute.For(); + IMediator mediator = Substitute.For(); + + var patchHandler = Mock.TypeWithArguments(mediator, authService, fhirDataStore); + + authService + .CheckAccess(DataActions.Update | DataActions.Read | DataActions.Write, CancellationToken.None) + .Returns(returnedDataAction); + + ResourceElement patient = Samples.GetDefaultPatient().UpdateVersion("1"); + var wrapper = new ResourceWrapper( + patient, + new RawResource(patient.Instance.ToJson(), FhirResourceFormat.Json, false), + new ResourceRequest(HttpMethod.Get), + false, + null, + null, + null); + + fhirDataStore.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(wrapper)); + + var request = new PatchResourceRequest(new ResourceKey("Patient", "123"), new FhirPathPatchPayload(new Parameters()), bundleResourceContext: null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + await patchHandler.Handle(request, CancellationToken.None); + + await mediator + .Received() + .Send(Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(DataActions.None)] + [InlineData(DataActions.Read)] + [InlineData(DataActions.Write)] + public async Task GivenAPatchResourceHandler_WhenUserLacksPermission_ThenUnauthorizedExceptionIsThrown(DataActions returnedDataAction) + { + // Arrange + IAuthorizationService authService = Substitute.For>(); + IFhirDataStore fhirDataStore = Substitute.For(); + IMediator mediator = Substitute.For(); + + var patchHandler = Mock.TypeWithArguments(mediator, authService, fhirDataStore); + + authService + .CheckAccess(DataActions.Update | DataActions.Read | DataActions.Write, CancellationToken.None) + .Returns(returnedDataAction); + + var request = new PatchResourceRequest(new ResourceKey("Patient", "123"), new FhirPathPatchPayload(new Parameters()), bundleResourceContext: null); + + // Act & Assert + await Assert.ThrowsAsync(() => patchHandler.Handle(request, CancellationToken.None)); + } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/ResourceHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/ResourceHandlerTests.cs index 8082d12290..88351130d2 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/ResourceHandlerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/ResourceHandlerTests.cs @@ -10,6 +10,7 @@ using System.Threading; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Model; +using Hl7.Fhir.Rest; using Hl7.Fhir.Serialization; using MediatR; using Microsoft.Extensions.DependencyInjection; @@ -37,6 +38,7 @@ using Microsoft.Health.Fhir.Core.Features.Security; using Microsoft.Health.Fhir.Core.Messages.Create; using Microsoft.Health.Fhir.Core.Messages.Delete; +using Microsoft.Health.Fhir.Core.Messages.Upsert; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.Core.Registration; using Microsoft.Health.Fhir.Core.UnitTests.Extensions; @@ -134,9 +136,9 @@ public ResourceHandlerTests() var conditionalUpsertLogger = Substitute.For>(); var conditionalDeleteLogger = Substitute.For>(); - collection.Add(x => _mediator).Singleton().AsSelf(); + collection.Add(x => _mediator).Singleton().AsSelf().AsImplementedInterfaces(); collection.Add(x => new CreateResourceHandler(_fhirDataStore, lazyConformanceProvider, _resourceWrapperFactory, _resourceIdProvider, referenceResolver, _authorizationService)).Singleton().AsSelf().AsImplementedInterfaces(); - collection.Add(x => new UpsertResourceHandler(_fhirDataStore, lazyConformanceProvider, _resourceWrapperFactory, _resourceIdProvider, referenceResolver, _authorizationService, ModelInfoProvider.Instance)).Singleton().AsSelf().AsImplementedInterfaces(); + collection.Add(x => new UpsertResourceHandler(_fhirDataStore, lazyConformanceProvider, _resourceWrapperFactory, _resourceIdProvider, referenceResolver, contextAccessor, _authorizationService, ModelInfoProvider.Instance)).Singleton().AsSelf().AsImplementedInterfaces(); collection.Add(x => new ConditionalCreateResourceHandler(_fhirDataStore, lazyConformanceProvider, _resourceWrapperFactory, _searchService, x.GetService(), _resourceIdProvider, _authorizationService, conditionalCreateLogger)).Singleton().AsSelf().AsImplementedInterfaces(); collection.Add(x => new ConditionalUpsertResourceHandler(_fhirDataStore, lazyConformanceProvider, _resourceWrapperFactory, _searchService, x.GetService(), _resourceIdProvider, _authorizationService, conditionalUpsertLogger)).Singleton().AsSelf().AsImplementedInterfaces(); collection.Add(x => new ConditionalDeleteResourceHandler(_fhirDataStore, lazyConformanceProvider, _resourceWrapperFactory, _searchService, x.GetService(), _resourceIdProvider, _authorizationService, deleter, contextAccessor, new OptionsWrapper(coreFeatureConfiguration), conditionalDeleteLogger)).Singleton().AsSelf().AsImplementedInterfaces(); @@ -474,5 +476,128 @@ private ResourceWrapper CreateMockResourceWrapper(ResourceElement resource, bool null, 0); } + + [Fact] + public async Task GivenUpsertRequestWithPostHttpVerb_WhenUserHasCreatePermission_ThenShouldSucceed() + { + var resource = Samples.GetDefaultObservation(); + var contextAccessor = Substitute.For(); + contextAccessor.RequestContext = new FhirRequestContext("POST", "http://localhost", "http://localhost", "id", new Dictionary(), new Dictionary()); + var bundleContext = new BundleResourceContext(Bundle.BundleType.Batch, BundleProcessingLogic.Sequential, Bundle.HTTPVerb.POST, persistedId: null, Guid.NewGuid()); + + _authorizationService.CheckAccess(DataActions.Create | DataActions.Write, Arg.Any()).Returns(DataActions.Create); + _fhirDataStore.UpsertAsync(Arg.Any(), Arg.Any()).Returns(x => new UpsertOutcome(x.ArgAt(0).Wrapper, SaveOutcomeType.Created)); + + var handler = new UpsertResourceHandler(_fhirDataStore, new Lazy(() => _conformanceProvider), _resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(_searchService, new TestQueryStringParser(), Substitute.For>()), contextAccessor, _authorizationService, ModelInfoProvider.Instance); + var request = new UpsertResourceRequest(resource, bundleContext); + + var result = await handler.Handle(request, CancellationToken.None); + Assert.NotNull(result); + } + + [Theory] + [InlineData(DataActions.None)] + [InlineData(DataActions.Update)] + public async Task GivenUpsertRequestWithPostHttpVerb_WhenUserLacksCreatePermission_ThenShouldThrowUnauthorizedException(DataActions returnedDataActions) + { + var resource = Samples.GetDefaultObservation(); + var contextAccessor = Substitute.For(); + contextAccessor.RequestContext = new FhirRequestContext("POST", "http://localhost", "http://localhost", "id", new Dictionary(), new Dictionary()); + var bundleContext = new BundleResourceContext(Bundle.BundleType.Batch, BundleProcessingLogic.Sequential, Bundle.HTTPVerb.POST, persistedId: null, Guid.NewGuid()); + + _authorizationService.CheckAccess(DataActions.Create | DataActions.Write, Arg.Any()).Returns(returnedDataActions); + + var handler = new UpsertResourceHandler(_fhirDataStore, new Lazy(() => _conformanceProvider), _resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(_searchService, new TestQueryStringParser(), Substitute.For>()), contextAccessor, _authorizationService, ModelInfoProvider.Instance); + var request = new UpsertResourceRequest(resource, bundleContext); + + await Assert.ThrowsAsync(() => handler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenUpsertRequestWithPutHttpVerb_WhenUserHasUpdatePermission_ThenShouldSucceed() + { + var resource = Samples.GetDefaultObservation(); + var contextAccessor = Substitute.For(); + contextAccessor.RequestContext = new FhirRequestContext("PUT", "http://localhost", "http://localhost", "id", new Dictionary(), new Dictionary()); + var bundleContext = new BundleResourceContext(Bundle.BundleType.Batch, BundleProcessingLogic.Sequential, Bundle.HTTPVerb.PUT, persistedId: null, Guid.NewGuid()); + + _authorizationService.CheckAccess(DataActions.Update | DataActions.Write, Arg.Any()).Returns(DataActions.Update); + _fhirDataStore.UpsertAsync(Arg.Any(), Arg.Any()).Returns(x => new UpsertOutcome(x.ArgAt(0).Wrapper, SaveOutcomeType.Updated)); + + var handler = new UpsertResourceHandler(_fhirDataStore, new Lazy(() => _conformanceProvider), _resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(_searchService, new TestQueryStringParser(), Substitute.For>()), contextAccessor, _authorizationService, ModelInfoProvider.Instance); + var request = new UpsertResourceRequest(resource, bundleContext); + + var result = await handler.Handle(request, CancellationToken.None); + Assert.NotNull(result); + } + + [Theory] + [InlineData(DataActions.None)] + [InlineData(DataActions.Create)] + public async Task GivenUpsertRequestWithPutHttpVerb_WhenUserLacksUpdatePermission_ThenShouldThrowUnauthorizedException(DataActions returnedDataAction) + { + var resource = Samples.GetDefaultObservation(); + var contextAccessor = Substitute.For(); + contextAccessor.RequestContext = new FhirRequestContext("PUT", "http://localhost", "http://localhost", "id", new Dictionary(), new Dictionary()); + var bundleContext = new BundleResourceContext(Bundle.BundleType.Batch, BundleProcessingLogic.Sequential, Bundle.HTTPVerb.PUT, persistedId: null, Guid.NewGuid()); + + _authorizationService.CheckAccess(DataActions.Update | DataActions.Write, Arg.Any()).Returns(returnedDataAction); + + var handler = new UpsertResourceHandler(_fhirDataStore, new Lazy(() => _conformanceProvider), _resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(_searchService, new TestQueryStringParser(), Substitute.For>()), contextAccessor, _authorizationService, ModelInfoProvider.Instance); + var request = new UpsertResourceRequest(resource, bundleContext); + + await Assert.ThrowsAsync(() => handler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenUpsertRequestWithInvalidHttpVerb_WhenResourceHasNoId_ThenShouldRequireCreatePermission() + { + var resource = Samples.GetDefaultObservation().UpdateId(null); + var contextAccessor = Substitute.For(); + contextAccessor.RequestContext = new FhirRequestContext("foo", "http://localhost", "http://localhost", "id", new Dictionary(), new Dictionary()); + + _authorizationService.CheckAccess(DataActions.Create | DataActions.Write, Arg.Any()).Returns(DataActions.Create); + _fhirDataStore.UpsertAsync(Arg.Any(), Arg.Any()).Returns(x => new UpsertOutcome(x.ArgAt(0).Wrapper, SaveOutcomeType.Created)); + + var handler = new UpsertResourceHandler(_fhirDataStore, new Lazy(() => _conformanceProvider), _resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(_searchService, new TestQueryStringParser(), Substitute.For>()), contextAccessor, _authorizationService, ModelInfoProvider.Instance); + var request = new UpsertResourceRequest(resource, bundleResourceContext: null); + + var result = await handler.Handle(request, CancellationToken.None); + Assert.NotNull(result); + } + + [Fact] + public async Task GivenUpsertRequestWithRequestContextPost_WhenNoBundleContext_ThenShouldRequireCreatePermission() + { + var resource = Samples.GetDefaultObservation(); + var contextAccessor = Substitute.For(); + contextAccessor.RequestContext = new FhirRequestContext("POST", "http://localhost", "http://localhost", "id", new Dictionary(), new Dictionary()); + + _authorizationService.CheckAccess(DataActions.Create | DataActions.Write, Arg.Any()).Returns(DataActions.Create); + _fhirDataStore.UpsertAsync(Arg.Any(), Arg.Any()).Returns(x => new UpsertOutcome(x.ArgAt(0).Wrapper, SaveOutcomeType.Created)); + + var handler = new UpsertResourceHandler(_fhirDataStore, new Lazy(() => _conformanceProvider), _resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(_searchService, new TestQueryStringParser(), Substitute.For>()), contextAccessor, _authorizationService, ModelInfoProvider.Instance); + var request = new UpsertResourceRequest(resource, bundleResourceContext: null); + + var result = await handler.Handle(request, CancellationToken.None); + Assert.NotNull(result); + } + + [Fact] + public async Task GivenUpsertRequestWithRequestContextPut_WhenNoBundleContext_ThenShouldRequireUpdatePermission() + { + var resource = Samples.GetDefaultObservation(); + var contextAccessor = Substitute.For(); + contextAccessor.RequestContext = new FhirRequestContext("PUT", "http://localhost", "http://localhost", "id", new Dictionary(), new Dictionary()); + + _authorizationService.CheckAccess(DataActions.Update | DataActions.Write, Arg.Any()).Returns(DataActions.Update); + _fhirDataStore.UpsertAsync(Arg.Any(), Arg.Any()).Returns(x => new UpsertOutcome(x.ArgAt(0).Wrapper, SaveOutcomeType.Updated)); + + var handler = new UpsertResourceHandler(_fhirDataStore, new Lazy(() => _conformanceProvider), _resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(_searchService, new TestQueryStringParser(), Substitute.For>()), contextAccessor, _authorizationService, ModelInfoProvider.Instance); + var request = new UpsertResourceRequest(resource, bundleResourceContext: null); + + var result = await handler.Handle(request, CancellationToken.None); + Assert.NotNull(result); + } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Upsert/ConditionalUpsertResourceHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Upsert/ConditionalUpsertResourceHandlerTests.cs new file mode 100644 index 0000000000..a509e350b4 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Resources/Upsert/ConditionalUpsertResourceHandlerTests.cs @@ -0,0 +1,219 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Hl7.Fhir.ElementModel; +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using MediatR; +using Microsoft.Extensions.Logging; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Exceptions; +using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.Conformance; +using Microsoft.Health.Fhir.Core.Features.Persistence; +using Microsoft.Health.Fhir.Core.Features.Resources.Upsert; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.Core.Features.Security; +using Microsoft.Health.Fhir.Core.Messages.Create; +using Microsoft.Health.Fhir.Core.Messages.Upsert; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.Health.Fhir.Shared.Core.UnitTests.Features.Resources.Upsert; + +[Trait(Traits.OwningTeam, OwningTeam.Fhir)] + +[Trait(Traits.Category, Categories.ConditionalOperations)] +public class ConditionalUpsertResourceHandlerTests +{ + private readonly ConditionalUpsertResourceHandler _conditionalUpsertHandler; + private readonly IAuthorizationService _authService; + private readonly ISearchService _searchService; + private readonly IMediator _mediator; + + public ConditionalUpsertResourceHandlerTests() + { + _authService = Substitute.For>(); + IFhirDataStore fhirDataStore = Substitute.For(); + _searchService = Substitute.For(); + _mediator = Substitute.For(); + Lazy conformanceProvider = Substitute.For>(); + IResourceWrapperFactory resourceWrapperFactory = Substitute.For(); + ResourceIdProvider resourceIdProvider = Substitute.For(); + ILogger logger = Substitute.For>(); + + _conditionalUpsertHandler = new ConditionalUpsertResourceHandler( + fhirDataStore, + conformanceProvider, + resourceWrapperFactory, + _searchService, + _mediator, + resourceIdProvider, + _authService, + logger); + } + + [Fact] + public async Task GivenAConditionalUpsertResourceHandler_WhenUserHasSearchAndUpdatePermissions_ThenUpsertShouldSucceed() + { + // Arrange + // Setup search service to return one match (for upsert scenarios) + var searchResult = GetSearchResult(Samples.GetDefaultPatient()); + + var taskResult = Task.FromResult(searchResult); + + _searchService.SearchAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(taskResult); + + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.Search | DataActions.Update); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalUpsertResourceRequest(patient, conditionalParameters, null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + await _conditionalUpsertHandler.Handle(request, CancellationToken.None); + + await _mediator + .Received() + .Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GivenAConditionalUpsertResourceHandler_WhenUserHasLegacyReadAndWritePermissions_ThenUpsertShouldSucceed() + { + // Arrange + // Setup search service to return one match (for upsert scenarios) + var searchResult = GetSearchResult(Samples.GetDefaultPatient()); + + var taskResult = Task.FromResult(searchResult); + + _searchService.SearchAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(taskResult); + + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.Read | DataActions.Write); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalUpsertResourceRequest(patient, conditionalParameters, null); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + await _conditionalUpsertHandler.Handle(request, CancellationToken.None); + + await _mediator + .Received() + .Send(Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(DataActions.Search)] + [InlineData(DataActions.Update)] + [InlineData(DataActions.Read)] + [InlineData(DataActions.None)] + public async Task GivenAConditionalUpsertResourceHandler_WhenUserhasInsufficientPermission_ThenUnauthorizedExceptionIsThrown(DataActions returnedDataAction) + { + // Arrange + // Setup search service to return one match (for upsert scenarios) + // Setup search service to return one match (for upsert scenarios) + var searchResult = GetSearchResult(Samples.GetDefaultPatient()); + + var taskResult = Task.FromResult(searchResult); + + _searchService.SearchAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(taskResult); + + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(returnedDataAction); + + var patient = Samples.GetDefaultPatient(); + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalUpsertResourceRequest(patient, conditionalParameters, null); + + // Act & Assert + await Assert.ThrowsAsync(() => _conditionalUpsertHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenAConditionalUpsertResourceHandler_WhenNoMatchFoundAndResourceHasNoId_ThenCreateIsExecuted() + { + IReadOnlyCollection searchResults = Array.Empty().AsReadOnly(); + + // Arrange - Setup for no matches to test create path + // Setup search service to return no matches + var searchResult = new SearchResult(new List().AsReadOnly(), null, null, Array.Empty>()); + + var taskResult = Task.FromResult(searchResult); + + _searchService.SearchAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(taskResult); + + _authService + .CheckAccess(DataActions.Read | DataActions.Write | DataActions.Search | DataActions.Update, CancellationToken.None) + .Returns(DataActions.Search | DataActions.Update); + + var patient = Samples.GetDefaultPatient().ToPoco(); + patient.Id = null; // No ID to trigger create path + var conditionalParameters = new List> { new("name", "John") }; + var request = new ConditionalUpsertResourceRequest(patient.ToResourceElement(), conditionalParameters, null); + + // Act + await _conditionalUpsertHandler.Handle(request, CancellationToken.None); + + // Assert - Should call create since no ID and no matches + await _mediator + .Received() + .Send(Arg.Any(), Arg.Any()); + } + + private SearchResult GetSearchResult(ResourceElement resourceElement) + { + var resource = resourceElement.ToPoco(); + resource.Id = "example"; + resource.VersionId = "version1"; + resource.Meta.Profile = new List { "test" }; + var rawResourceFactory = new RawResourceFactory(new FhirJsonSerializer()); + ResourceElement typedElement = resource.ToResourceElement(); + + var wrapper = new ResourceWrapper(typedElement, rawResourceFactory.Create(typedElement, keepMeta: true), new ResourceRequest(HttpMethod.Post, "http://fhir"), false, null, null, null); + var result = new SearchResultEntry(wrapper, ValueSets.SearchEntryMode.Match); + + var searchResult = new SearchResult( + Enumerable.Repeat(result, 1), + null, + null, + Array.Empty>(), + null, + null); + + return searchResult; + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchOptionsFactoryTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchOptionsFactoryTests.cs index ab3d5d8e4d..b869e1e62b 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchOptionsFactoryTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchOptionsFactoryTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using Hl7.Fhir.Model; +using Hl7.Fhir.Rest; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Health.Core.Features.Context; @@ -20,6 +21,7 @@ using Microsoft.Health.Fhir.Core.Features.Search.Access; using Microsoft.Health.Fhir.Core.Features.Search.Expressions; using Microsoft.Health.Fhir.Core.Features.Search.Expressions.Parsers; +using Microsoft.Health.Fhir.Core.Features.Security; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.Core.UnitTests.Features.Context; using Microsoft.Health.Fhir.Tests.Common; @@ -28,6 +30,7 @@ using NSubstitute.ExceptionExtensions; using Xunit; using static Microsoft.Health.Fhir.Core.UnitTests.Features.Search.SearchExpressionTestHelper; +using SortOrder = Microsoft.Health.Fhir.Core.Features.Search.SortOrder; namespace Microsoft.Health.Fhir.Core.UnitTests.Features.Search { @@ -73,6 +76,120 @@ public SearchOptionsFactoryTests() NullLogger.Instance); } + public static IEnumerable GetSearchParameterTestData + { + get + { + yield return new object[] + { + "Patient", + new List + { + new ScopeRestriction("Patient", DataActions.Read, "patient", new SearchParams("code1", "foo")), + new ScopeRestriction("Observation", DataActions.Read, "patient", new SearchParams("code2", "doo")), + }, + new List>(), + "(And (Param ResourceType (StringEquals TokenCode 'Patient')) (Param ResourceType (TokenCode IN (Patient, Observation))) (Or (And (Param ResourceType (StringEquals TokenCode 'Patient')) code1=foo) (And (Param ResourceType (StringEquals TokenCode 'Observation')) code2=doo)))", + }; + yield return new object[] + { + "Patient", + new List + { + new ScopeRestriction("Patient", DataActions.Read, "patient", CreateSearchParams(("code1", "foo"), ("code2", "goo"))), + new ScopeRestriction("Observation", DataActions.Read, "patient", CreateSearchParams(("code2", "doo"))), + }, + new List> + { + Tuple.Create("_type", "Patient,Observation,Practitioner"), + Tuple.Create("tag", "xyz"), + }, + "(And (Param ResourceType (StringEquals TokenCode 'Patient')) (Param ResourceType (TokenCode IN (Patient, Observation))) (Or (And (Param ResourceType (StringEquals TokenCode 'Patient')) code1=foo code2=goo) (And (Param ResourceType (StringEquals TokenCode 'Observation')) code2=doo)) _type=Patient,Observation,Practitioner tag=xyz)", + }; + yield return new object[] + { + "Patient", + new List + { + new ScopeRestriction("Patient", DataActions.Read, "patient", CreateSearchParams(("code1", "foo"), ("code2", "goo"))), + new ScopeRestriction("Observation", DataActions.Read, "patient", CreateSearchParams(("code2", "doo"))), + }, + new List> + { + Tuple.Create("tag", "xyz"), + }, + "(And (Param ResourceType (StringEquals TokenCode 'Patient')) (Param ResourceType (TokenCode IN (Patient, Observation))) (Or (And (Param ResourceType (StringEquals TokenCode 'Patient')) code1=foo code2=goo) (And (Param ResourceType (StringEquals TokenCode 'Observation')) code2=doo)) tag=xyz)", + }; + yield return new object[] + { + "Patient", + new List + { + new ScopeRestriction(KnownResourceTypes.All, DataActions.Read, "patient", CreateSearchParams(("_type", "Practitioner,CarePlan,Organization"))), + new ScopeRestriction("Observation", DataActions.Read, "patient", CreateSearchParams(("code2", "doo"))), + }, + null, + "(And (Param ResourceType (StringEquals TokenCode 'Patient')) _type=Practitioner,CarePlan,Organization)", + }; + yield return new object[] + { + // TODO: Check this one + "Patient", + new List + { + new ScopeRestriction("all", DataActions.Search, "patient", null), + new ScopeRestriction("Observation", DataActions.Search, "patient", CreateSearchParams(("code2", "doo"))), + }, + null, + "(Param ResourceType (StringEquals TokenCode 'Patient'))", + }; + yield return new object[] + { + // TODO: Check this one + "Patient", + new List + { + new ScopeRestriction("all", DataActions.Search, "patient", CreateSearchParams(("_type", "Observation"))), + new ScopeRestriction("Observation", DataActions.Search, "patient", CreateSearchParams(("code2", "doo"))), + }, + null, + "(And (Param ResourceType (StringEquals TokenCode 'Patient')) _type=Observation)", + }; + yield return new object[] + { + null, + new List + { + new ScopeRestriction("all", DataActions.Search, "patient", null), + }, + null, + null, + }; + yield return new object[] + { + null, + new List + { + new ScopeRestriction("Observation", DataActions.Search, "patient", CreateSearchParams(("code1", "doo"))), + new ScopeRestriction("Encounter", DataActions.Search, "patient", CreateSearchParams(("code2", "goo"))), + }, + null, + "(And (Param ResourceType (TokenCode IN (Observation, Encounter))) (Or (And (Param ResourceType (StringEquals TokenCode 'Observation')) code1=doo) (And (Param ResourceType (StringEquals TokenCode 'Encounter')) code2=goo)))", + }; + } + } + + private static SearchParams CreateSearchParams(params (string key, string value)[] items) + { + var searchParams = new SearchParams(); + foreach (var item in items) + { + searchParams.Add(item.key, item.value); + } + + return searchParams; + } + [Fact] public void GivenANullQueryParameters_WhenCreated_ThenDefaultSearchOptionsShouldBeCreated() { @@ -613,6 +730,70 @@ public void GivenAnIncludesOperationRequest_WhenIncludesContinuationTokenIsMissi Assert.Throws(() => CreateSearchOptions(isIncludesOperation: true)); } + [Theory] + [MemberData(nameof(GetSearchParameterTestData))] + public void Create_AddsFineGrainedAccessControlWithSearchParametersExpressions_UsingMemberData(string resourceType, List scopeRestrictions, List> queryParameters, string expectedSubstring) + { + // Arrange + var stubExpressionParser = Substitute.For(); + stubExpressionParser.Parse(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(x => new StubExpression($"{x.ArgAt(1)}={x.ArgAt(2)}")); + stubExpressionParser.ParseInclude(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns((IncludeExpression)null); + + var stubResourceTypeSearchParameter = new StubSearchParameterInfo("ResourceType", "ResourceType"); + var stubSearchParameterDefinitionManager = Substitute.For(); + stubSearchParameterDefinitionManager.GetSearchParameter(Arg.Any(), Arg.Any()) + .Returns(stubResourceTypeSearchParameter); + ISearchParameterDefinitionManager.SearchableSearchParameterDefinitionManagerResolver resolver = () => stubSearchParameterDefinitionManager; + + var fhirRequestContext = new DefaultFhirRequestContext + { + AccessControlContext = new AccessControlContext + { + ApplyFineGrainedAccessControl = true, + ApplyFineGrainedAccessControlWithSearchParameters = true, + }, + }; + + foreach (var restriction in scopeRestrictions) + { + fhirRequestContext.AccessControlContext.AllowedResourceActions.Add(restriction); + } + + var contextAccessor = Substitute.For>(); + contextAccessor.RequestContext.Returns(fhirRequestContext); + + var dummySortingValidator = Substitute.For(); + dummySortingValidator.ValidateSorting( + Arg.Any>(), + out _).Returns(true); + + var factory = new SearchOptionsFactory( + stubExpressionParser, + resolver, + new OptionsWrapper(_coreFeatures), + contextAccessor, + Substitute.For(), + new ExpressionAccessControl(contextAccessor), + NullLogger.Instance); + + // Act + SearchOptions options = factory.Create(resourceType, queryParameters, onlyIds: false, isIncludesOperation: false); + + // Assert + if (string.IsNullOrEmpty(expectedSubstring)) + { + Assert.Null(options.Expression); + } + else + { + Assert.NotNull(options.Expression); + string expressionText = options.Expression.ToString(); + Assert.Contains(expectedSubstring, expressionText, System.StringComparison.OrdinalIgnoreCase); + } + } + private SearchOptions CreateSearchOptions( string resourceType = DefaultResourceType, IReadOnlyList> queryParameters = null, @@ -623,5 +804,51 @@ private SearchOptions CreateSearchOptions( { return _factory.Create(compartmentType, compartmentId, resourceType, queryParameters, resourceVersionTypes: resourceVersionTypes, isIncludesOperation: isIncludesOperation); } + + // A simple stub implementation for Expression used in our test. + private class StubExpression : Microsoft.Health.Fhir.Core.Features.Search.Expressions.Expression + { + private readonly string _description; + + public StubExpression(string description) + { + _description = description; + } + + public override string ToString() => _description; + + public override void AddValueInsensitiveHashCode(ref HashCode hashCode) + { + hashCode.Add(_description); + } + + public override bool ValueInsensitiveEquals(Microsoft.Health.Fhir.Core.Features.Search.Expressions.Expression other) => + other is StubExpression se && se._description == _description; + + public override TOutput AcceptVisitor(IExpressionVisitor visitor, TContext context) + { + throw new NotImplementedException(); + } + } + + // A stub for SearchParameterInfo. + private class StubSearchParameterInfo : SearchParameterInfo + { + public StubSearchParameterInfo(string name, string code) + : base(name, code) + { + } + + public override string ToString() => Code; + } + + // A dummy implementation of ExpressionAccessControl that does nothing. + private class DummyExpressionAccessControl : ExpressionAccessControl + { + public DummyExpressionAccessControl() + : base(null) + { + } + } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHandlerTests.cs index 9aa3a12bc4..b37d636b24 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHandlerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHandlerTests.cs @@ -6,12 +6,17 @@ using System; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Hl7.Fhir.Model; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Filters; +using Microsoft.Health.Fhir.Core.Features.Security; using Microsoft.Health.Fhir.Core.Features.Security.Authorization; using Microsoft.Health.Fhir.Core.Messages.Search; +using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.Tests.Common; using Microsoft.Health.Test.Utilities; using NSubstitute; @@ -56,5 +61,102 @@ public async Task GivenASearchResourceRequest_WhenHandled_ThenABundleShouldBeRet Assert.NotNull(actualResponse); Assert.Equal(expectedBundle, actualResponse.Bundle); } + + [Fact] + public async Task GivenASearchResourceRequest_WhenUserHasSearchPermission_ThenSearchSucceeds() + { + var authorizationService = Substitute.For>(); + var searchResourceHandler = new SearchResourceHandler( + _searchService, + _bundleFactory, + authorizationService, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + + var request = new SearchResourceRequest("Patient", null); + var searchResult = new SearchResult(Enumerable.Empty(), null, null, new Tuple[0]); + var expectedBundle = new Bundle().ToResourceElement(); + + // Setup authorization to return Search permission + authorizationService.CheckAccess(Arg.Any(), CancellationToken.None) + .Returns(DataActions.Search); + + _searchService.SearchAsync(request.ResourceType, request.Queries, CancellationToken.None).Returns(searchResult); + _bundleFactory.CreateSearchBundle(searchResult).Returns(expectedBundle); + + SearchResourceResponse actualResponse = await searchResourceHandler.Handle(request, CancellationToken.None); + + Assert.NotNull(actualResponse); + Assert.Equal(expectedBundle, actualResponse.Bundle); + } + + [Fact] + public async Task GivenASearchResourceRequest_WhenUserHasOnlyReadPermission_ThenUnauthorizedExceptionThrown() + { + var authorizationService = Substitute.For>(); + + // Setup authorization to return only Read permission (no Search permission) + // This simulates SMART v2 scope like "patient/Patient.r" which only allows direct access + authorizationService.CheckAccess(Arg.Any(), Arg.Any()) + .Returns(DataActions.ReadById); + + var searchResourceHandler = new SearchResourceHandler( + _searchService, + _bundleFactory, + authorizationService, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + + var request = new SearchResourceRequest("Patient", null); + + await Assert.ThrowsAsync(() => + searchResourceHandler.Handle(request, CancellationToken.None)); + } + + [Fact] + public async Task GivenASearchResourceRequest_WhenUserHasReadAndSearchPermissions_ThenSearchSucceeds() + { + var authorizationService = Substitute.For>(); + var searchResourceHandler = new SearchResourceHandler( + _searchService, + _bundleFactory, + authorizationService, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + + var request = new SearchResourceRequest("Patient", null); + var searchResult = new SearchResult(Enumerable.Empty(), null, null, new Tuple[0]); + var expectedBundle = new Bundle().ToResourceElement(); + + // Setup authorization to return Search permission (which is what we check for) + // This simulates SMART v1 ".read" or v2 ".rs" scopes + authorizationService.CheckAccess(Arg.Any(), CancellationToken.None) + .Returns(DataActions.Search | DataActions.Read); + + _searchService.SearchAsync(request.ResourceType, request.Queries, CancellationToken.None).Returns(searchResult); + _bundleFactory.CreateSearchBundle(searchResult).Returns(expectedBundle); + + SearchResourceResponse actualResponse = await searchResourceHandler.Handle(request, CancellationToken.None); + + Assert.NotNull(actualResponse); + Assert.Equal(expectedBundle, actualResponse.Bundle); + } + + [Fact] + public async Task GivenASearchResourceRequest_WhenUserHasNoPermissions_ThenUnauthorizedExceptionThrown() + { + var authorizationService = Substitute.For>(); + var searchResourceHandler = new SearchResourceHandler( + _searchService, + _bundleFactory, + authorizationService, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + + var request = new SearchResourceRequest("Patient", null); + + // Setup authorization to return no permissions + authorizationService.CheckAccess(Arg.Any(), CancellationToken.None) + .Returns(DataActions.None); + + await Assert.ThrowsAsync(() => + searchResourceHandler.Handle(request, CancellationToken.None)); + } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHistoryHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHistoryHandlerTests.cs index 4dde22610b..3dab103a8b 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHistoryHandlerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHistoryHandlerTests.cs @@ -7,9 +7,12 @@ using System.Linq; using System.Threading; using Hl7.Fhir.Model; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Filters; +using Microsoft.Health.Fhir.Core.Features.Security; using Microsoft.Health.Fhir.Core.Features.Security.Authorization; using Microsoft.Health.Fhir.Core.Messages.Search; using Microsoft.Health.Fhir.Tests.Common; @@ -56,5 +59,61 @@ public async Task GivenASearchResourceHistoryRequest_WhenHandled_ThenABundleShou Assert.NotNull(actualResponse); Assert.Equal(expectedBundle, actualResponse.Bundle); } + + [Theory] + [InlineData(DataActions.Read)] + [InlineData(DataActions.Search)] + public async Task GivenASearchResourceHistoryRequest_WhenUserHasReadPermission_ThenSearchShouldSucceed(DataActions returnedDataAction) + { + // Arrange + var authService = Substitute.For>(); + var searchResourceHistoryHandler = new SearchResourceHistoryHandler( + _searchService, + _bundleFactory, + authService, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + + authService + .CheckAccess(DataActions.Read | DataActions.Search, CancellationToken.None) + .Returns(returnedDataAction); + + var request = new SearchResourceHistoryRequest("Patient"); + var searchResult = new SearchResult(Enumerable.Empty(), null, null, new Tuple[0]); + var expectedBundle = new Bundle().ToResourceElement(); + + _searchService.SearchHistoryAsync(request.ResourceType, null, null, null, null, null, null, null, null, CancellationToken.None).Returns(searchResult); + _bundleFactory.CreateHistoryBundle(searchResult).Returns(expectedBundle); + + // Act & Assert - Should not throw UnauthorizedFhirActionException + var actualResponse = await searchResourceHistoryHandler.Handle(request, CancellationToken.None); + + Assert.NotNull(actualResponse); + Assert.Equal(expectedBundle, actualResponse.Bundle); + } + + [Theory] + [InlineData(DataActions.None)] + [InlineData(DataActions.Write)] + [InlineData(DataActions.ReadById)] + public async Task GivenASearchResourceHistoryRequest_WhenUserLacksPermissions_ThenUnauthorizedExceptionIsThrown(DataActions returnedDataAction) + { + // Arrange + var authService = Substitute.For>(); + var searchResourceHistoryHandler = new SearchResourceHistoryHandler( + _searchService, + _bundleFactory, + authService, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + + authService + .CheckAccess(DataActions.Read | DataActions.Search, CancellationToken.None) + .Returns(returnedDataAction); + + var request = new SearchResourceHistoryRequest("Patient"); + + // Act & Assert + await Assert.ThrowsAsync(() => + searchResourceHistoryHandler.Handle(request, CancellationToken.None)); + } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Security/Authorization/RoleBasedFhirAuthorizationServiceTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Security/Authorization/RoleBasedFhirAuthorizationServiceTests.cs index 86ecde902a..a6dfbe236c 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Security/Authorization/RoleBasedFhirAuthorizationServiceTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Security/Authorization/RoleBasedFhirAuthorizationServiceTests.cs @@ -38,6 +38,11 @@ public RoleBasedFhirAuthorizationServiceTests() List roles = new List(); roles.Add(new Role("Read", DataActions.Read, "/")); roles.Add(new Role("Write", DataActions.Write, "/")); + roles.Add(new Role("Create", DataActions.Create, "/")); + roles.Add(new Role("ReadById", DataActions.ReadById, "/")); + roles.Add(new Role("Update", DataActions.Update, "/")); + roles.Add(new Role("Delete", DataActions.Delete, "/")); + roles.Add(new Role("Search", DataActions.Search, "/")); _authorizationConfiguration.Roles = roles; _fhirRequestContextAccessor = Substitute.For>(); @@ -46,159 +51,320 @@ public RoleBasedFhirAuthorizationServiceTests() _authorizationConfiguration, _fhirRequestContextAccessor); } - [Fact] - public async Task GivenUserReadDA_WhenInvoked_ForReadDA_NoSMARTScope_ThenReturnedReadDataAction() + public static IEnumerable GetAuthorizationTestData() { - var defaultFhirRequestContext = new DefaultFhirRequestContext(); - defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = false; - - var claims = new List(); - claims.Add(new Claim("roles", "Read")); - var expectedPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); - - defaultFhirRequestContext.Principal = expectedPrincipal; - _fhirRequestContextAccessor.RequestContext.Returns(defaultFhirRequestContext); - - var result = await _roleBasedFhirAuthorizationService.CheckAccess(DataActions.Read, CancellationToken.None); - Assert.Equal(DataActions.Read, result); - } - - [Fact] - public async Task GivenUserReadDA_WhenInvoked_ForWriteDA_NoSMARTScope_ThenReturnedNoneDataAction() - { - var defaultFhirRequestContext = new DefaultFhirRequestContext(); - defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = false; - - var claims = new List(); - claims.Add(new Claim("roles", "Read")); - var expectedPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); - - defaultFhirRequestContext.Principal = expectedPrincipal; - _fhirRequestContextAccessor.RequestContext.Returns(defaultFhirRequestContext); - - var result = await _roleBasedFhirAuthorizationService.CheckAccess(DataActions.Write, CancellationToken.None); - Assert.Equal(DataActions.None, result); + // Each test case sends the following parameters: + // string testDescription, bool applyFineGrained (smart), string roleClaim, string resourceType, DataActions requestedAction, + // List allowedResourceActions, DataActions expected + + // 1. No SMART scope, Read requested. Expect Read. + yield return new object[] + { + "No SMART: Read action returns Read", + false, // applyFineGrained + "Read", // roleClaim + null, // resourceType is not needed + DataActions.Read, + new List(), // no allowed scopes + DataActions.Read, + }; + + // 2. No SMART scope, Write requested with role Read. Expect None. + yield return new object[] + { + "No SMART: Write action with Read role returns None", + false, + "Read", + null, + DataActions.Write, + new List(), + DataActions.None, + }; + + // 3. With SMART scope V1: For PatientRead, allowed scope for Patient with Read is present. + yield return new object[] + { + "SMART: For Patient resource, allowed Patient Read returns Read", + true, + "Read", + KnownResourceTypes.Patient, + DataActions.Read, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Read, "user1"), + }, + DataActions.Read, + }; + + // 4. With SMART scope V1: For PatientRead, allowed scope for Medication (not matching) returns None. + yield return new object[] + { + "SMART: For Patient resource, allowed Medication Read returns None", + true, + "Read", + KnownResourceTypes.Patient, + DataActions.Read, + new List + { + new ScopeRestriction(KnownResourceTypes.Medication, DataActions.Read, "user1"), + }, + DataActions.None, + }; + + // 5. With SMART scope V1: For Patient Write, if only Patient Read is allowed, then Write returns None. + yield return new object[] + { + "SMART: For Patient Write, allowed only Patient Read returns None", + true, + "Write", + KnownResourceTypes.Patient, + DataActions.Write, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Read, "user1"), + }, + DataActions.None, + }; + + // 6. With SMART scope V1: For Patient Write, allowed scopes include both Read and Write, so Write returns Write. + yield return new object[] + { + "SMART: For Patient Write, allowed Patient Read and Write returns Write", + true, + "Write", + KnownResourceTypes.Patient, + DataActions.Write, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Read, "user1"), + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Write, "user1"), + }, + DataActions.Write, + }; + + // 7. With SMART scope V1: For Patient Write, if only Observation Write is allowed then returns None. + yield return new object[] + { + "SMART: For Patient Write, allowed Observation Write (mismatch) returns None", + true, + "Write", + KnownResourceTypes.Patient, + DataActions.Write, + new List + { + new ScopeRestriction(KnownResourceTypes.Observation, DataActions.Write, "user1"), + }, + DataActions.None, + }; + + // 8. With SMART scope V1: For Patient Read, if allowed All is provided, then Read returns Read. + yield return new object[] + { + "SMART: For Patient Read with all resources allowed returns Read", + true, + "Read", + KnownResourceTypes.Patient, + DataActions.Read, + new List + { + new ScopeRestriction(KnownResourceTypes.All, DataActions.Read, "user1"), + }, + DataActions.Read, + }; + + // 9. With SMART scope V2: For PatientReadById, allowed scope for Patient with ReadById is present. + yield return new object[] + { + "SMART: For Patient resource, allowed Patient ReadById returns ReadById", + true, + "ReadById", + KnownResourceTypes.Patient, + DataActions.ReadById, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.ReadById, "user1"), + }, + DataActions.ReadById, + }; + + // 10. With SMART scope V2: For PatientReadById, allowed scope for Medication (not matching) returns None. + yield return new object[] + { + "SMART: For Patient resource, allowed Medication Read returns None", + true, + "ReadById", + KnownResourceTypes.Patient, + DataActions.ReadById, + new List + { + new ScopeRestriction(KnownResourceTypes.Medication, DataActions.ReadById, "user1"), + }, + DataActions.None, + }; + + // 11. With SMART scope V2: For Patient Create, if only Patient Read is allowed, then Create returns None. + yield return new object[] + { + "SMART: For Patient Create, allowed only Patient Read returns None", + true, + "Create", + KnownResourceTypes.Patient, + DataActions.Create, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Read, "user1"), + }, + DataActions.None, + }; + + // 12. With SMART scope V2: For Patient Update, if only Patient Read is allowed, then Create returns None. + yield return new object[] + { + "SMART: For Patient Update, allowed only Patient Read returns None", + true, + "Update", + KnownResourceTypes.Patient, + DataActions.Update, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Read, "user1"), + }, + DataActions.None, + }; + + // 13. With SMART scope V2: For Patient Create, if only Patient Update is allowed, then Create returns None. + yield return new object[] + { + "SMART: For Patient Create, allowed only Patient Update returns None", + true, + "Create", + KnownResourceTypes.Patient, + DataActions.Create, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Update, "user1"), + }, + DataActions.None, + }; + + // 14. With SMART scope V2: For Patient Update, if only Patient Create is allowed, then Update returns None. + yield return new object[] + { + "SMART: For Patient Update, allowed only Patient Create returns None", + true, + "Update", + KnownResourceTypes.Patient, + DataActions.Update, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Create, "user1"), + }, + DataActions.None, + }; + + // 15. With SMART scope V2: For Patient Create, allowed scopes include both Create and Update, so Create returns Create. + yield return new object[] + { + "SMART: For Patient Create, allowed Patient Create and Update returns Create", + true, + "Create", + KnownResourceTypes.Patient, + DataActions.Create, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Update, "user1"), + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Create, "user1"), + }, + DataActions.Create, + }; + + // 16. With SMART scope V2: For Patient Update, allowed scopes include both Create and Update, so Update returns Update. + yield return new object[] + { + "SMART: For Patient Update, allowed Patient Create and Update returns Update", + true, + "Update", + KnownResourceTypes.Patient, + DataActions.Update, + new List + { + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Update, "user1"), + new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Create, "user1"), + }, + DataActions.Update, + }; + + // 17. With SMART scope V2: For PatientCreate, if only Observation Update is allowed then returns None. + yield return new object[] + { + "SMART: For Patient Create, allowed Observation Update (mismatch) returns None", + true, + "Create", + KnownResourceTypes.Patient, + DataActions.Update, + new List + { + new ScopeRestriction(KnownResourceTypes.Observation, DataActions.Update, "user1"), + }, + DataActions.None, + }; + + // 18. With SMART scope V2: For PatientUpdate, if only Observation Create is allowed then returns None. + yield return new object[] + { + "SMART: For Patient Update, allowed Observation Create (mismatch) returns None", + true, + "Update", + KnownResourceTypes.Patient, + DataActions.Update, + new List + { + new ScopeRestriction(KnownResourceTypes.Observation, DataActions.Create, "user1"), + }, + DataActions.None, + }; } - [Fact] - public async Task GivenUserReadDA_WhenInvokedForPatientRead_SMARTScopePatientRead_ThenReturnedReadDataAction() + [Theory] + [MemberData(nameof(GetAuthorizationTestData))] + public async Task CombinedAuthorizationTests( + string testDescription, + bool applyFineGrained, + string roleClaim, + string resourceType, + DataActions requestedAction, + List allowedResourceActions, + DataActions expected) { + // Arrange var defaultFhirRequestContext = new DefaultFhirRequestContext(); - defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = true; - + defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = applyFineGrained; var claims = new List(); - claims.Add(new Claim("roles", "Read")); - var expectedPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + claims.Add(new Claim("roles", roleClaim)); + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); - defaultFhirRequestContext.ResourceType = KnownResourceTypes.Patient; - defaultFhirRequestContext.Principal = expectedPrincipal; + // Set resource type if provided; if null, leave it as the default. + if (!string.IsNullOrEmpty(resourceType)) + { + defaultFhirRequestContext.ResourceType = resourceType; + } - _fhirRequestContextAccessor.RequestContext.Returns(defaultFhirRequestContext); - _fhirRequestContextAccessor.RequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Read, "user1")); - - var result = await _roleBasedFhirAuthorizationService.CheckAccess(DataActions.Read, CancellationToken.None); - Assert.Equal(DataActions.Read, result); - } - - [Fact] - public async Task GivenUserReadDA_WhenInvokedForPatientRead_SMARTScopeMedicationRead_ThenReturnedNoneDataAction() - { - var defaultFhirRequestContext = new DefaultFhirRequestContext(); - defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = true; - - var claims = new List(); - claims.Add(new Claim("roles", "Read")); - var expectedPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); - - defaultFhirRequestContext.ResourceType = KnownResourceTypes.Patient; - defaultFhirRequestContext.Principal = expectedPrincipal; + defaultFhirRequestContext.Principal = principal; _fhirRequestContextAccessor.RequestContext.Returns(defaultFhirRequestContext); - _fhirRequestContextAccessor.RequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(KnownResourceTypes.Medication, DataActions.Read, "user1")); - - var result = await _roleBasedFhirAuthorizationService.CheckAccess(DataActions.Read, CancellationToken.None); - Assert.Equal(DataActions.None, result); - } - - [Fact] - public async Task GivenUserWriteDA_WhenInvokedForPatientWrite_SMARTScopePatientRead_ThenReturnedNoneDataAction() - { - var defaultFhirRequestContext = new DefaultFhirRequestContext(); - defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = true; - - var claims = new List(); - claims.Add(new Claim("roles", "Write")); - var expectedPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); - defaultFhirRequestContext.ResourceType = KnownResourceTypes.Patient; - defaultFhirRequestContext.Principal = expectedPrincipal; + // Clear and set allowed scopes. + defaultFhirRequestContext.AccessControlContext.AllowedResourceActions.Clear(); + foreach (var scope in allowedResourceActions) + { + defaultFhirRequestContext.AccessControlContext.AllowedResourceActions.Add(scope); + } - _fhirRequestContextAccessor.RequestContext.Returns(defaultFhirRequestContext); - _fhirRequestContextAccessor.RequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Read, "user1")); - - var result = await _roleBasedFhirAuthorizationService.CheckAccess(DataActions.Write, CancellationToken.None); - Assert.Equal(DataActions.None, result); - } - - [Fact] - public async Task GivenUserWriteDA_WhenInvokedForPatientWrite_SMARTScopePatientReadAndWrite_ThenReturnedWriteDataAction() - { - var defaultFhirRequestContext = new DefaultFhirRequestContext(); - defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = true; - - var claims = new List(); - claims.Add(new Claim("roles", "Write")); - var expectedPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); - - defaultFhirRequestContext.ResourceType = KnownResourceTypes.Patient; - defaultFhirRequestContext.Principal = expectedPrincipal; - - _fhirRequestContextAccessor.RequestContext.Returns(defaultFhirRequestContext); - _fhirRequestContextAccessor.RequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Read, "user1")); - _fhirRequestContextAccessor.RequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(KnownResourceTypes.Patient, DataActions.Write, "user1")); - - var result = await _roleBasedFhirAuthorizationService.CheckAccess(DataActions.Write, CancellationToken.None); - Assert.Equal(DataActions.Write, result); - } - - [Fact] - public async Task GivenUserWriteDA_WhenInvokedForPatientWrite_SMARTScopeObservationWrite_ThenReturnedNoneDataAction() - { - var defaultFhirRequestContext = new DefaultFhirRequestContext(); - defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = true; - - var claims = new List(); - claims.Add(new Claim("roles", "Write")); - var expectedPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); - - defaultFhirRequestContext.ResourceType = KnownResourceTypes.Patient; - defaultFhirRequestContext.Principal = expectedPrincipal; - - _fhirRequestContextAccessor.RequestContext.Returns(defaultFhirRequestContext); - _fhirRequestContextAccessor.RequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(KnownResourceTypes.Observation, DataActions.Write, "user1")); - - var result = await _roleBasedFhirAuthorizationService.CheckAccess(DataActions.Write, CancellationToken.None); - Assert.Equal(DataActions.None, result); - } - - [Fact] - public async Task GivenUserReadDA_WhenInvokedForPatientRead_SMARTScopeAllResourcesRead_ThenReturnedReadDataAction() - { - var defaultFhirRequestContext = new DefaultFhirRequestContext(); - defaultFhirRequestContext.AccessControlContext.ApplyFineGrainedAccessControl = true; - - var claims = new List(); - claims.Add(new Claim("roles", "Read")); - var expectedPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); - - defaultFhirRequestContext.ResourceType = KnownResourceTypes.Patient; - defaultFhirRequestContext.Principal = expectedPrincipal; - - _fhirRequestContextAccessor.RequestContext.Returns(defaultFhirRequestContext); - _fhirRequestContextAccessor.RequestContext.AccessControlContext.AllowedResourceActions.Add(new ScopeRestriction(KnownResourceTypes.All, DataActions.Read, "user1")); + // Act + var result = await _roleBasedFhirAuthorizationService.CheckAccess(requestedAction, CancellationToken.None); - var result = await _roleBasedFhirAuthorizationService.CheckAccess(DataActions.Read, CancellationToken.None); - Assert.Equal(DataActions.Read, result); + // Assert + Assert.True(expected == result, testDescription); } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems index 72a549060c..2b49b1c67d 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems @@ -35,6 +35,7 @@ + diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs b/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs index 976a87ef5f..3e52301f8d 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs @@ -173,7 +173,7 @@ public static async Task GetSmartConfigurationAsync(th var response = await mediator.Send(new GetSmartConfigurationRequest(), cancellationToken); - return new SmartConfigurationResult(response.AuthorizationEndpoint, response.TokenEndpoint, response.Capabilities); + return new SmartConfigurationResult(response.AuthorizationEndpoint, response.TokenEndpoint, response.Capabilities, response.ScopesSupported); } public static async Task GetOperationVersionsAsync(this IMediator mediator, CancellationToken cancellationToken = default) diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/ConditionalResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/ConditionalResourceHandler.cs index 6708110997..39f00e559a 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/ConditionalResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/ConditionalResourceHandler.cs @@ -49,7 +49,17 @@ public async Task Handle(TRequest request, CancellationToken cancella { EnsureArg.IsNotNull(request, nameof(request)); - if (await AuthorizationService.CheckAccess(DataActions.Read | DataActions.Write, cancellationToken) != (DataActions.Read | DataActions.Write)) + // Get the required permissions for this specific conditional operation + var requiredPermissions = GetRequiredPermissions(request); + var granted = await AuthorizationService.CheckAccess(requiredPermissions.legacyPermissions | requiredPermissions.granularPermissions, cancellationToken); + + // Check if user has the required permissions: + // 1. Legacy: Read + Write + // 2. Granular: Search + Create/Update/Delete (specific combinations) + bool hasLegacyPermissions = (granted & requiredPermissions.legacyPermissions) == requiredPermissions.legacyPermissions; + bool hasGranularPermissions = (granted & requiredPermissions.granularPermissions) == requiredPermissions.granularPermissions; + + if (!hasLegacyPermissions && !hasGranularPermissions) { throw new UnauthorizedFhirActionException(); } @@ -80,6 +90,17 @@ public async Task Handle(TRequest request, CancellationToken cancella } } + /// + /// Gets the required permissions for the specific conditional operation. + /// Returns both legacy permissions (Read + Write) and granular permissions (Search + specific action). + /// + protected virtual (DataActions legacyPermissions, DataActions granularPermissions) GetRequiredPermissions(TRequest request) + { + // Default: Legacy Read+Write, Granular Search+Create/Update + // Concrete implementations should override this to specify the exact granular permissions + return (DataActions.Read | DataActions.Write, DataActions.Search | DataActions.Create | DataActions.Update); + } + public abstract Task HandleSingleMatch(TRequest request, SearchResultEntry match, CancellationToken cancellationToken); public abstract Task HandleNoMatch(TRequest request, CancellationToken cancellationToken); diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Create/ConditionalCreateResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Create/ConditionalCreateResourceHandler.cs index 56a36cc9e3..fe3252011c 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Create/ConditionalCreateResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Create/ConditionalCreateResourceHandler.cs @@ -51,5 +51,14 @@ public override Task HandleSingleMatch(ConditionalCreate var saveOutcome = new SaveOutcome(new Models.RawResourceElement(match.Resource), SaveOutcomeType.MatchFound); return Task.FromResult(new UpsertResourceResponse(saveOutcome)); } + + /// + /// Conditional create requires search permissions (to find existing resources) and create permissions (for new resources). + /// Legacy: Read + Write, SMART v2: Search + Create + /// + protected override (DataActions legacyPermissions, DataActions granularPermissions) GetRequiredPermissions(ConditionalCreateResourceRequest request) + { + return (DataActions.Read | DataActions.Write, DataActions.Search | DataActions.Create); + } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Create/CreateResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Create/CreateResourceHandler.cs index c93c7398d4..1f3c95602f 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Create/CreateResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Create/CreateResourceHandler.cs @@ -47,7 +47,11 @@ public async Task Handle(CreateResourceRequest request, { EnsureArg.IsNotNull(request, nameof(request)); - if (await AuthorizationService.CheckAccess(DataActions.Write, cancellationToken) != DataActions.Write) + // Check for granular Create permission (SMART v2) or legacy Write permission (SMART v1/backward compatibility) + DataActions requiredActions = DataActions.Create | DataActions.Write; + DataActions allowedActions = await AuthorizationService.CheckAccess(requiredActions, cancellationToken); + + if ((allowedActions & requiredActions) == DataActions.None) { throw new UnauthorizedFhirActionException(); } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Delete/ConditionalDeleteResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Delete/ConditionalDeleteResourceHandler.cs index 31bf71b693..c6f0ba2e6d 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Delete/ConditionalDeleteResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Delete/ConditionalDeleteResourceHandler.cs @@ -67,9 +67,22 @@ public async Task Handle(ConditionalDeleteResourceReques { EnsureArg.IsNotNull(request, nameof(request)); - DataActions dataActions = (request.DeleteOperation == DeleteOperation.SoftDelete ? DataActions.Delete : DataActions.HardDelete | DataActions.Delete) | DataActions.Read; + // Build required permissions: delete permission + read/search permission for conditional operations + DataActions deletePermissions = request.DeleteOperation == DeleteOperation.SoftDelete ? DataActions.Delete : DataActions.HardDelete | DataActions.Delete; + DataActions searchPermissions = DataActions.Read | DataActions.Search; // Support both legacy Read and SMART v2 Search + DataActions requiredPermissions = deletePermissions | searchPermissions; // Include legacy Write support - if (await AuthorizationService.CheckAccess(dataActions, cancellationToken) != dataActions) + var grantedAccess = await AuthorizationService.CheckAccess(requiredPermissions, cancellationToken); + + // Check if user has required delete permissions (granular or legacy Write) + bool hasDeletePermission = request.DeleteOperation == DeleteOperation.SoftDelete + ? (grantedAccess & DataActions.Delete) != 0 + : (grantedAccess & (DataActions.HardDelete | DataActions.Delete)) == (DataActions.HardDelete | DataActions.Delete); + + // Check if user has required search permissions for conditional operations + bool hasSearchPermission = (grantedAccess & (DataActions.Read | DataActions.Search)) != 0; + + if (!hasDeletePermission || !hasSearchPermission) { throw new UnauthorizedFhirActionException(); } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Get/GetResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Get/GetResourceHandler.cs index 048b540251..c35120dea3 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Get/GetResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Get/GetResourceHandler.cs @@ -49,7 +49,9 @@ public async Task Handle(GetResourceRequest request, Cancel { EnsureArg.IsNotNull(request, nameof(request)); - if (await AuthorizationService.CheckAccess(DataActions.Read, cancellationToken) != DataActions.Read) + // Check for either legacy Read permission or new SMART v2 ReadV2 permission + var grantedAccess = await AuthorizationService.CheckAccess(DataActions.Read | DataActions.ReadById, cancellationToken); + if ((grantedAccess & (DataActions.Read | DataActions.ReadById)) == DataActions.None) { throw new UnauthorizedFhirActionException(); } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/ConditionalPatchResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/ConditionalPatchResourceHandler.cs index b3dda7cc91..41154b40a6 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/ConditionalPatchResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/ConditionalPatchResourceHandler.cs @@ -65,5 +65,14 @@ public override async Task HandleSingleMatch(Conditional var patchedResource = request.Payload.Patch(match.Resource); return await _mediator.Send(new UpsertResourceRequest(patchedResource, bundleResourceContext: null), cancellationToken); } + + /// + /// Conditional patch requires search permissions (to find existing resources) and update permissions (for modifications). + /// Legacy: Read + Write, SMART v2: Search + Update + /// + protected override (DataActions legacyPermissions, DataActions granularPermissions) GetRequiredPermissions(ConditionalPatchResourceRequest request) + { + return (DataActions.Read | DataActions.Write, DataActions.Search | DataActions.Update); + } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/PatchResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/PatchResourceHandler.cs index d1a7344901..e1c91a7a9d 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/PatchResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/PatchResourceHandler.cs @@ -46,7 +46,10 @@ public async Task Handle(PatchResourceRequest request, C { EnsureArg.IsNotNull(request, nameof(request)); - if (await AuthorizationService.CheckAccess(DataActions.Read | DataActions.Write, cancellationToken) != (DataActions.Read | DataActions.Write)) + // Check for granular permissions (Update) or legacy permissions (Read + Write) + var granted = await AuthorizationService.CheckAccess(DataActions.Update | DataActions.Read | DataActions.Write, cancellationToken); + if ((granted & DataActions.Update) != DataActions.Update && + (granted & (DataActions.Read | DataActions.Write)) != (DataActions.Read | DataActions.Write)) { throw new UnauthorizedFhirActionException(); } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/ConditionalUpsertResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/ConditionalUpsertResourceHandler.cs index 02f2efdaa9..c19c35dd85 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/ConditionalUpsertResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/ConditionalUpsertResourceHandler.cs @@ -78,5 +78,14 @@ public override async Task HandleSingleMatch(Conditional throw new BadRequestException(string.Format(Core.Resources.ConditionalUpdateMismatchedIds, resourceWrapper.ResourceId, resource.Id)); } } + + /// + /// Conditional update requires search permissions (to find existing resources) and update permissions (for modifications). + /// Legacy: Read + Write, SMART v2: Search + Update + /// + protected override (DataActions legacyPermissions, DataActions granularPermissions) GetRequiredPermissions(ConditionalUpsertResourceRequest request) + { + return (DataActions.Read | DataActions.Write, DataActions.Search | DataActions.Update); + } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/UpsertResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/UpsertResourceHandler.cs index 68f8f9725d..903b75c83c 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/UpsertResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/UpsertResourceHandler.cs @@ -10,11 +10,14 @@ using EnsureThat; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Model; +using Hl7.Fhir.Rest; using MediatR; +using Microsoft.Health.Core.Features.Context; using Microsoft.Health.Core.Features.Security.Authorization; using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Conformance; +using Microsoft.Health.Fhir.Core.Features.Context; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Features.Security; using Microsoft.Health.Fhir.Core.Messages.Upsert; @@ -30,6 +33,7 @@ public partial class UpsertResourceHandler : BaseResourceHandler, IRequestHandle private readonly ResourceReferenceResolver _referenceResolver; private readonly Dictionary _referenceIdDictionary; private readonly IModelInfoProvider _modelInfoProvider; + private readonly RequestContextAccessor _contextAccessor; public UpsertResourceHandler( IFhirDataStore fhirDataStore, @@ -37,15 +41,18 @@ public UpsertResourceHandler( IResourceWrapperFactory resourceWrapperFactory, ResourceIdProvider resourceIdProvider, ResourceReferenceResolver referenceResolver, + RequestContextAccessor contextAccessor, IAuthorizationService authorizationService, IModelInfoProvider modelInfoProvider) : base(fhirDataStore, conformanceProvider, resourceWrapperFactory, resourceIdProvider, authorizationService) { EnsureArg.IsNotNull(modelInfoProvider, nameof(modelInfoProvider)); EnsureArg.IsNotNull(referenceResolver, nameof(referenceResolver)); + EnsureArg.IsNotNull(contextAccessor, nameof(contextAccessor)); _referenceResolver = referenceResolver; _modelInfoProvider = modelInfoProvider; + _contextAccessor = contextAccessor; _referenceIdDictionary = new Dictionary(); } @@ -53,9 +60,63 @@ public async Task Handle(UpsertResourceRequest request, { EnsureArg.IsNotNull(request, nameof(request)); - if (await AuthorizationService.CheckAccess(DataActions.Write, cancellationToken) != DataActions.Write) + // Determine HTTP method, preferring Bundle context over request context + Hl7.Fhir.Model.Bundle.HTTPVerb? method = request.BundleResourceContext?.HttpVerb; + + if (method == null && _contextAccessor?.RequestContext?.Method != null) + { + if (System.Enum.TryParse(_contextAccessor.RequestContext.Method, true, out var parsedMethod)) + { + method = parsedMethod; + } + } + + if (method == Hl7.Fhir.Model.Bundle.HTTPVerb.POST) + { + // Explicit create via POST + var granted = await AuthorizationService.CheckAccess(DataActions.Create | DataActions.Write, cancellationToken); + if ((granted & (DataActions.Create | DataActions.Write)) == DataActions.None) + { + throw new UnauthorizedFhirActionException(); + } + } + else if (method == Hl7.Fhir.Model.Bundle.HTTPVerb.PUT) + { + // Explicit update via PUT + var granted = await AuthorizationService.CheckAccess(DataActions.Update | DataActions.Write, cancellationToken); + if ((granted & (DataActions.Update | DataActions.Write)) == DataActions.None) + { + throw new UnauthorizedFhirActionException(); + } + } + else { - throw new UnauthorizedFhirActionException(); + // Fallback when method is unavailable: infer from ETag/Id + var tmp = request.Resource?.ToPoco(); + if (string.IsNullOrEmpty(tmp?.Id)) + { + var granted = await AuthorizationService.CheckAccess(DataActions.Create | DataActions.Write, cancellationToken); + if ((granted & (DataActions.Create | DataActions.Write)) == DataActions.None) + { + throw new UnauthorizedFhirActionException(); + } + } + else if (request.WeakETag != null) + { + var granted = await AuthorizationService.CheckAccess(DataActions.Update | DataActions.Write, cancellationToken); + if ((granted & (DataActions.Update | DataActions.Write)) == DataActions.None) + { + throw new UnauthorizedFhirActionException(); + } + } + else + { + var granted = await AuthorizationService.CheckAccess(DataActions.Create | DataActions.Update | DataActions.Write, cancellationToken); + if ((granted & (DataActions.Create | DataActions.Update | DataActions.Write)) == DataActions.None) + { + throw new UnauthorizedFhirActionException(); + } + } } Resource resource = request.Resource.ToPoco(); diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchOptionsFactory.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchOptionsFactory.cs index 8dbb1dbb18..3e139c08d2 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchOptionsFactory.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchOptionsFactory.cs @@ -231,7 +231,7 @@ public SearchOptions Create( if (string.Equals(query.Item2, "*:*", StringComparison.OrdinalIgnoreCase)) { notReferencedSearch = true; - } + } else { _contextAccessor.RequestContext?.BundleIssues.Add( @@ -239,7 +239,7 @@ public SearchOptions Create( OperationOutcomeConstants.IssueSeverity.Warning, OperationOutcomeConstants.IssueType.NotSupported, Core.Resources.NotReferencedParameterInvalidValue)); - } + } } else if (string.Equals(query.Item1, KnownQueryParameterNames.IncludesContinuationToken, StringComparison.OrdinalIgnoreCase)) { @@ -402,7 +402,7 @@ public SearchOptions Create( searchExpressions.Add(Expression.SearchParameter(ResourceTypeSearchParameter, Expression.StringEquals(FieldName.TokenCode, null, resourceType, false))); } - CheckFineGrainedAccessControl(searchExpressions); + CheckFineGrainedAccessControl(searchExpressions, searchParams, parsedResourceTypes); var resourceTypesString = parsedResourceTypes.Select(x => x.ToString()).ToArray(); @@ -745,19 +745,32 @@ private void LogSearchParameterData(Uri url, bool isMissing = false) _logger.LogInformation(logOutput); } - private void CheckFineGrainedAccessControl(List searchExpressions) + private void CheckFineGrainedAccessControl(List searchExpressions, SearchParams searchParams, string[] parsedResourceTypes) { // check resource type restrictions from SMART clinical scopes if (_contextAccessor.RequestContext?.AccessControlContext?.ApplyFineGrainedAccessControl == true) { bool allowAllResourceTypes = false; var clinicalScopeResources = new List(); + var finalSmartSearchExpressions = new List(); foreach (ScopeRestriction restriction in _contextAccessor.RequestContext?.AccessControlContext.AllowedResourceActions) { if (restriction.Resource == KnownResourceTypes.All) { allowAllResourceTypes = true; + + // Check if SMART V2 search parameter constraint exists + // If yes then we can add it to searchParams before breaking + // This should get ANDed with main query and be applied as a common search parameter across all resource types + if (restriction.SearchParameters != null && restriction.SearchParameters.Parameters.Any()) + { + foreach (var param in restriction.SearchParameters.Parameters) + { + searchParams.Add(param.Item1, param.Item2); + } + } + break; } @@ -766,6 +779,37 @@ private void CheckFineGrainedAccessControl(List searchExpressions) throw new ResourceNotSupportedException(restriction.Resource); } + // Form the AND expression for resource type and its searchParameters restrictions. + var smartSearchExpressions = new List(); + var smartSearchParams = new SearchParams(); + + // Check if there are any search parameter constraint for this clinicalScopeResourceType + // If search parameters are defined in the restriction, add them to searchParams. + if (restriction.SearchParameters != null && restriction.SearchParameters.Parameters.Any()) + { + smartSearchExpressions.Add(Expression.SearchParameter(ResourceTypeSearchParameter, Expression.StringEquals(FieldName.TokenCode, null, clinicalScopeResourceType.ToString(), false))); + foreach (var param in restriction.SearchParameters.Parameters) + { + smartSearchParams.Add(param.Item1, param.Item2); + } + + smartSearchExpressions.AddRange(smartSearchParams.Parameters.Select( + q => + { + try + { + return _expressionParser.Parse(new[] { clinicalScopeResourceType.ToString() }, q.Item1, q.Item2); + } + catch (SearchParameterNotSupportedException) + { + return null; + } + }) + .Where(item => item != null)); + + finalSmartSearchExpressions.Add(Expression.And(smartSearchExpressions.ToArray())); + } + clinicalScopeResources.Add(clinicalScopeResourceType); } @@ -780,6 +824,11 @@ private void CheckFineGrainedAccessControl(List searchExpressions) searchExpressions.Add(Expression.SearchParameter(ResourceTypeSearchParameter, Expression.StringEquals(FieldName.TokenCode, null, "none", false))); } } + + if (finalSmartSearchExpressions.Any()) + { + searchExpressions.Add(Expression.Or(finalSmartSearchExpressions.ToArray())); + } } } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchResourceHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchResourceHandler.cs index 5a919a531d..c6a1037445 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchResourceHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchResourceHandler.cs @@ -50,7 +50,13 @@ public async Task Handle(SearchResourceRequest request, { EnsureArg.IsNotNull(request, nameof(request)); - if (await _authorizationService.CheckAccess(DataActions.Read, cancellationToken) != DataActions.Read) + // For SMART v2 compliance, search operations require the Search permission. + // SMART v2 scopes like "patient/Patient.r" allow read-only access without search capability, + // while "patient/Patient.s" or "patient/Patient.rs" include search permissions. + // Users with only read permission can access resources directly by ID but cannot search. + // We continue to allow DataActions.Read for legacy support + var grantedAccess = await _authorizationService.CheckAccess(DataActions.Search | DataActions.Read, cancellationToken); + if ((grantedAccess & (DataActions.Search | DataActions.Read)) == 0) { throw new UnauthorizedFhirActionException(); } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchResourceHistoryHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchResourceHistoryHandler.cs index 7996cdc3fc..ba6a397ac3 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchResourceHistoryHandler.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchResourceHistoryHandler.cs @@ -39,7 +39,9 @@ public async Task Handle(SearchResourceHistoryReq { EnsureArg.IsNotNull(request, nameof(request)); - if (await _authorizationService.CheckAccess(DataActions.Read, cancellationToken) != DataActions.Read) + // Check for either legacy Read permission or new SMART v2 Search permission + var grantedAccess = await _authorizationService.CheckAccess(DataActions.Read | DataActions.Search, cancellationToken); + if ((grantedAccess & (DataActions.Read | DataActions.Search)) == DataActions.None) { throw new UnauthorizedFhirActionException(); } diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs index 97be0289fe..3c5170b242 100644 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs @@ -16,6 +16,7 @@ using Microsoft.Health.Fhir.SqlServer.Features.Schema; using Microsoft.Health.Fhir.SqlServer.Features.Search; using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; +using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; using Microsoft.Health.Fhir.SqlServer.Features.Storage; using Microsoft.Health.Fhir.Tests.Common; @@ -34,6 +35,7 @@ namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search; public class SqlQueryGeneratorTests { private readonly ISqlServerFhirModel _fhirModel; + private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; private readonly SchemaInformation _schemaInformation = new(SchemaVersionConstants.Min, SchemaVersionConstants.Max); private readonly IndentedStringBuilder _strBuilder = new(new StringBuilder()); private readonly SqlQueryGenerator _queryGenerator; @@ -41,6 +43,11 @@ public class SqlQueryGeneratorTests public SqlQueryGeneratorTests() { _fhirModel = Substitute.For(); + + // Create real instances instead of mocking since factory is internal + var searchParameterToSearchValueTypeMap = new SearchParameterToSearchValueTypeMap(); + _queryGeneratorFactory = new SearchParamTableExpressionQueryGeneratorFactory(searchParameterToSearchValueTypeMap); + _schemaInformation.Current = SchemaVersionConstants.Max; using Data.SqlClient.SqlCommand command = new(); @@ -51,6 +58,7 @@ public SqlQueryGeneratorTests() parameters, _fhirModel, _schemaInformation, + _queryGeneratorFactory, false, false); } diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs index f1247d234d..9f49afb726 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs @@ -17,7 +17,9 @@ using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.SqlServer.Features.Schema; using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; +using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.Health.Fhir.ValueSets; using Microsoft.Health.SqlServer; using Microsoft.Health.SqlServer.Features.Schema; using Microsoft.Health.SqlServer.Features.Schema.Model; @@ -57,12 +59,14 @@ internal class SqlQueryGenerator : DefaultSqlExpressionVisitor _searchParamIds = new(); + private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; public SqlQueryGenerator( IndentedStringBuilder sb, HashingSqlQueryParameterManager parameters, ISqlServerFhirModel model, SchemaInformation schemaInfo, + SearchParamTableExpressionQueryGeneratorFactory queryGeneratorFactory, bool reuseQueryPlans, bool isAsyncOperation, SqlException sqlException = null) @@ -71,11 +75,13 @@ public SqlQueryGenerator( EnsureArg.IsNotNull(parameters, nameof(parameters)); EnsureArg.IsNotNull(model, nameof(model)); EnsureArg.IsNotNull(schemaInfo, nameof(schemaInfo)); + EnsureArg.IsNotNull(queryGeneratorFactory, nameof(queryGeneratorFactory)); StringBuilder = sb; Parameters = parameters; Model = model; _schemaInfo = schemaInfo; + _queryGeneratorFactory = queryGeneratorFactory; _reuseQueryPlans = reuseQueryPlans; _isAsyncOperation = isAsyncOperation; @@ -1179,7 +1185,7 @@ private SearchParameterQueryGeneratorContext GetContext(string tableAlias = null return new SearchParameterQueryGeneratorContext(StringBuilder, Parameters, Model, _schemaInfo, isAsyncOperation: _isAsyncOperation, tableAlias); } - private void AppendNewSetOfUnionAllTableExpressions(SearchOptions context, UnionExpression unionExpression, SearchParamTableExpressionQueryGenerator queryGenerator) + private void AppendNewSetOfUnionAllTableExpressions(SearchOptions context, UnionExpression unionExpression, SearchParamTableExpressionQueryGenerator defaultQueryGenerator) { if (unionExpression.Operator != UnionOperator.All) { @@ -1190,6 +1196,9 @@ private void AppendNewSetOfUnionAllTableExpressions(SearchOptions context, Union int firstInclusiveTableExpressionId = _tableExpressionCounter + 1; foreach (Expression innerExpression in unionExpression.Expressions) { + // Determine the appropriate query generator for this specific inner expression + var queryGenerator = DetermineQueryGeneratorForExpression(innerExpression, defaultQueryGenerator); + var searchParamExpression = new SearchParamTableExpression( queryGenerator, innerExpression, @@ -1257,6 +1266,17 @@ private void AppendNewTableExpression(IndentedStringBuilder sb, SearchParamTable sb.Append(")"); } + /// + /// Determines the appropriate query generator for a specific expression within a UNION. + /// This allows different expressions in a UNION to use different underlying SQL tables. + /// + private SearchParamTableExpressionQueryGenerator DetermineQueryGeneratorForExpression(Expression expression, SearchParamTableExpressionQueryGenerator defaultQueryGenerator) + { + // Use the factory to determine the appropriate query generator for this expression + var specificGenerator = expression.AcceptVisitor(_queryGeneratorFactory, _queryGeneratorFactory.InitialContext); + return specificGenerator ?? defaultQueryGenerator; + } + private bool UseAppendWithJoin() { // if either: diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchService.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchService.cs index e27deb179e..0f6918686c 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchService.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchService.cs @@ -54,6 +54,7 @@ internal class SqlServerSearchService : SearchService private readonly ISqlServerFhirModel _model; private readonly SqlRootExpressionRewriter _sqlRootExpressionRewriter; + private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; private readonly SortRewriter _sortRewriter; private readonly PartitionEliminationRewriter _partitionEliminationRewriter; @@ -86,6 +87,7 @@ public SqlServerSearchService( PartitionEliminationRewriter partitionEliminationRewriter, CompartmentSearchRewriter compartmentSearchRewriter, SmartCompartmentSearchRewriter smartCompartmentSearchRewriter, + SearchParamTableExpressionQueryGeneratorFactory queryGeneratorFactory, ISqlRetryService sqlRetryService, IOptions sqlServerDataStoreConfiguration, SchemaInformation schemaInformation, @@ -102,12 +104,14 @@ public SqlServerSearchService( EnsureArg.IsNotNull(partitionEliminationRewriter, nameof(partitionEliminationRewriter)); EnsureArg.IsNotNull(compartmentSearchRewriter, nameof(compartmentSearchRewriter)); EnsureArg.IsNotNull(smartCompartmentSearchRewriter, nameof(smartCompartmentSearchRewriter)); + EnsureArg.IsNotNull(queryGeneratorFactory, nameof(queryGeneratorFactory)); EnsureArg.IsNotNull(requestContextAccessor, nameof(requestContextAccessor)); EnsureArg.IsNotNull(logger, nameof(logger)); _sqlServerDataStoreConfiguration = EnsureArg.IsNotNull(sqlServerDataStoreConfiguration?.Value, nameof(sqlServerDataStoreConfiguration)); _model = model; _sqlRootExpressionRewriter = sqlRootExpressionRewriter; + _queryGeneratorFactory = queryGeneratorFactory; _sortRewriter = sortRewriter; _partitionEliminationRewriter = partitionEliminationRewriter; _compartmentSearchRewriter = compartmentSearchRewriter; @@ -387,6 +391,7 @@ await _sqlRetryService.ExecuteSql( new HashingSqlQueryParameterManager(new SqlQueryParameterManager(sqlCommand.Parameters)), _model, _schemaInformation, + _queryGeneratorFactory, reuseQueryPlans, sqlSearchOptions.IsAsyncOperation, sqlException); @@ -1347,6 +1352,7 @@ await _sqlRetryService.ExecuteSql( new HashingSqlQueryParameterManager(new SqlQueryParameterManager(sqlCommand.Parameters)), _model, _schemaInformation, + _queryGeneratorFactory, _reuseQueryPlans.IsEnabled(_sqlRetryService), sqlSearchOptions.IsAsyncOperation, sqlException); diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchTests.cs index be8991bfc6..aed38785bd 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchTests.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchTests.cs @@ -26,7 +26,10 @@ using Microsoft.Health.Fhir.Core.Features.Search.Parameters; using Microsoft.Health.Fhir.Core.Features.Search.Registry; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; +using Microsoft.Health.Fhir.Core.Messages.Create; using Microsoft.Health.Fhir.Core.Messages.Get; +using Microsoft.Health.Fhir.Core.Messages.Patch; +using Microsoft.Health.Fhir.Core.Messages.Upsert; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.Core.UnitTests.Extensions; using Microsoft.Health.Fhir.Tests.Common; @@ -115,19 +118,19 @@ public async Task InitializeAsync() var smartBundle = Samples.GetJsonSample("SmartPatientA"); foreach (var entry in smartBundle.Entry) { - await PutResource(entry.Resource); + await UpsertResource(entry.Resource); } smartBundle = Samples.GetJsonSample("SmartPatientB"); foreach (var entry in smartBundle.Entry) { - await PutResource(entry.Resource); + await UpsertResource(entry.Resource); } smartBundle = Samples.GetJsonSample("SmartPatientC"); foreach (var entry in smartBundle.Entry) { - await PutResource(entry.Resource); + await UpsertResource(entry.Resource); } smartBundle = Samples.GetJsonSample("SmartPatientD"); @@ -139,12 +142,12 @@ public async Task InitializeAsync() smartBundle = Samples.GetJsonSample("SmartCommon"); foreach (var entry in smartBundle.Entry) { - await PutResource(entry.Resource); + await UpsertResource(entry.Resource); } - await PutResource(Samples.GetJsonSample("Medication")); - await PutResource(Samples.GetJsonSample("Organization")); - await PutResource(Samples.GetJsonSample("Location-example-hq")); + await UpsertResource(Samples.GetJsonSample("Medication")); + await UpsertResource(Samples.GetJsonSample("Organization")); + await UpsertResource(Samples.GetJsonSample("Location-example-hq")); } } @@ -905,12 +908,12 @@ public async Task GivenPractitionerAccessControlContext_WhenSearchingOwnPractiti Assert.Contains(results.Results, r => r.Resource.ResourceTypeName == "CareTeam"); } - private async Task PutResource(Resource resource) + private async Task UpsertResource(Resource resource, string httpMethod = "PUT") { ResourceElement resourceElement = resource.ToResourceElement(); var rawResource = new RawResource(resource.ToJson(), FhirResourceFormat.Json, isMetaSet: false); - var resourceRequest = new ResourceRequest(WebRequestMethods.Http.Put); + var resourceRequest = new ResourceRequest(httpMethod); var compartmentIndices = Substitute.For(); var searchIndices = _searchIndexer.Extract(resourceElement); var wrapper = new ResourceWrapper(resourceElement, rawResource, resourceRequest, false, searchIndices, compartmentIndices, new List>(), _searchParameterDefinitionManager.GetSearchParameterHashForResourceType("Patient")); @@ -961,5 +964,168 @@ private void ConfigureFhirRequestContext( contextAccessor.RequestContext.AccessControlContext.Returns(accessControlContext); } + + // SMART v2 Granular Scope Tests + + [SkippableFact] + public async Task GivenSmartV2CreateScope_WhenCreatingPatient_ThenPatientIsCreated() + { + Skip.If( + ModelInfoProvider.Instance.Version != FhirSpecification.R4 && + ModelInfoProvider.Instance.Version != FhirSpecification.R4B, + "This test is only valid for R4 and R4B"); + + var scopeRestriction = new ScopeRestriction("Patient", Core.Features.Security.DataActions.Create, "patient"); + + ConfigureFhirRequestContext(_contextAccessor, new List() { scopeRestriction }); + _contextAccessor.RequestContext.AccessControlContext.CompartmentId = "smart-patient-test"; + _contextAccessor.RequestContext.AccessControlContext.CompartmentResourceType = "Patient"; + + var newPatient = new Patient + { + Id = "smart-v2-create-test", + Name = new List { new HumanName().WithGiven("TestCreate").AndFamily("SmartV2") }, + }; + + var result = await UpsertResource(newPatient); + Assert.NotNull(result); + Assert.Equal("smart-v2-create-test", result.Wrapper.ResourceId); + } + + [SkippableFact] + public async Task GivenSmartV2ReadScope_WhenReadingPatient_ThenPatientIsReturned() + { + Skip.If( + ModelInfoProvider.Instance.Version != FhirSpecification.R4 && + ModelInfoProvider.Instance.Version != FhirSpecification.R4B, + "This test is only valid for R4 and R4B"); + + var scopeRestriction = new ScopeRestriction("Patient", Core.Features.Security.DataActions.Read, "patient"); + + ConfigureFhirRequestContext(_contextAccessor, new List() { scopeRestriction }); + _contextAccessor.RequestContext.AccessControlContext.CompartmentId = "smart-patient-A"; + _contextAccessor.RequestContext.AccessControlContext.CompartmentResourceType = "Patient"; + + var result = await _fixture.GetResourceHandler.Handle( + new GetResourceRequest(new ResourceKey("Patient", "smart-patient-A"), bundleResourceContext: null), + CancellationToken.None); + + Assert.NotNull(result.Resource); + Assert.Equal("smart-patient-A", result.Resource.Id); + } + + [SkippableFact] + public async Task GivenSmartV2SearchScope_WhenSearchingPatients_ThenPatientsAreReturned() + { + Skip.If( + ModelInfoProvider.Instance.Version != FhirSpecification.R4 && + ModelInfoProvider.Instance.Version != FhirSpecification.R4B, + "This test is only valid for R4 and R4B"); + + var query = new List>(); + query.Add(new Tuple("name", "SMARTGivenName1")); + + var scopeRestriction = new ScopeRestriction("Patient", Core.Features.Security.DataActions.Search, "patient"); + + ConfigureFhirRequestContext(_contextAccessor, new List() { scopeRestriction }); + _contextAccessor.RequestContext.AccessControlContext.CompartmentId = "smart-patient-A"; + _contextAccessor.RequestContext.AccessControlContext.CompartmentResourceType = "Patient"; + + var results = await _searchService.Value.SearchAsync("Patient", query, CancellationToken.None); + + Assert.NotEmpty(results.Results); + Assert.All(results.Results, r => Assert.Equal("Patient", r.Resource.ResourceTypeName)); + } + + [SkippableFact] + public async Task GivenSmartV2UpdateScope_WhenUpdatingPatient_ThenPatientIsUpdated() + { + Skip.If( + ModelInfoProvider.Instance.Version != FhirSpecification.R4 && + ModelInfoProvider.Instance.Version != FhirSpecification.R4B, + "This test is only valid for R4 and R4B"); + + var scopeRestriction = new ScopeRestriction("Patient", Core.Features.Security.DataActions.Update, "patient"); + + ConfigureFhirRequestContext(_contextAccessor, new List() { scopeRestriction }); + _contextAccessor.RequestContext.AccessControlContext.CompartmentId = "smart-patient-A"; + _contextAccessor.RequestContext.AccessControlContext.CompartmentResourceType = "Patient"; + + // Create an updated patient resource + var updatedPatient = new Patient + { + Id = "smart-patient-A", + Name = new List { new HumanName().WithGiven("UpdatedName").AndFamily("Updated") }, + }; + + var result = await UpsertResource(updatedPatient); + Assert.NotNull(result); + } + + [SkippableFact] + public async Task GivenSmartV2SearchAndCreateScopes_WhenSearchingWithCreate_ThenBothPermissionsWork() + { + Skip.If( + ModelInfoProvider.Instance.Version != FhirSpecification.R4 && + ModelInfoProvider.Instance.Version != FhirSpecification.R4B, + "This test is only valid for R4 and R4B"); + + var scopeRestriction1 = new ScopeRestriction("Patient", Core.Features.Security.DataActions.Search, "patient"); + var scopeRestriction2 = new ScopeRestriction("Patient", Core.Features.Security.DataActions.Create, "patient"); + + ConfigureFhirRequestContext(_contextAccessor, new List() { scopeRestriction1, scopeRestriction2 }); + _contextAccessor.RequestContext.AccessControlContext.CompartmentId = "smart-patient-test"; + _contextAccessor.RequestContext.AccessControlContext.CompartmentResourceType = "Patient"; + + // Test search capability + var query = new List>(); + query.Add(new Tuple("name", "SMARTGivenName1")); + var searchResults = await _searchService.Value.SearchAsync("Patient", query, CancellationToken.None); + + Assert.False(searchResults.SearchIssues.Any()); + + // Test create capability + var newPatient = new Patient + { + Id = "smart-v2-search-create-test", + Name = new List { new HumanName().WithGiven("SearchCreate").AndFamily("SmartV2") }, + }; + + var createResult = await UpsertResource(newPatient); + Assert.NotNull(createResult); + } + + [SkippableFact] + public async Task GivenSmartV2SearchAndUpdateScopes_WhenSearchingWithUpdate_ThenBothPermissionsWork() + { + Skip.If( + ModelInfoProvider.Instance.Version != FhirSpecification.R4 && + ModelInfoProvider.Instance.Version != FhirSpecification.R4B, + "This test is only valid for R4 and R4B"); + + var scopeRestriction1 = new ScopeRestriction("Patient", Core.Features.Security.DataActions.Search, "patient"); + var scopeRestriction2 = new ScopeRestriction("Patient", Core.Features.Security.DataActions.Update, "patient"); + + ConfigureFhirRequestContext(_contextAccessor, new List() { scopeRestriction1, scopeRestriction2 }); + _contextAccessor.RequestContext.AccessControlContext.CompartmentId = "smart-patient-A"; + _contextAccessor.RequestContext.AccessControlContext.CompartmentResourceType = "Patient"; + + // Test search capability + var query = new List>(); + query.Add(new Tuple("_id", "smart-patient-A")); + var searchResults = await _searchService.Value.SearchAsync("Patient", query, CancellationToken.None); + Assert.NotEmpty(searchResults.Results); + + // Test update capability + var updatedPatient = new Patient + { + Id = "smart-patient-A", + Name = new List { new HumanName().WithGiven("SearchUpdate").AndFamily("SmartV2") }, + }; + + var updateResult = await UpsertResource(updatedPatient); + Assert.NotNull(updateResult); + Assert.Equal("smart-patient-A", updateResult.Wrapper.ResourceId); + } } } diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/FhirStorageTestsFixture.cs b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/FhirStorageTestsFixture.cs index 2fe0aeaaba..ce620443c8 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/FhirStorageTestsFixture.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/FhirStorageTestsFixture.cs @@ -205,7 +205,7 @@ public async Task InitializeAsync() var collection = new ServiceCollection(); collection.AddSingleton(typeof(IRequestHandler), new CreateResourceHandler(DataStore, new Lazy(() => ConformanceProvider), resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(SearchService, new TestQueryStringParser(), Substitute.For>()), DisabledFhirAuthorizationService.Instance)); - collection.AddSingleton(typeof(IRequestHandler), new UpsertResourceHandler(DataStore, new Lazy(() => ConformanceProvider), resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(SearchService, new TestQueryStringParser(), Substitute.For>()), DisabledFhirAuthorizationService.Instance, ModelInfoProvider.Instance)); + collection.AddSingleton(typeof(IRequestHandler), new UpsertResourceHandler(DataStore, new Lazy(() => ConformanceProvider), resourceWrapperFactory, _resourceIdProvider, new ResourceReferenceResolver(SearchService, new TestQueryStringParser(), Substitute.For>()), FhirRequestContextAccessor, DisabledFhirAuthorizationService.Instance, ModelInfoProvider.Instance)); collection.AddSingleton(typeof(IRequestHandler), GetResourceHandler); collection.AddSingleton(typeof(IRequestHandler), new DeleteResourceHandler(DataStore, new Lazy(() => ConformanceProvider), resourceWrapperFactory, _resourceIdProvider, DisabledFhirAuthorizationService.Instance, deleter)); collection.AddSingleton(typeof(IRequestHandler), new SearchResourceHistoryHandler(SearchService, bundleFactory, DisabledFhirAuthorizationService.Instance, new DataResourceFilter(MissingDataFilterCriteria.Default)));