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 4 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
4 changes: 3 additions & 1 deletion src/regExpressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ 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}`;
Expand All @@ -38,7 +40,7 @@ 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);
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
150 changes: 148 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,29 @@ describe('verifyAccessToken; System level export requests', () => {
expectedUserIdentity,
);
});

test('System scope with no sub set', () => {
const decodedAccessToken = { ...baseAccessNoScopes, scp: ['system/*.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: 'get-status-export' },
}),
).resolves.toMatchObject({
...decodedAccessToken,
sub: '__system__',
});
});
});

function createEntry(resource: any, searchMode = 'match') {
Expand Down
25 changes: 17 additions & 8 deletions src/smartHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const logger = getComponentLogger();

// eslint-disable-next-line import/prefer-default-export
export class SMARTHandler implements Authorization {
private static defaultSystemId = '__system__';

/**
* If a fhirUser is of these resourceTypes they will be able to READ & WRITE without having to meet the reference criteria
*/
Expand Down Expand Up @@ -109,25 +111,32 @@ 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) {
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 requrestor is relying on the "user" scope we need to verify they are coming from the correct endpoint & resourceType
rsmayda marked this conversation as resolved.
Show resolved Hide resolved
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');
}
} else if (!userIdentity.sub) {
// if there is a system scope and there is no sub set it to system
userIdentity.sub = SMARTHandler.defaultSystemId;
rsmayda marked this conversation as resolved.
Show resolved Hide resolved
}
}

const userIdentity: UserIdentity = clone(decodedToken);
if (fhirUserClaim && usableScopes.some(scope => scope.startsWith('user/'))) {
userIdentity.fhirUserObject = getFhirUser(fhirUserClaim);
}
Expand Down
39 changes: 34 additions & 5 deletions src/smartScopeHelper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ const emptyScopeRule = (): ScopeRule => ({
read: [],
write: [],
},
system: {
read: [],
write: [],
},
});
const isScopeSufficientCases: ScopeType[][] = [['user'], ['patient']];
const isScopeSufficientCases: ScopeType[][] = [['user'], ['patient'], ['system']];
describe.each(isScopeSufficientCases)('%s: isScopeSufficient', (scopeType: ScopeType) => {
test('scope is sufficient to read Observation: Scope with resourceType "Observation" should be able to read "Observation" resources', () => {
const clonedScopeRule = emptyScopeRule();
Expand Down Expand Up @@ -58,14 +62,14 @@ describe.each(isScopeSufficientCases)('%s: isScopeSufficient', (scopeType: Scope
);
});

test('scope is sufficient for bulk data access with "user" scopeType but not "patient" scopeType', () => {
test('scope is sufficient for bulk data access with "user" || "system" scopeType but not "patient" scopeType', () => {
const clonedScopeRule = emptyScopeRule();
clonedScopeRule[scopeType].read = ['read'];
const bulkDataAuth: BulkDataAuth = { operation: 'initiate-export', exportType: 'system' };

// Only scopeType of user has bulkDataAccess
expect(isScopeSufficient(`${scopeType}/*.read`, clonedScopeRule, 'read', undefined, bulkDataAuth)).toEqual(
scopeType === 'user',
scopeType !== 'patient',
);
});

Expand Down Expand Up @@ -107,13 +111,19 @@ describe.each(isScopeSufficientCases)('%s: isScopeSufficient', (scopeType: Scope

describe('getScopes', () => {
test('scope type delimited by space', () => {
expect(getScopes('launch/encounter user/*.read fake')).toEqual(['launch/encounter', 'user/*.read', 'fake']);
expect(getScopes('launch/encounter user/*.read fake system/*.*')).toEqual([
'launch/encounter',
'user/*.read',
'fake',
'system/*.*',
]);
});
test('scope type as array', () => {
expect(getScopes(['launch/encounter', 'user/*.read', 'fake'])).toEqual([
expect(getScopes(['launch/encounter', 'user/*.read', 'fake', 'system/*.*'])).toEqual([
'launch/encounter',
'user/*.read',
'fake',
'system/*.*',
]);
});
});
Expand Down Expand Up @@ -204,6 +214,25 @@ describe('filterOutUnusableScope', () => {
).toEqual([expectedScopes[0], expectedScopes[2]]);
});

test('filter system; due to scope being insufficient', () => {
const clonedScopeRule = emptyScopeRule();
clonedScopeRule.user.read = ['read'];
clonedScopeRule.patient.read = ['read'];
clonedScopeRule.system.read = ['read'];
const expectedScopes = ['user/Patient.read', 'system/Obersvation.*', 'system/*.read'];
expect(
filterOutUnusableScope(
expectedScopes,
clonedScopeRule,
'read',
'Patient',
undefined,
undefined,
'fhirUser',
),
).toEqual([expectedScopes[0], expectedScopes[2]]);
rsmayda marked this conversation as resolved.
Show resolved Hide resolved
});

test('filter user & patient', () => {
const clonedScopeRule = emptyScopeRule();
clonedScopeRule.user.read = ['read'];
Expand Down
Loading