Skip to content

Commit a2e891c

Browse files
committed
Add SMART v2 scopes
1 parent 8677d35 commit a2e891c

File tree

13 files changed

+300
-26
lines changed

13 files changed

+300
-26
lines changed

src/Microsoft.Health.Fhir.Api/Features/SMART/SmartClinicalScopesMiddleware.cs

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@ namespace Microsoft.Health.Fhir.Api.Features.Smart
2828
public class SmartClinicalScopesMiddleware
2929
{
3030
private readonly RequestDelegate _next;
31-
private const string AllDataActions = "all";
3231
private readonly ILogger<SmartClinicalScopesMiddleware> _logger;
3332

34-
// 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
35-
private static readonly Regex ClinicalScopeRegEx = new Regex(@"(^|\s+)(?<id>patient|user|system)(/|\$|\.)(?<resource>\*|([a-zA-Z]*)|all)\.(?<accessLevel>read|write|\*|all)", RegexOptions.Compiled);
33+
// Regex based on SMART on FHIR clinical scopes v1.0 and v2.0
34+
// v1: http://hl7.org/fhir/smart-app-launch/1.0.0/scopes-and-launch-context/index.html#clinical-scope-syntax
35+
// v2: http://hl7.org/fhir/smart-app-launch/scopes-and-launch-context/index.html#scopes-for-requesting-fhir-resources
36+
private static readonly Regex ClinicalScopeRegEx = new Regex(@"(^|\s+)(?<id>patient|user|system)(/|\$|\.)(?<resource>\*|([a-zA-Z]*)|all)\.(?<accessLevel>read|write|\*|all|[cruds]+)", RegexOptions.Compiled);
3637

3738
public SmartClinicalScopesMiddleware(RequestDelegate next, ILogger<SmartClinicalScopesMiddleware> logger)
3839
{
@@ -43,6 +44,66 @@ public SmartClinicalScopesMiddleware(RequestDelegate next, ILogger<SmartClinical
4344
_next = next;
4445
}
4546

47+
/// <summary>
48+
/// Parse SMART scope permissions supporting both v1 and v2 formats.
49+
/// v1: read, write, *, all
50+
/// v2: c (create), r (read), u (update), d (delete), s (search)
51+
/// </summary>
52+
/// <param name="accessLevel">The access level from the scope (e.g., "read", "rs", "cruds")</param>
53+
/// <returns>DataActions representing the permissions</returns>
54+
private static DataActions ParseScopePermissions(string accessLevel)
55+
{
56+
if (string.IsNullOrEmpty(accessLevel))
57+
{
58+
return DataActions.None;
59+
}
60+
61+
// Handle v1 scope formats first for backward compatibility
62+
switch (accessLevel.ToLowerInvariant())
63+
{
64+
case "read":
65+
// v1 read includes both read and search permissions
66+
return DataActions.Read | DataActions.Export | DataActions.Search;
67+
case "write":
68+
// v1 write includes create, update, delete, and legacy write permissions
69+
return DataActions.Write | DataActions.Create | DataActions.Update | DataActions.Delete;
70+
case "*":
71+
case "all":
72+
// Full access includes all permissions
73+
return DataActions.Read | DataActions.Write | DataActions.Export | DataActions.Search |
74+
DataActions.Create | DataActions.Update | DataActions.Delete;
75+
}
76+
77+
// Handle v2 scope format (e.g., "rs", "cruds")
78+
var permissions = DataActions.None;
79+
foreach (char permission in accessLevel.ToLowerInvariant())
80+
{
81+
switch (permission)
82+
{
83+
case 'c':
84+
permissions |= DataActions.Create; // SMART v2 granular create permission
85+
break;
86+
case 'r':
87+
permissions |= DataActions.ReadV2 | DataActions.Export; // SMART v2 read-only (no search)
88+
break;
89+
case 'u':
90+
permissions |= DataActions.Update; // SMART v2 granular update permission
91+
break;
92+
case 'd':
93+
permissions |= DataActions.Delete; // SMART v2 granular delete permission
94+
break;
95+
case 's':
96+
permissions |= DataActions.Search; // Search is a separate permission in v2
97+
break;
98+
default:
99+
// Unknown permission character - log warning but continue
100+
break;
101+
}
102+
}
103+
104+
return permissions;
105+
}
106+
46107
public async Task Invoke(
47108
HttpContext context,
48109
RequestContextAccessor<IFhirRequestContext> fhirRequestContextAccessor,
@@ -96,19 +157,7 @@ public async Task Invoke(
96157
var resource = match.Groups["resource"]?.Value;
97158
var accessLevel = match.Groups["accessLevel"]?.Value;
98159

99-
switch (accessLevel)
100-
{
101-
case "read":
102-
permittedDataActions = DataActions.Read | DataActions.Export;
103-
break;
104-
case "write":
105-
permittedDataActions = DataActions.Write;
106-
break;
107-
case "*":
108-
case AllDataActions:
109-
permittedDataActions = DataActions.Read | DataActions.Write | DataActions.Export;
110-
break;
111-
}
160+
permittedDataActions = ParseScopePermissions(accessLevel);
112161

113162
if (!string.IsNullOrEmpty(resource)
114163
&& !string.IsNullOrEmpty(id))

src/Microsoft.Health.Fhir.Core/Features/Conformance/GetSmartConfigurationHandler.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,19 @@ protected GetSmartConfigurationResponse Handle(GetSmartConfigurationRequest requ
5454
"permission-user",
5555
};
5656

57-
return new GetSmartConfigurationResponse(authorizationEndpoint, tokenEndpoint, capabilities);
57+
// Add SMART v2 scope support - these are the core scopes supported natively by the FHIR service
58+
ICollection<string> scopesSupported = new List<string>
59+
{
60+
// Standard OAuth/OIDC scopes
61+
"openid",
62+
"fhirUser",
63+
"launch",
64+
"launch/patient",
65+
"offline_access",
66+
"online_access",
67+
};
68+
69+
return new GetSmartConfigurationResponse(authorizationEndpoint, tokenEndpoint, capabilities, scopesSupported);
5870
}
5971
catch (Exception e) when (e is ArgumentNullException || e is UriFormatException)
6072
{

src/Microsoft.Health.Fhir.Core/Features/Operations/SmartConfigurationResult.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ public SmartConfigurationResult(Uri authorizationEndpoint, Uri tokenEndpoint, IC
2626
Capabilities = capabilities;
2727
}
2828

29+
public SmartConfigurationResult(Uri authorizationEndpoint, Uri tokenEndpoint, ICollection<string> capabilities, ICollection<string> scopesSupported)
30+
{
31+
EnsureArg.IsNotNull(authorizationEndpoint, nameof(authorizationEndpoint));
32+
EnsureArg.IsNotNull(tokenEndpoint, nameof(tokenEndpoint));
33+
EnsureArg.IsNotNull(capabilities, nameof(capabilities));
34+
35+
AuthorizationEndpoint = authorizationEndpoint;
36+
TokenEndpoint = tokenEndpoint;
37+
Capabilities = capabilities;
38+
ScopesSupported = scopesSupported;
39+
}
40+
2941
[JsonConstructor]
3042
public SmartConfigurationResult()
3143
{
@@ -39,5 +51,8 @@ public SmartConfigurationResult()
3951

4052
[JsonProperty("capabilities")]
4153
public ICollection<string> Capabilities { get; private set; }
54+
55+
[JsonProperty("scopes_supported")]
56+
public ICollection<string> ScopesSupported { get; private set; }
4257
}
4358
}

src/Microsoft.Health.Fhir.Core/Features/Security/DataActions.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ public enum DataActions : ulong
1515
{
1616
None = 0,
1717

18-
Read = 1,
19-
Write = 1 << 1,
18+
// Legacy permissions (maintained for backward compatibility)
19+
Read = 1 << 0, // Legacy read permission (includes search capability for SMART v1 compatibility)
20+
Write = 1 << 1, // Legacy write permission (kept for backward compatibility)
2021
Delete = 1 << 2,
2122
HardDelete = 1 << 3,
2223
Export = 1 << 4,
@@ -28,9 +29,15 @@ public enum DataActions : ulong
2829
SearchParameter = 1 << 10,
2930
BulkOperator = 1 << 11,
3031

32+
// SMART v2 granular permissions
33+
Search = 1 << 12, // SMART v2 search permission
34+
ReadV2 = 1 << 13, // SMART v2 read permission (read-only, no search)
35+
Create = 1 << 14, // SMART v2 create permission
36+
Update = 1 << 15, // SMART v2 update permission
37+
3138
Smart = 1 << 30, // Do not include Smart in the '*' case. We only want smart for a user if explicitly added to the role or user
3239

3340
[EnumMember(Value = "*")]
34-
All = (BulkOperator << 1) - 1,
41+
All = (Update << 1) - 1,
3542
}
3643
}

src/Microsoft.Health.Fhir.Core/Messages/Get/GetSmartConfigurationResponse.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,24 @@ public GetSmartConfigurationResponse(Uri authorizationEndpoint, Uri tokenEndpoin
2222
Capabilities = capabilities;
2323
}
2424

25+
public GetSmartConfigurationResponse(Uri authorizationEndpoint, Uri tokenEndpoint, ICollection<string> capabilities, ICollection<string> scopesSupported)
26+
{
27+
EnsureArg.IsNotNull(authorizationEndpoint, nameof(authorizationEndpoint));
28+
EnsureArg.IsNotNull(tokenEndpoint, nameof(tokenEndpoint));
29+
EnsureArg.IsNotNull(capabilities, nameof(capabilities));
30+
31+
AuthorizationEndpoint = authorizationEndpoint;
32+
TokenEndpoint = tokenEndpoint;
33+
Capabilities = capabilities;
34+
ScopesSupported = scopesSupported;
35+
}
36+
2537
public Uri AuthorizationEndpoint { get; }
2638

2739
public Uri TokenEndpoint { get; }
2840

2941
public ICollection<string> Capabilities { get; }
42+
43+
public ICollection<string> ScopesSupported { get; }
3044
}
3145
}

