Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#8034): replicate primary contacts at max depth #9593

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
168 changes: 114 additions & 54 deletions api/src/services/authorization.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const getDepth = (userCtx) => {
const settingDepth = setting && parseInt(setting.depth, 10);
if (!isNaN(settingDepth) && settingDepth > depth.contactDepth) {
depth.contactDepth = settingDepth;
depth.replicatePrimaryContacts = setting.replicate_primary_contacts;

const settingsReportDepth = setting && parseInt(setting.report_depth);
depth.reportDepth = !isNaN(settingsReportDepth) ? settingsReportDepth : -1;
Expand Down Expand Up @@ -129,7 +130,7 @@ const allowedDoc = (docId, authorizationContext, { replicationKeys, contactsByDe

if (contactsByDepth && contactsByDepth.length) {
//it's a contact
return allowedContact(contactsByDepth, authorizationContext.contactsByDepthKeys);
return allowedContact(contactsByDepth, authorizationContext);
}

//it's a report, task or target
Expand Down Expand Up @@ -199,43 +200,62 @@ const getContactsByDepthKeys = (userCtx, depth) => {
return keys;
};

// checks whether there is at least one common contactsByDepthKey
const allowedContact = (contactsByDepth, userContactsByDepthKeys) => {
const viewResultKeys = contactsByDepth.map(result => result.key);
return viewResultKeys.some(viewResult => userContactsByDepthKeys.some(generated => _.isEqual(viewResult, generated)));
/**
* Returns whether an authenticated user has access to a document
* @param {Array<{ key: [string, string?], value: { _id:string, shortcode:string} }>} docContactsByDepth
* @param {Object} authorizationContext
* @param {Array} authorizationContext.contactsByDepthKeys - list containing user's generated contactsByDepthKeys
* @param {Array<string>} authorizationContext.subjectIds - allowed subjectIds.
*
* @returns {Boolean}
*/
const allowedContact = (docContactsByDepth, authorizationContext) => {
const viewResultKeys = docContactsByDepth.map(result => result.key);
const contactsByDepthKeys = authorizationContext.contactsByDepthKeys;
const matchedView = viewResultKeys.some(
viewResult => contactsByDepthKeys.some(generated => _.isEqual(viewResult, generated))
);

if (matchedView) {
return true;
}

// this doc isn't allowed through its direct lineage, but can be a primary contact of a place that is.
const { _id: docId, shortcode } = docContactsByDepth[0].value;
return authorizationContext.subjectIds.includes(docId) || authorizationContext.subjectIds.includes(shortcode);
};

const getContextObject = (userCtx) => {
const { contactDepth, reportDepth } = getDepth(userCtx);
const { contactDepth, reportDepth, replicatePrimaryContacts } = getDepth(userCtx);
const subjectsDepth = {};
return {
userCtx,
contactsByDepthKeys: getContactsByDepthKeys(userCtx, contactDepth),
contactsByDepthKeys: getContactsByDepthKeys(userCtx, contactDepth, replicatePrimaryContacts),
subjectIds: [ ALL_KEY, getUserSettingsId(userCtx.name) ],
contactDepth,
reportDepth,
subjectsDepth,
replicatePrimaryContacts,
};
};

const getContactSubjects = (row) => {
const subjects = [];
const getContactSubjects = (row, replicatePrimaryContacts) => {
const { _id: docId, shortcode, primary_contact: primaryContact } = row.value || {};
const subjects = [docId, shortcode];

subjects.push(row.id);

if (row.value) {
subjects.push(row.value);
if (replicatePrimaryContacts) {
subjects.push(primaryContact);
}

return subjects;
return subjects.filter(Boolean);
};

const getAuthorizationContext = (userCtx) => {
const authorizationCtx = getContextObject(userCtx);

return db.medic.query('medic/contacts_by_depth', { keys: authorizationCtx.contactsByDepthKeys }).then(results => {
results.rows.forEach(row => {
const subjects = getContactSubjects(row);
const subjects = getContactSubjects(row, authorizationCtx.replicatePrimaryContacts);
authorizationCtx.subjectIds.push(...subjects);

if (usesReportDepth(authorizationCtx)) {
Expand Down Expand Up @@ -269,8 +289,11 @@ const getReplicationKeys = (viewResults) => {
return replicationKeys;
};

// replication keys are either contact shortcodes (`patient_id` or `place_id`) or doc ids
// returns a list of corresponding contact docs
/**
* returns a list of corresponding contact docs
* @param {string[]} replicationKeys - either contact shortcodes (`patient_id` or `place_id`) or doc ids
* @returns {Promise<Object[]>}
*/
const findContactsByReplicationKeys = (replicationKeys) => {
replicationKeys = _.without(replicationKeys, UNASSIGNED_KEY);

Expand Down Expand Up @@ -300,27 +323,67 @@ const findContactsByReplicationKeys = (replicationKeys) => {
.then(results => results.rows.map(row => row.doc).filter(doc => doc));
};

const getContactShortcode = (viewResults) => viewResults &&
viewResults.contactsByDepth &&
viewResults.contactsByDepth[0] &&
viewResults.contactsByDepth[0].value;

const getContactUuid = (viewResults) => viewResults &&
viewResults.contactsByDepth &&
viewResults.contactsByDepth[0] &&
viewResults.contactsByDepth[0].key &&
viewResults.contactsByDepth[0].key[0];

// in case we want to determine whether a user has access to a small set of docs (for example, during a GET attachment
// request), instead of querying `medic/contacts_by_depth` to get all allowed subjectIds, we run the view queries
// over the provided docs, get all contacts that the docs emit for in `medic/docs_by_replication_key` and create a
// reduced set of relevant allowed subject ids.
const getScopedAuthorizationContext = (userCtx, scopeDocsCtx = []) => {
const getContactsByLineage = async (docs) => {
const lineageIds = new Set();

for (const doc of docs) {
let parent = doc;
while (parent) {
lineageIds.add(parent._id);
parent = parent.parent;
}
}

const uniqIds = [...lineageIds].filter(Boolean);
const allDocsResult = await db.medic.allDocs({ keys: uniqIds, include_docs: true });
return allDocsResult.rows.map(row => row.doc).filter(doc => doc);
};

/**
* Iterates over list of contacts and populates list of allowed subject ids. Returns whether new subjects were added.
* @param {{ subjectIds: string[], replicatePrimaryContacts: Boolean }}authorizationCtx
* @param {Object[]} contacts - contact docs to be evaluated
*/
const populateAllowedSubjectIds = (authorizationCtx, contacts) => {
const initialSubjectIdsCount = authorizationCtx.subjectIds.length;
contacts.forEach(contact => {
if (!contact) {
return;
}

const viewResults = getViewResults(contact);
if (!allowedDoc(contact._id, authorizationCtx, viewResults)) {
return;
}

const contactsByDepthResults = viewResults.contactsByDepth?.[0]?.value;
const subjectIds = [contactsByDepthResults._id, contactsByDepthResults.shortcode];
if (authorizationCtx.replicatePrimaryContacts) {
subjectIds.push(contactsByDepthResults.primary_contact);
}
const contactDepth = getContactDepth(authorizationCtx, viewResults.contactsByDepth);
includeSubjects(authorizationCtx, subjectIds, contactDepth);
});
return authorizationCtx.subjectIds.length !== initialSubjectIdsCount;
};

/**
* To determine whether a user has access to a small set of docs (for example, during a GET attachment
* request), instead of querying `medic/contacts_by_depth` to get all allowed subjectIds, runs the view queries
* over the provided docs, gets all contacts that the docs emit for in `medic/docs_by_replication_key`,
* if primary contacts are replicated, we also include the docs' lineage, and creates a reduced set of
* relevant allowed subject ids.
*
* @param userCtx
* @param scopeDocsCtx
* @returns {Promise<{subjectIds: (string|string)[]}
*/
const getScopedAuthorizationContext = async (userCtx, scopeDocsCtx = []) => {
const authorizationCtx = getContextObject(userCtx);

scopeDocsCtx = scopeDocsCtx.filter(docCtx => docCtx && docCtx.doc);
if (!scopeDocsCtx.length) {
return Promise.resolve(authorizationCtx);
return authorizationCtx;
}

// collect all values that the docs would emit in `medic/docs_by_replication_key`
Expand All @@ -330,31 +393,28 @@ const getScopedAuthorizationContext = (userCtx, scopeDocsCtx = []) => {
replicationKeys.push(...getReplicationKeys(viewResults));
});

return findContactsByReplicationKeys(replicationKeys).then(contacts => {
// we simulate a `medic/contacts_by_depth` filter over the list contacts
contacts.forEach(contact => {
if (!contact) {
return;
}
const contacts = await findContactsByReplicationKeys(replicationKeys);
if (authorizationCtx.replicatePrimaryContacts) {
const contactsByLineage = await getContactsByLineage(contacts);
contacts.push(...contactsByLineage);
}

const viewResults = getViewResults(contact);
if (!allowedDoc(contact._id, authorizationCtx, viewResults)) {
return;
}
// we simulate a `medic/contacts_by_depth` filter over the list contacts
// reiterate because primary contacts are only allowed after we initially populate subject ids list
let newSubjects;
do {
newSubjects = populateAllowedSubjectIds(authorizationCtx, contacts);
console.log(newSubjects);
} while (newSubjects);

const contactUuid = getContactUuid(viewResults);
const contactDepth = getContactDepth(authorizationCtx, viewResults.contactsByDepth);
const shortcode = getContactShortcode(viewResults);

includeSubjects(authorizationCtx, [contactUuid, shortcode], contactDepth);
});
if (hasAccessToUnassignedDocs(userCtx)) {
authorizationCtx.subjectIds.push(UNASSIGNED_KEY);
}

if (hasAccessToUnassignedDocs(userCtx)) {
authorizationCtx.subjectIds.push(UNASSIGNED_KEY);
}
console.log(authorizationCtx.subjectIds);

return authorizationCtx;
});
return authorizationCtx;
};

/**
Expand Down
6 changes: 5 additions & 1 deletion ddocs/medic-db/medic/views/contacts_by_depth/map.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
function(doc) {
if (['contact', 'person', 'clinic', 'health_center', 'district_hospital'].indexOf(doc.type) !== -1) {
var value = doc.patient_id || doc.place_id;
var value = {
_id: doc._id,
shortcode: doc.patient_id || doc.place_id,
primary_contact: doc.contact && doc.contact._id
}
var parent = doc;
var depth = 0;
while (parent) {
Expand Down
Loading