Skip to content
This repository has been archived by the owner on Jul 11, 2024. It is now read-only.

Feature/cds sls #146

Merged
merged 4 commits into from
Mar 28, 2024
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
6 changes: 4 additions & 2 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ const morgan = require("morgan");
const { ping } = require("./controllers/ping");
const { error } = require("./controllers/error");
const { discovery } = require("./controllers/discovery");
const Hook = require("./controllers/patient-consent-consult");
const ConsentDecisionHook = require("./controllers/patient-consent-consult");
const Xacml = require("./controllers/xacml");
const SLS = require("./controllers/sls");
const SLSHook = require("./controllers/bundle-security-label");

const app = express();

Expand All @@ -23,9 +24,10 @@ app.get("/ping", ping);

app.get("/cds-services", discovery);

app.post("/cds-services/patient-consent-consult", Hook.post);
app.post("/cds-services/patient-consent-consult", ConsentDecisionHook.post);
app.post("/xacml", Xacml.post);
app.post("/sls", SLS.post);
app.post("/cds-services/bundle-security-label", SLSHook.post);

app.use(error);

Expand Down
19 changes: 19 additions & 0 deletions controllers/bundle-security-label.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const { validateSlsHookRequest } = require("../lib/validators");
const { labelBundleDiff } = require("../lib/labeling/labeler");
const {
resourcesToHookResponse
} = require("../lib/labeling/sls-decision-hooks-response");

async function post(req, res, next) {
try {
validateSlsHookRequest(req);
const bundle = req.body.context.bundle;
res.send(resourcesToHookResponse(labelBundleDiff(bundle)));
} catch (e) {
next(e);
}
}

module.exports = {
post
};
4 changes: 2 additions & 2 deletions controllers/patient-consent-consult.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { validateHookRequest } = require("../lib/validators");
const { validateConsentDecisionHookRequest } = require("../lib/validators");
const { asCard } = require("../lib/consent-decision-card");
const { processDecision } = require("../lib/consent-processor");
const { fetchConsents } = require("../lib/consent-discovery");
Expand All @@ -7,7 +7,7 @@ const logger = require("../lib/logger");

async function post(req, res, next) {
try {
validateHookRequest(req);
validateConsentDecisionHookRequest(req);

const patientIds = req.body.context.patientId;
const category = req.body.context.category || [];
Expand Down
4 changes: 2 additions & 2 deletions lib/decision-processor.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
const { CONSENT_PERMIT } = require("./consent-decisions");
const { maybeRedactBundle } = require("./redacter");
const { labelBundle } = require("./labeling/labeler");
const { label } = require("./labeling/labeler");

const maybeApplyDecision = (decisionEntry, content) => {
return content && decisionEntry.decision === CONSENT_PERMIT
? {
...decisionEntry,
content: maybeRedactBundle(
decisionEntry.obligations,
labelBundle(content)
label(content)
)
}
: decisionEntry;
Expand Down
45 changes: 36 additions & 9 deletions lib/labeling/labeler.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,24 @@ const labelBundle = (bundle) => ({
...bundle,
entry: bundle.entry.map((entry) => ({
...entry,
resource: labelResource(entry.resource)
resource: sanitizeResource(labelResource(entry.resource))
}))
});

/**
* remove the 'updated' attribute used to track which resources were update in the course of labeling.
*/
function sanitizeResource(resource) {
const { updated, ...rest } = resource;
return rest;
}

const labelBundleDiff = (bundle) =>
bundle.entry
.map(({ resource }) => labelResource(resource))
.filter((resource) => resource.updated)
.map((resource) => sanitizeResource(resource));

const labelResource = (resource) =>
labelResourceConfidentiality(labelResourceSensitivity(resource));

Expand All @@ -37,7 +51,10 @@ function labelResourceConfidentiality(resource) {
}))
)
.flat();
return addUniqueLabelsToResource(resource, labels);
return addUniqueLabelsToResource(
{ ...resource, updated: applicableRules.length > 0 },
labels
);
}

function addUniqueLabelsToResource(resource, labels) {
Expand All @@ -52,30 +69,40 @@ function addUniqueLabelsToResource(resource, labels) {
};
}