src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/SMART/SmartClinicalScopesMiddlewareTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,51 @@ public static IEnumerable<object[]> GetTestScopes()
419419
new ScopeRestriction("Encounter", DataActions.Read | DataActions.Write | DataActions.Export, "user"),
420420
},
421421
};
422+
423+
// SMART v2 scope format tests
424+
yield return new object[] { "patient/Patient.rs", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.ReadV2 | DataActions.Export | DataActions.Search, "patient") } };
425+
yield return new object[] { "patient/Patient.r", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.ReadV2 | DataActions.Export, "patient") } };
426+
yield return new object[] { "patient/Patient.s", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.Search, "patient") } };
427+
yield return new object[] { "patient/Patient.c", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.Create, "patient") } };
428+
yield return new object[] { "patient/all.c", new List<ScopeRestriction>() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Create, "patient") } };
429+
yield return new object[] { "patient.all.c", new List<ScopeRestriction>() { new ScopeRestriction(KnownResourceTypes.All, DataActions.Create, "patient") } };
430+
yield return new object[] { "patient/Patient.u", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.Update, "patient") } };
431+
yield return new object[] { "patient/Patient.d", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.Delete, "patient") } };
432+
yield return new object[] { "patient/Patient.cruds", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.Create | DataActions.Update | DataActions.Delete | DataActions.ReadV2 | DataActions.Export | DataActions.Search, "patient") } };
433+
yield return new object[] { "user/*.rs", new List<ScopeRestriction>() { new ScopeRestriction(KnownResourceTypes.All, DataActions.ReadV2 | DataActions.Export | DataActions.Search, "user") } };
434+
yield return new object[]
435+
{
436+
"patient/Patient.rs user/Observation.cud",
437+
new List<ScopeRestriction>()
438+
{
439+
new ScopeRestriction("Patient", DataActions.ReadV2 | DataActions.Export | DataActions.Search, "patient"),
440+
new ScopeRestriction("Observation", DataActions.Create | DataActions.Update | DataActions.Delete, "user"),
441+
},
442+
};
443+
444+
// Test v1 vs v2 behavior: v1 .read includes search, v2 .r does not include search
445+
yield return new object[] { "patient/Patient.read", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient") } };
446+
yield return new object[]
447+
{
448+
"patient/Patient.read patient/Observation.r",
449+
new List<ScopeRestriction>()
450+
{
451+
new ScopeRestriction("Patient", DataActions.Read | DataActions.Export | DataActions.Search, "patient"),
452+
new ScopeRestriction("Observation", DataActions.ReadV2 | DataActions.Export, "patient"),
453+
},
454+
};
455+
456+
// Test v1 vs v2 write behavior: v1 .write includes all write operations, v2 granular permissions
457+
yield return new object[] { "patient/Patient.write", new List<ScopeRestriction>() { new ScopeRestriction("Patient", DataActions.Write | DataActions.Create | DataActions.Update | DataActions.Delete, "patient") } };
458+
yield return new object[]
459+
{
460+
"patient/Patient.write user/Observation.cu",
461+
new List<ScopeRestriction>()
462+
{
463+
new ScopeRestriction("Patient", DataActions.Write | DataActions.Create | DataActions.Update | DataActions.Delete, "patient"),
464+
new ScopeRestriction("Observation", DataActions.Create | DataActions.Update, "user"),
465+
},
466+
};
422467
}
423468

