Skip to content

Commit 4b2ba57

Browse files
committed
Enforce system-only SMART operation access
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 13c9cccc-8ec4-4b60-8da0-f36ee9ab8be5
1 parent 05a3b2a commit 4b2ba57

15 files changed

Lines changed: 878 additions & 8 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# ADR-2607: SMART System Export Authorization
2+
3+
**Status**: Proposed
4+
**Date**: 2026-07-14
5+
**Feature**: SMART export authorization
6+
7+
## Context
8+
9+
FHIR Bulk Data export is asynchronous: the initiating request creates a job, while separate operation URLs expose status, result metadata, and cancellation. Existing route-level authorization checks the export action but does not authorize those operation URLs against the resource set represented by the persisted job. Predictable job identifiers can therefore expose or mutate an export job unless authorization is repeated after loading its metadata.
10+
11+
The [FHIR Bulk Data Access IG](https://hl7.org/fhir/uv/bulkdata/export.html) defines export as a backend-services operation using SMART system scopes. Patient and user SMART scopes describe interactive clinical access and cannot safely authorize an asynchronous bulk extraction. Existing RBAC export permission and non-SMART behavior must remain unchanged.
12+
13+
## Options Considered
14+
15+
1. **Rely on route-level export authorization** - Authorize only the export action on each route. *(rejected: status and cancellation would not be checked against persisted job resource types)*
16+
2. **Persist patient or user creator context** - Bind jobs to a SMART compartment and revalidate that identity on later requests. *(rejected: adds authorization identity to job metadata and treats Bulk Data export as interactive clinical access)*
17+
3. **Require SMART system scopes and authorize persisted resource types** - Limit SMART export to backend system scopes and revalidate each job's resource set. *(chosen)*
18+
19+
## Decision
20+
21+
Requests subject to SMART fine-grained access control may create, read, or cancel export jobs only through system scopes. Patient and user contexts are rejected. RBAC `Export` permission remains an independent prerequisite. A system wildcard export-read scope may create an export without `_type`; otherwise creation requires a nonempty explicit `_type`, and every requested type must be covered by an unconstrained system scope.
22+
23+
SMART v1 coverage requires read plus export. SMART v2 coverage requires read-by-id, search, and export, equivalent to `rs` plus export. Search-parameter-constrained scopes do not authorize export. Status and cancellation derive requirements from persisted `_type` and defensively include completed output types. A job without explicit `_type` always requires wildcard access, even when current output contains only a subset. Unauthorized job access is returned as not found to avoid an existence oracle.
24+
25+
## Consequences
26+
27+
- SMART patient and user applications cannot use Bulk Data export.
28+
- Partial system access can export an explicit authorized type set without receiving wildcard access.
29+
- Legacy and new jobs without explicit `_type` remain visible only to wildcard system callers.
30+
- Export job records do not persist SMART authorization identity or compartment metadata.
31+
- Unauthorized job access is indistinguishable from an unknown job identifier.
32+
- Completed output types can tighten later authorization but cannot weaken the no-`_type` wildcard rule.
33+
- RBAC and non-SMART export behavior are unchanged.
34+
- Other asynchronous operation types are outside this decision and retain their existing behavior.

src/Microsoft.Health.Fhir.Core.UnitTests/Features/Operations/Export/CancelExportRequestHandlerTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using Microsoft.Health.Fhir.Core.Features.Operations;
1818
using Microsoft.Health.Fhir.Core.Features.Operations.Export;
1919
using Microsoft.Health.Fhir.Core.Features.Operations.Export.Models;
20+
using Microsoft.Health.Fhir.Core.Features.Operations.Security;
2021
using Microsoft.Health.Fhir.Core.Features.Persistence;
2122
using Microsoft.Health.Fhir.Core.Features.Security;
2223
using Microsoft.Health.Fhir.Core.Features.Security.Authorization;
@@ -35,6 +36,7 @@ public class CancelExportRequestHandlerTests
3536
private const string JobId = "jobId";
3637

3738
private readonly IFhirOperationDataStore _fhirOperationDataStore = Substitute.For<IFhirOperationDataStore>();
39+
private readonly IExportSmartScopeValidator _exportSmartScopeValidator = Substitute.For<IExportSmartScopeValidator>();
3840
private readonly IMediator _mediator;
3941

4042
private readonly CancellationToken _cancellationToken = new CancellationTokenSource().Token;
@@ -49,6 +51,7 @@ public CancelExportRequestHandlerTests()
4951
.Add(sp => new CancelExportRequestHandler(
5052
_fhirOperationDataStore,
5153
DisabledFhirAuthorizationService.Instance,
54+
_exportSmartScopeValidator,
5255
_retryCount,
5356
_sleepDurationProvider,
5457
NullLogger<CancelExportRequestHandler>.Instance))
@@ -74,6 +77,7 @@ public async Task GivenAFhirMediator_WhenUserIsNotAuthorized_ThenUnauthorizedFhi
7477
var handler = new CancelExportRequestHandler(
7578
_fhirOperationDataStore,
7679
authorizationService,
80+
_exportSmartScopeValidator,
7781
_retryCount,
7882
_sleepDurationProvider,
7983
NullLogger<CancelExportRequestHandler>.Instance);
@@ -304,6 +308,33 @@ public async Task GivenAFhirMediator_WhenRetryEncountersJobNotFoundOnRefetch_The
304308
await _fhirOperationDataStore.Received(1).UpdateExportJobAsync(Arg.Any<ExportJobRecord>(), weakETag, Arg.Any<bool>(), Arg.Any<CancellationToken>());
305309
}
306310