function labelResourceSensitivity(resource) {
function applicableSensitivityRules(resource) {
const clinicalCodes = JSONPath({ path: "$..coding", json: resource }).flat();
const canonicalCodes = codesShortHand(clinicalCodes);
const applicableRules = SENSITIVITY_RULES.map((rule) => ({
return SENSITIVITY_RULES.map((rule) => ({
...rule,
codeSets: rule.codeSets.filter(({ codes }) =>
codes.some((code) => canonicalCodes.includes(code))
)
})).filter(({codeSets}) => codeSets.length > 0);
})).filter(({ codeSets }) => codeSets.length > 0);
}

function labelResourceSensitivity(resource) {
const applicableRules = applicableSensitivityRules(resource);
const labels = applicableRules
.map(({ id, basis, labels, codeSets }) =>
labels.map((label) => ({
...label,
...{
extension: [
...basisExtension(basis),
...codeSets.map(({ groupId }) => basisExtension({ system: id, code: groupId })).flat()
...codeSets
.map(({ groupId }) =>
basisExtension({ system: id, code: groupId })
)
.flat()
]
}
}))
)
.flat();
return addUniqueLabelsToResource(resource, labels);
return addUniqueLabelsToResource(
{ ...resource, updated: applicableRules.length > 0 },
labels
);
}

const SEC_LABEL_BASIS_URL =
Expand All @@ -94,10 +121,10 @@ const basisExtension = (basisCoding) =>
const label = (object) =>
object?.resourceType == "Bundle"
? labelBundle(object)
: labelResource(object);
: sanitizeResource(labelResource(object));

module.exports = {
labelResource,
labelBundle,
labelBundleDiff,
label
};
15 changes: 15 additions & 0 deletions lib/labeling/sls-decision-hooks-response.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const resourcesToHookResponseUpdateAction = (resources) =>
resources.map((resource) => ({
type: "update",
description: "labeled resource",
resource: resource
}));

const resourcesToHookResponse = (resources) => ({
cards: [],
systemActions: resourcesToHookResponseUpdateAction(resources)
});

module.exports = {
resourcesToHookResponse
};
58 changes: 28 additions & 30 deletions lib/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,51 @@ const Ajv = require("ajv");

const ajv = new Ajv();

const hookRequestSchema = require("../schemas/patient-consent-consult-hook-request.schema.json");
const hookRequestValidator = ajv.compile(hookRequestSchema);
const consentDecisionHookRequestSchema = require("../schemas/patient-consent-consult-hook-request.schema.json");
const consentDecisionHookRequestValidator = ajv.compile(
consentDecisionHookRequestSchema
);

const xacmlRequestSchema = require("../schemas/xacml-request.schema.json");
const xacmlRequestValidator = ajv.compile(xacmlRequestSchema);

const slsRequestSchema = require("../schemas/sls-request.schema.json");
const slsRequestValidator = ajv.compile(slsRequestSchema);

function validateHookRequest(req) {
const slsHookRequestSchema = require("../schemas/bundle-security-label-request.schema.json");
const slsHookRequestValidator = ajv.compile(slsHookRequestSchema);

const validationException = (errors) => ({
httpCode: 400,
error: "bad_request",
errorMessage: `Invalid request: ${prettifySchemaValidationErrors(errors)}`
});

function validateConsentDecisionHookRequest(req) {
const body = req.body;
if (!hookRequestValidator(body)) {
const errorMessages = prettifySchemaValidationErrors(
hookRequestValidator.errors
);
throw {
httpCode: 400,
error: "bad_request",
errorMessage: `Invalid request: ${errorMessages}`
};
if (!consentDecisionHookRequestValidator(body)) {
throw validationException(consentDecisionHookRequestValidator.errors);
}
}

function validateXacmlRequest(req) {
const body = req.body;
if (!xacmlRequestValidator(body)) {
const errorMessages = prettifySchemaValidationErrors(
xacmlRequestValidator.errors
);
throw {
httpCode: 400,
error: "bad_request",
errorMessage: `Invalid request: ${errorMessages}`
};
throw validationException(xacmlRequestValidator.errors);
}
}

function validateSlsRequest(req) {
const body = req.body;
if (!slsRequestValidator(body)) {
const errorMessages = prettifySchemaValidationErrors(
slsRequestValidator.errors
);
throw {
httpCode: 400,
error: "bad_request",
errorMessage: `Invalid request: ${errorMessages}`
};
throw validationException(slsRequestValidator.errors);
}
}

function validateSlsHookRequest(req) {
const body = req.body;
if (!slsHookRequestValidator(body)) {
throw validationException(slsHookRequestValidator.errors);
}
}

Expand All @@ -61,7 +58,8 @@ function prettifySchemaValidationErrors(givenErrors) {
}

module.exports = {
validateHookRequest,
validateConsentDecisionHookRequest,
validateXacmlRequest,
validateSlsRequest
validateSlsRequest,
validateSlsHookRequest
};
51 changes: 51 additions & 0 deletions schemas/bundle-security-label-request.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/sdhealthconnect/leap-cds/schemas/bundle-security-label-request.schema.json",
"title": "SLS Hook Request",
"description": "SLS Hook Request",
"type": "object",
"properties": {
"hook": {
"type": "string",
"pattern": "bundle-security-label"
},
"hookInstance": {
"description": "UUID for this hook call",
"type": "string"
},
"context": {
"type": "object",
"properties": {
"bundle": {
"type": "object",
"properties": {
"entry": {
"type": "array",
"items": {
"type": "object",
"properties": {
"resource": {
"type": "object",
"properties": {
"resourceType": {
"type": "string"
}
},
"required": ["resourceType"]
}
},
"required": ["resource"]
}
},
"resourceType": {
"type": "string",
"pattern": "Bundle"
}
},
"required": ["entry", "resourceType"]
}
}
}
},
"required": ["hook", "hookInstance", "context"]
}
45 changes: 45 additions & 0 deletions test/controllers/bundle-security-label.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const _ = require("lodash");
const request = require("supertest");
const { app } = require("../../app");

const BUNDLE = require("../fixtures/empty-bundle.json");
const OBSERVATION = require("../fixtures/observations/observations-ketamine.json");
const NON_SENSITIVE_OBSERVATION = require("../fixtures/observations/observation-bacteria.json");

it("should return 200 and a labeled bundle", async () => {
const bundleOfObservations = _.cloneDeep(BUNDLE);
bundleOfObservations.entry = [
{ fullUrl: "1", resource: OBSERVATION },
{ fullUrl: "2", resource: NON_SENSITIVE_OBSERVATION }
];
bundleOfObservations.total = 2;

const res = await request(app)
.post("/cds-services/bundle-security-label")
.set("Accept", "application/json")
.send({
hookInstance: "...",
hook: "bundle-security-label",
context: {
bundle: bundleOfObservations
}
});

expect(res.status).toEqual(200);

const systemActions = res.body.systemActions;
expect(systemActions.length).toBe(1);
expect(systemActions[0].type).toBe("update");
expect(systemActions[0].resource.meta?.security).toMatchObject(
expect.arrayContaining([
expect.objectContaining({
system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
code: "SUD"
}),
expect.objectContaining({
system: "http://terminology.hl7.org/CodeSystem/v3-Confidentiality",
code: "R"
})
])
);
});
Loading
Loading