424469
private static async Task<AuthorizationConfiguration> LoadRoles(AuthorizationConfiguration authConfig)

src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Conformance/GetSmartConfigurationHandlerTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ public async Task GivenASmartConfigurationHandler_WhenSecurityConfigurationEnabl
6464
"permission-patient",
6565
"permission-user",
6666
});
67+
68+
// Verify SMART v2 scopes are included
69+
Assert.NotNull(response.ScopesSupported);
6770
}
6871

6972
[Fact]

src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchResourceHandlerTests.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
using System;
77
using System.Linq;
88
using System.Threading;
9+
using System.Threading.Tasks;
910
using Hl7.Fhir.Model;
11+
using Microsoft.Health.Core.Features.Security.Authorization;
12+
using Microsoft.Health.Fhir.Core.Exceptions;
1013
using Microsoft.Health.Fhir.Core.Extensions;
1114
using Microsoft.Health.Fhir.Core.Features.Search;
1215
using Microsoft.Health.Fhir.Core.Features.Search.Filters;
16+
using Microsoft.Health.Fhir.Core.Features.Security;
1317
using Microsoft.Health.Fhir.Core.Features.Security.Authorization;
1418
using Microsoft.Health.Fhir.Core.Messages.Search;
19+
using Microsoft.Health.Fhir.Core.Models;
1520
using Microsoft.Health.Fhir.Tests.Common;
1621
using Microsoft.Health.Test.Utilities;
1722
using NSubstitute;
@@ -56,5 +61,101 @@ public async Task GivenASearchResourceRequest_WhenHandled_ThenABundleShouldBeRet
5661
Assert.NotNull(actualResponse);
5762
Assert.Equal(expectedBundle, actualResponse.Bundle);
5863
}
64+
65+
[Fact]
66+
public async Task GivenASearchResourceRequest_WhenUserHasSearchPermission_ThenSearchSucceeds()
67+
{
68+
var authorizationService = Substitute.For<IAuthorizationService<DataActions>>();
69+
var searchResourceHandler = new SearchResourceHandler(
70+
_searchService,
71+
_bundleFactory,
72+
authorizationService,
73+
new DataResourceFilter(MissingDataFilterCriteria.Default));
74+
75+
var request = new SearchResourceRequest("Patient", null);
76+
var searchResult = new SearchResult(Enumerable.Empty<SearchResultEntry>(), null, null, new Tuple<string, string>[0]);
77+
var expectedBundle = new Bundle().ToResourceElement();
78+
79+
// Setup authorization to return Search permission
80+
authorizationService.CheckAccess(DataActions.Search, CancellationToken.None)
81+
.Returns(DataActions.Search);
82+
83+
_searchService.SearchAsync(request.ResourceType, request.Queries, CancellationToken.None).Returns(searchResult);
84+
_bundleFactory.CreateSearchBundle(searchResult).Returns(expectedBundle);
85+
86+
SearchResourceResponse actualResponse = await searchResourceHandler.Handle(request, CancellationToken.None);
87+
88+
Assert.NotNull(actualResponse);
89+
Assert.Equal(expectedBundle, actualResponse.Bundle);
90+
}
91+
92+
[Fact]
93+
public async Task GivenASearchResourceRequest_WhenUserHasOnlyReadPermission_ThenUnauthorizedExceptionThrown()
94+
{
95+
var authorizationService = Substitute.For<IAuthorizationService<DataActions>>();
96+
var searchResourceHandler = new SearchResourceHandler(
97+
_searchService,
98+
_bundleFactory,
99+
authorizationService,
100+
new DataResourceFilter(MissingDataFilterCriteria.Default));
101+
102+
var request = new SearchResourceRequest("Patient", null);
103+
104+
// Setup authorization to return only Read permission (no Search permission)
105+
// This simulates SMART v2 scope like "patient/Patient.r" which only allows direct access
106+
authorizationService.CheckAccess(DataActions.Search, CancellationToken.None)
107+
.Returns(DataActions.None);
108+
109+
await Assert.ThrowsAsync<UnauthorizedFhirActionException>(() =>
110+
searchResourceHandler.Handle(request, CancellationToken.None));
111+
}
112+
113+
[Fact]
114+
public async Task GivenASearchResourceRequest_WhenUserHasReadAndSearchPermissions_ThenSearchSucceeds()
115+
{
116+
var authorizationService = Substitute.For<IAuthorizationService<DataActions>>();
117+
var searchResourceHandler = new SearchResourceHandler(
118+
_searchService,
119+
_bundleFactory,
120+
authorizationService,
121+
new DataResourceFilter(MissingDataFilterCriteria.Default));
122+
123+
var request = new SearchResourceRequest("Patient", null);
124+
var searchResult = new SearchResult(Enumerable.Empty<SearchResultEntry>(), null, null, new Tuple<string, string>[0]);
125+
var expectedBundle = new Bundle().ToResourceElement();
126+
127+
// Setup authorization to return Search permission (which is what we check for)
128+
// This simulates SMART v1 ".read" or v2 ".rs" scopes
129+
authorizationService.CheckAccess(DataActions.Search, CancellationToken.None)
130+
.Returns(DataActions.Search);
131+
132+
_searchService.SearchAsync(request.ResourceType, request.Queries, CancellationToken.None).Returns(searchResult);
133+
_bundleFactory.CreateSearchBundle(searchResult).Returns(expectedBundle);
134+
135+
SearchResourceResponse actualResponse = await searchResourceHandler.Handle(request, CancellationToken.None);
136+
137+
Assert.NotNull(actualResponse);
138+
Assert.Equal(expectedBundle, actualResponse.Bundle);
139+
}
140+
141+
[Fact]
142+
public async Task GivenASearchResourceRequest_WhenUserHasNoPermissions_ThenUnauthorizedExceptionThrown()
143+
{
144+
var authorizationService = Substitute.For<IAuthorizationService<DataActions>>();
145+
var searchResourceHandler = new SearchResourceHandler(
146+
_searchService,
147+
_bundleFactory,
148+
authorizationService,
149+
new DataResourceFilter(MissingDataFilterCriteria.Default));
150+
151+
var request = new SearchResourceRequest("Patient", null);
152+
153+
// Setup authorization to return no permissions
154+
authorizationService.CheckAccess(DataActions.Search, CancellationToken.None)
155+
.Returns(DataActions.None);
156+
157+
await Assert.ThrowsAsync<UnauthorizedFhirActionException>(() =>
158+
searchResourceHandler.Handle(request, CancellationToken.None));
159+
}
59160
}
60161
}

0 commit comments

Comments
 (0)