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