311+
/// <summary>
312+
/// Verifies that cancelling an export invokes the SMART export job validator
313+
/// and that a denial from the validator propagates without mutating the job.
314+
/// </summary>
315+
[Fact]
316+
public async Task GivenAFhirMediator_WhenCancelingExportJob_ThenSmartExportJobValidatorIsInvoked()
317+
{
318+
SetupExportJob(OperationStatus.Running);
319+
320+
await _mediator.CancelExportAsync(JobId, _cancellationToken);
321+
322+
_exportSmartScopeValidator.Received(1).ValidateJobAccess(Arg.Any<ExportJobRecord>());
323+
}
324+
325+
[Fact]
326+
public async Task GivenAFhirMediator_WhenSmartScopeValidatorDeniesCancelAccess_ThenJobNotFoundExceptionShouldBeThrownAndJobNotUpdated()
327+
{
328+
SetupExportJob(OperationStatus.Running);
329+
_exportSmartScopeValidator
330+
.When(x => x.ValidateJobAccess(Arg.Any<ExportJobRecord>()))
331+
.Do(_ => throw new UnauthorizedFhirActionException());
332+
333+
await Assert.ThrowsAsync<JobNotFoundException>(() => _mediator.CancelExportAsync(JobId, _cancellationToken));
334+
335+
await _fhirOperationDataStore.DidNotReceive().UpdateExportJobAsync(Arg.Any<ExportJobRecord>(), Arg.Any<WeakETag>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
336+
}
337+
307338
private ExportJobOutcome SetupExportJob(OperationStatus operationStatus, WeakETag weakETag = null)
308339
{
309340
var outcome = CreateExportJobOutcome(

src/Microsoft.Health.Fhir.Core.UnitTests/Features/Operations/Export/GetExportRequestHandlerTests.cs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using Microsoft.Health.Fhir.Core.Features.Operations;
1818
using Microsoft.Health.Fhir.Core.Features.Operations.Export;
1919
using Microsoft.Health.Fhir.Core.Features.Operations.Export.Models;
20+
using Microsoft.Health.Fhir.Core.Features.Operations.Security;
2021
using Microsoft.Health.Fhir.Core.Features.Persistence;
2122
using Microsoft.Health.Fhir.Core.Features.Security;
2223
using Microsoft.Health.Fhir.Core.Features.Security.Authorization;
@@ -36,6 +37,7 @@ public class GetExportRequestHandlerTests
3637
private const string JobId = "jobId";
3738

3839
private readonly IFhirOperationDataStore _fhirOperationDataStore = Substitute.For<IFhirOperationDataStore>();
40+
private readonly IExportSmartScopeValidator _exportSmartScopeValidator = Substitute.For<IExportSmartScopeValidator>();
3941
private readonly IMediator _mediator;
4042

4143
private readonly CancellationToken _cancellationToken = new CancellationTokenSource().Token;
@@ -48,7 +50,8 @@ public GetExportRequestHandlerTests()
4850
collection
4951
.Add(sp => new GetExportRequestHandler(
5052
_fhirOperationDataStore,
51-
DisabledFhirAuthorizationService.Instance))
53+
DisabledFhirAuthorizationService.Instance,
54+
_exportSmartScopeValidator))
5255
.Singleton()
5356
.AsSelf()
5457
.AsImplementedInterfaces();
@@ -70,7 +73,8 @@ public async Task GivenAFhirMediator_WhenUserIsNotAuthorized_ThenUnauthorizedFhi
7073

7174
var handler = new GetExportRequestHandler(
7275
_fhirOperationDataStore,
73-
authorizationService);
76+
authorizationService,
77+
_exportSmartScopeValidator);
7478

