Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

feat: Add support for system scope #41

Merged
merged 8 commits into from
Jun 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ This resource server supports [SMART's clinical scopes](http://www.hl7.org/fhir/
- For `user` scopes, there must be a `fhirUser` claim in the access token.
- The access modifiers `read` and `write` will give permissions as defined in the incoming [SMARTConfig](./src/smartConfig.ts).

The resource server also supports [SMART's Flat FHIR or Bulk Data `system` scope](https://hl7.org/fhir/uv/bulkdata/authorization/index.html#scopes). `system` scopes have the format `system/(:resourceType|*).(read|write|*)`– which conveys the same access scope as the matching user format `user/(:resourceType|*).(read|write|*)`.

### Attribute Based Access Control (ABAC)

This implementation of the SMART on FHIR specification uses attribute based access control. Access to a resource is given if one of the following statements is true:
Expand All @@ -43,12 +45,13 @@ This implementation of the SMART on FHIR specification uses attribute based acce
As an example below, the Patient resource is accessible by:

- Admins of the system
- Requests with the usage of the `system` scope
- `Patient/example`: via `resourceType` and `id` check
- `Patient/diffPatient`: since it is referenced in the `link` field
- `Practitioner/DrBell`: since it is referenced in the `generalPractitioner` field

```json
// Example Patient pesource with references
// Example Patient resource with references
{
"resourceType": "Patient",
"id": "example",
Expand Down
10 changes: 6 additions & 4 deletions src/regExpressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { CLINICAL_SCOPE_REGEX } from './smartScopeHelper';
import { FHIR_SCOPE_REGEX } from './smartScopeHelper';
import { FHIR_USER_REGEX, FHIR_RESOURCE_REGEX } from './smartAuthorizationHelper';

describe('CLINICAL_SCOPE_REGEX', () => {
Expand All @@ -15,10 +15,12 @@ describe('CLINICAL_SCOPE_REGEX', () => {
['patient', 'Observation', 'read'],
['patient', 'FakeResource', 'write'],
['patient', '*', 'write'],
['system', '*', 'write'],
['system', 'Patient', 'read'],
];
test.each(testCases)('CASE: %p/%p.%p; expect: matches', async (scopeType, scopeResourceType, accessType) => {
const expectedStr = `${scopeType}/${scopeResourceType}.${accessType}`;
const actualMatch = expectedStr.match(CLINICAL_SCOPE_REGEX);
const actualMatch = expectedStr.match(FHIR_SCOPE_REGEX);
expect(actualMatch).toBeTruthy();
expect(actualMatch!.groups).toBeTruthy();
expect(actualMatch!.groups!.scopeType).toEqual(scopeType);
Expand All @@ -38,10 +40,10 @@ describe('CLINICAL_SCOPE_REGEX', () => {
['/Patient.read'],
['patient/.read'],
['patient/Patient.'],
['system/Patient.read'],
['system'],
];
test.each(uniqueTestCases)('CASE: %p; expect: no match', async scope => {
const actualMatch = scope.match(CLINICAL_SCOPE_REGEX);
const actualMatch = scope.match(FHIR_SCOPE_REGEX);
expect(actualMatch).toBeFalsy();
});
});
Expand Down
7 changes: 6 additions & 1 deletion src/smartConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
import { KeyValueMap } from 'fhir-works-on-aws-interface';

export type ScopeType = 'patient' | 'user';
export type ScopeType = 'patient' | 'user' | 'system';
export type AccessModifier = 'read' | 'write' | '*';
export type IdentityType = 'Patient' | 'Practitioner' | 'Person ' | 'RelatedPerson';

Expand Down Expand Up @@ -42,11 +42,16 @@ export type AccessRule = {
* read: ['read','search-type', 'vread'],
* write: ['transaction','update', 'patch', 'create'],
* },
* system: {
* read: ['read','search-type', 'vread'],
* write: [],
* },
* };
*/
export interface ScopeRule {
patient: AccessRule;
user: AccessRule;
system: AccessRule;
}

export type FhirResource = { hostname: string; resourceType: string; id: string };
Expand Down
147 changes: 145 additions & 2 deletions src/smartHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const scopeRule = (): ScopeRule => ({
read: ['read', 'vread', 'search-type', 'search-system', 'history-instance', 'history-type', 'history-system'],
write: ['create', 'update', 'delete', 'patch', 'transaction', 'batch'],
},
system: {
read: ['read', 'vread', 'search-type', 'search-system', 'history-instance', 'history-type', 'history-system'],
write: ['create', 'update', 'delete', 'patch', 'transaction', 'batch'],
},
});

const expectedAud = 'api://default';
Expand Down Expand Up @@ -379,6 +383,48 @@ describe('verifyAccessToken', () => {
{ ...baseAccessNoScopes, scp: 'user/*.* patient/*.write' },
false,
],
[
'system&user&patient_manyWrite_no context or fhirUser',
{ accessToken: 'fake', operation: 'create', resourceType: 'Patient' },
{ ...baseAccessNoScopes, scp: 'user/*.* patient/*.write system/Patient.write' },
true,
],
[
'system_manyRead_Write',
{ accessToken: 'fake', operation: 'update', resourceType: 'Patient', id: patientId },
{ ...baseAccessNoScopes, scp: ['system/*.read'] },
false,
],
[
'system_manyRead_Read',
{ accessToken: 'fake', operation: 'vread', resourceType: 'Observation', id: '1', vid: '1' },
{ ...baseAccessNoScopes, scp: ['system/*.read'] },
true,
],
[
'system_ObservationRead_Read',
{ accessToken: 'fake', operation: 'vread', resourceType: 'Observation', id: '1', vid: '1' },
{ ...baseAccessNoScopes, scp: 'system/Observation.read' },
true,
],
[
'system_manyRead_search',
{ accessToken: 'fake', operation: 'search-type', resourceType: 'Observation' },
{ ...baseAccessNoScopes, scp: 'system/*.read' },
true,
],
[
'system_manyWrite_Read',
{ accessToken: 'fake', operation: 'read', resourceType: 'Patient', id: patientId },
{ ...baseAccessNoScopes, scp: 'system/*.write' },
false,
],
[
'system_PatientWrite_create',
{ accessToken: 'fake', operation: 'create', resourceType: 'Patient' },
{ ...baseAccessNoScopes, scp: 'system/Patient.write' },
true,
],
];

const authZConfig = baseAuthZConfig();
Expand Down Expand Up @@ -459,7 +505,7 @@ describe('verifyAccessToken; System level export requests', () => {
false,
],
[
'No User read access: initiate-export',
'No export read access: cancel-export',
{
accessToken: 'fake',
operation: 'read',
Expand All @@ -470,7 +516,7 @@ describe('verifyAccessToken; System level export requests', () => {
false,
],
[
'No User access; Patient only: initiate-export',
'No export read access; Patient only: cancel-export',
{
accessToken: 'fake',
operation: 'read',
Expand All @@ -480,6 +526,83 @@ describe('verifyAccessToken; System level export requests', () => {
{ ...baseAccessNoScopes, scp: ['patient/*.*'], ...patientContext },
false,
],
[
'System all scope: initiate-export',
{
accessToken: 'fake',
operation: 'read',
resourceType: '',
bulkDataAuth: { exportType: 'system', operation: 'initiate-export' },
},
{ ...baseAccessNoScopes, scp: ['system/*.*'] },
true,
],
[
'System read scope: initiate-export',
{
accessToken: 'fake',
operation: 'read',
resourceType: '',
bulkDataAuth: { exportType: 'system', operation: 'initiate-export' },
},
{ ...baseAccessNoScopes, scp: ['system/*.read'] },
true,
],
[
'System read scope: cancel-export',
{
accessToken: 'fake',
operation: 'read',
resourceType: '',
bulkDataAuth: { exportType: 'system', operation: 'cancel-export' },
},
{ ...baseAccessNoScopes, scp: ['system/*.read'] },
true,
],
[
'System read scope: get-status-export',
{
accessToken: 'fake',
operation: 'read',
resourceType: '',
bulkDataAuth: { exportType: 'system', operation: 'get-status-export' },
},
{ ...baseAccessNoScopes, scp: ['system/*.read'] },
true,
],
[
'System write scope: get-status-export',
{
accessToken: 'fake',
operation: 'read',
resourceType: '',
bulkDataAuth: { exportType: 'system', operation: 'get-status-export' },
},
{ ...baseAccessNoScopes, scp: ['system/*.write'] },
false,
],
[
'System & external practitioner read scope: get-status-export',
{
accessToken: 'fake',
operation: 'read',
resourceType: '',
bulkDataAuth: { exportType: 'system', operation: 'get-status-export' },
},
{ ...baseAccessNoScopes, scp: ['user/*.read', 'system/*.read'], fhirUser: externalPractitionerIdentity },
true,
],
[
'System & internal patient read scope: get-status-export',
{
accessToken: 'fake',
operation: 'read',
resourceType: '',
bulkDataAuth: { exportType: 'system', operation: 'get-status-export' },
},
{ ...baseAccessNoScopes, scp: ['user/*.read', 'system/*.read'], ...patientContext },
true,
],
];

const authZConfig = baseAuthZConfig();
Expand All @@ -502,6 +625,26 @@ describe('verifyAccessToken; System level export requests', () => {
expectedUserIdentity,
);
});

test.each([['user'], ['system']])('CASE: %p scope; bulk data request; no sub set in JWT', baseScope => {
const decodedAccessToken = { ...baseAccessNoScopes, scp: [`${baseScope}/*.read`], sub: '' };
jest.spyOn(smartAuthorizationHelper, 'verifyJwtToken').mockImplementation(() =>
Promise.resolve(decodedAccessToken),
);
const { decode } = jwt as jest.Mocked<typeof import('jsonwebtoken')>;
decode.mockReturnValue(<{ [key: string]: any }>decodedAccessToken);

const expectedUserIdentity = getExpectedUserIdentity(decodedAccessToken);
expectedUserIdentity.scp = decodedAccessToken.scp;
return expect(
authZHandler.verifyAccessToken({
accessToken: 'fake',
operation: 'read',
resourceType: '',
bulkDataAuth: { exportType: 'system', operation: 'initiate-export' },
}),
).rejects.toThrowError(UnauthorizedError);
});
});

function createEntry(resource: any, searchMode = 'match') {
Expand Down
20 changes: 14 additions & 6 deletions src/smartHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,25 +109,33 @@ export class SMARTHandler implements Authorization {
fhirUserClaim,
);
if (!usableScopes.length) {
logger.error('User supplied scopes are insufficient', {
logger.warn('User supplied scopes are insufficient', {
usableScopes,
operation: request.operation,
resourceType: request.resourceType,
});
throw new UnauthorizedError('access_token does not have permission for requested operation');
}
const userIdentity: UserIdentity = clone(decodedToken);

if (request.bulkDataAuth) {
if (!fhirUserClaim) {
if (!userIdentity.sub) {
logger.error('A JWT token is without a `sub` claim; we cannot process the bulk action without one.');
throw new UnauthorizedError('User does not have permission for requested operation');
}
const fhirUser = getFhirUser(fhirUserClaim);
if (fhirUser.hostname !== this.apiUrl || !this.bulkDataAccessTypes.includes(fhirUser.resourceType)) {
throw new UnauthorizedError('User does not have permission for requested operation');
if (
!usableScopes.some((scope: string) => {
return scope.startsWith('system');
})
) {
// if requestor is relying on the "user" scope we need to verify they are coming from the correct endpoint & resourceType
const fhirUser = getFhirUser(fhirUserClaim);
if (fhirUser.hostname !== this.apiUrl || !this.bulkDataAccessTypes.includes(fhirUser.resourceType)) {
throw new UnauthorizedError('User does not have permission for requested operation');
}
}
}

const userIdentity: UserIdentity = clone(decodedToken);
if (fhirUserClaim && usableScopes.some(scope => scope.startsWith('user/'))) {
userIdentity.fhirUserObject = getFhirUser(fhirUserClaim);
}
Expand Down
Loading