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

Commit

Permalink
feat: Add support for system scope (#41)
Browse files Browse the repository at this point in the history
  • Loading branch information
rsmayda authored Jun 24, 2021
1 parent 332806d commit 2229ce8
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 78 deletions.
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

0 comments on commit 2229ce8

Please sign in to comment.