7579
await Assert.ThrowsAsync<UnauthorizedFhirActionException>(() =>
7680
handler.Handle(new GetExportRequest(new Uri("http://localhost"), JobId), _cancellationToken));
@@ -349,6 +353,38 @@ public async Task GivenAFhirMediator_WhenGettingCanceledJobWithPartialResults_Th
349353
Assert.Single(response.JobResult.Error);
350354
}
351355

356+
/// <summary>
357+
/// Verifies that the SMART export scope validator is invoked with the fetched job record
358+
/// and that a denial from the validator propagates to the caller.
359+
/// </summary>
360+
[Fact]
361+
public async Task GivenAFhirMediator_WhenGettingCompletedExportJob_ThenSmartScopeValidatorIsInvoked()
362+
{
363+
var jobRecord = CreateExportJobRecord(OperationStatus.Completed);
364+
var outcome = CreateExportJobOutcome(jobRecord);
365+
366+
_fhirOperationDataStore.GetExportJobByIdAsync(JobId, _cancellationToken).Returns(outcome);
367+
368+
await _mediator.Send(new GetExportRequest(new Uri("http://localhost"), JobId), _cancellationToken);
369+
370+
_exportSmartScopeValidator.Received(1).ValidateJobAccess(jobRecord);
371+
}
372+
373+
[Fact]
374+
public async Task GivenAFhirMediator_WhenSmartScopeValidatorDeniesExportAccess_ThenJobNotFoundExceptionShouldBeThrown()
375+
{
376+
var jobRecord = CreateExportJobRecord(OperationStatus.Completed);
377+
var outcome = CreateExportJobOutcome(jobRecord);
378+
379+
_fhirOperationDataStore.GetExportJobByIdAsync(JobId, _cancellationToken).Returns(outcome);
380+
_exportSmartScopeValidator
381+
.When(x => x.ValidateJobAccess(Arg.Any<ExportJobRecord>()))
382+
.Do(_ => throw new UnauthorizedFhirActionException());
383+
384+
await Assert.ThrowsAsync<JobNotFoundException>(() =>
385+
_mediator.Send(new GetExportRequest(new Uri("http://localhost"), JobId), _cancellationToken));
386+
}
387+
352388
private ExportJobRecord CreateExportJobRecord(OperationStatus operationStatus)
353389
{
354390
return new ExportJobRecord(

0 commit comments

Comments
 (0)