Skip to content

Commit

Permalink
RN-1001: Limit which users and permissions can be seen in the Admin P…
Browse files Browse the repository at this point in the history
…anel (#5350)
  • Loading branch information
rohan-bes authored Feb 11, 2024
1 parent 1d72ddd commit cb6a1eb
Show file tree
Hide file tree
Showing 34 changed files with 645 additions and 223 deletions.
3 changes: 0 additions & 3 deletions packages/access-policy/src/AccessPolicy.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ export class AccessPolicy {
throw new Error('Cannot instantiate an AccessPolicy without providing the policy details');
}
const permissionGroupLists = Object.values(this.policy);
if (permissionGroupLists.length === 0) {
throw new Error('At least one entity should be specified in an access policy');
}
if (permissionGroupLists.some(permissionGroups => !Array.isArray(permissionGroups))) {
throw new Error(
'Each entity should contain an array of permissionGroups for which the user has access',
Expand Down
2 changes: 1 addition & 1 deletion packages/admin-panel-server/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export async function createApp() {
.use('hierarchies', forwardToEntityApi)
.use('*', forwardRequest(CENTRAL_API_URL));

await builder.initialiseApiClient([{ entityCode: 'DL', permissionGroupName: 'Public' }]);
await builder.initialiseApiClient();

const app = builder.build();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,21 @@ const StyledHorizontalTree = styled(HorizontalTree)`
}
`;

function getDescendantData(parentNodeId, data) {
return data
.filter(node => node.parent_id === parentNodeId)
const getRootLevelNodes = data =>
data.filter(({ parent_id: parentId }) => !data.some(({ id }) => id === parentId)); // Cannot find parent

const getChildrenOfNode = (data, nodeId) =>
data.filter(({ parent_id: parentId }) => parentId === nodeId);

const getDescendantData = (parentNodeId, data) => {
const nodes = !parentNodeId ? getRootLevelNodes(data) : getChildrenOfNode(data, parentNodeId);
return nodes
.sort((a, b) => a.name.localeCompare(b.name))
.map(node => {
const children = data.filter(childNode => childNode.parent_id === node.id);
return { id: node.id, name: node.name, children };
});
}
};

const usePermissionGroups = () => {
const { isLoading, data } = useQuery(
Expand Down
22 changes: 22 additions & 0 deletions packages/admin-panel/src/pages/resources/UsersPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,28 @@ const CREATE_CONFIG = {
editEndpoint: 'users',
fields: [
...EDIT_FIELDS,
{
Header: 'Country',
source: 'countryName',
editConfig: {
sourceKey: 'countryName',
optionsEndpoint: 'countries',
optionLabelKey: 'name',
optionValueKey: 'name',
secondaryLabel: 'Select the country to grant this user access to',
},
},
{
Header: 'Permission Group',
source: 'permissionGroupName',
editConfig: {
sourceKey: 'permissionGroupName',
optionsEndpoint: 'permissionGroups',
optionLabelKey: 'name',
optionValueKey: 'name',
secondaryLabel: 'Select the permission group to grant this user access to',
},
},
{
Header: 'Api Client (Not required for most users, see Readme of admin-panel for usage)',
source: 'is_api_client',
Expand Down
4 changes: 2 additions & 2 deletions packages/central-server/src/apiV2/GETPermissionGroups.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { GETHandler } from './GETHandler';
import { assertAdminPanelAccess, hasTupaiaAdminPanelAccess } from '../permissions';
import { assertAdminPanelAccess, hasBESAdminAccess } from '../permissions';

export class GETPermissionGroups extends GETHandler {
permissionsFilteredInternally = true;
Expand All @@ -22,7 +22,7 @@ export class GETPermissionGroups extends GETHandler {
}

async getPermissionsFilter(dbConditions, dbOptions) {
if (hasTupaiaAdminPanelAccess(this.accessPolicy)) {
if (hasBESAdminAccess(this.accessPolicy)) {
return { dbConditions, dbOptions };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ export class EditAccessRequests extends BulkEditHandler {

async checkPermissionForRecord(models, recordId, updatedRecord) {
const accessRequest = await models.accessRequest.findById(recordId);
const accessRequestData = await accessRequest.getData();
// Check Permissions
const accessRequestChecker = accessPolicy =>
assertAccessRequestEditPermissions(accessPolicy, models, recordId, updatedRecord);
assertAccessRequestEditPermissions(accessPolicy, models, recordId, {
...accessRequestData,
...updatedRecord,
});
await this.assertPermissions(
assertAnyPermissions([assertBESAdminAccess, accessRequestChecker]),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
* Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd
*/

import { QUERY_CONJUNCTIONS, SqlQuery } from '@tupaia/database';
import { hasBESAdminAccess, BES_ADMIN_PERMISSION_GROUP } from '../../permissions';
import {
getAdminPanelAllowedCountryIds,
getAdminPanelAllowedCountryCodes,
mergeFilter,
getAdminPanelAllowedPermissionGroupIdsByCountryIds,
} from '../utilities';

export const assertAccessRequestPermissions = async (accessPolicy, models, accessRequestId) => {
Expand All @@ -18,10 +18,21 @@ export const assertAccessRequestPermissions = async (accessPolicy, models, acces

const entity = await models.entity.findById(accessRequest.entity_id);
const accessibleCountryCodes = getAdminPanelAllowedCountryCodes(accessPolicy);
if (accessibleCountryCodes.includes(entity.country_code)) {
return true;
if (!accessibleCountryCodes.includes(entity.country_code)) {
throw new Error('Need Admin Panel access to the country this access request is for');
}
throw new Error('Need Admin Panel access to the country this access request is for');

if (accessRequest.permission_group_id) {
const permissionGroup = await models.permissionGroup.findById(
accessRequest.permission_group_id,
);

if (!accessPolicy.allows(entity.country_code, permissionGroup.name)) {
throw new Error(`Need ${permissionGroup.name} access to ${entity.country_code}`);
}
}

return true;
};

export const assertAccessRequestEditPermissions = async (
Expand All @@ -40,7 +51,7 @@ export const assertAccessRequestEditPermissions = async (
const accessRequest = await models.accessRequest.findById(accessRequestId);
const permissionGroup = await models.permissionGroup.findById(accessRequest.permission_group_id);
if (permissionGroup.name === BES_ADMIN_PERMISSION_GROUP) {
throw new Error('Need BES Admin access to make this change');
throw new Error('Need Admin Panel access to the country this access request is for');
}

return true;
Expand All @@ -51,34 +62,70 @@ export const assertAccessRequestUpsertPermissions = async (
models,
{ permission_group_id: permissionGroupId, entity_id: entityId },
) => {
// Check we're not trying to change this access request to give someone:
// BES admin access
// Access to an entity we don't have admin panel access
const entity = await models.entity.findById(entityId);
const accessibleCountryCodes = getAdminPanelAllowedCountryCodes(accessPolicy);
if (!accessibleCountryCodes.includes(entity.country_code)) {
throw new Error('Need access to the newly edited entity');
}

if (permissionGroupId) {
// Check we're not trying to change this access request to give someone:
// BES admin access
// Access to an entity we don't have admin panel access
const permissionGroup = await models.permissionGroup.findById(permissionGroupId);
if (permissionGroup.name === BES_ADMIN_PERMISSION_GROUP) {
throw new Error('Need BES Admin access to make this change');
}
}
if (entityId) {
const entity = await models.entity.findById(entityId);
const accessibleCountryCodes = getAdminPanelAllowedCountryCodes(accessPolicy);
if (!accessibleCountryCodes.includes(entity.country_code)) {
throw new Error('Need access to the newly edited entity');

if (!accessPolicy.allows(entity.country_code, permissionGroup.name)) {
throw new Error(`Need ${permissionGroup.name} access to ${entity.country_code}`);
}
}
};

/**
* Filter to check if the entity permission is within our access policy.
*
* eg. { DL: [Admin, Public], TO: ['Donor'] }
* =>
* (entity = 'DL' AND (permission_group IS NULL OR permission_group IN ('Admin', 'Public'))
* OR (entity = 'TO' AND (permission_group IS NULL OR permission_group IN ('Donor'))
*/
const buildRawSqlAccessRequestFilter = async (accessPolicy, models) => {
const allowedPermissionIdsByCountryIds = await getAdminPanelAllowedPermissionGroupIdsByCountryIds(
accessPolicy,
models,
);
const sql = Object.values(allowedPermissionIdsByCountryIds)
.map(
permissionGroupIds =>
`(access_request.entity_id = ? AND (access_request.permission_group_id IS NULL OR access_request.permission_group_id IN ${SqlQuery.record(
permissionGroupIds,
)}))`,
)
.join(' OR ');

const parameters = Object.entries(allowedPermissionIdsByCountryIds).flat(Infinity);
return { sql, parameters };
};

export const createAccessRequestDBFilter = async (accessPolicy, models, criteria) => {
if (hasBESAdminAccess(accessPolicy)) {
return criteria;
}
// If we don't have BES Admin access, add a filter to the SQL query
const dbConditions = { ...criteria };
dbConditions['access_request.entity_id'] = mergeFilter(
await getAdminPanelAllowedCountryIds(accessPolicy, models),
dbConditions['access_request.entity_id'],
);
const rawSqlFilter = await buildRawSqlAccessRequestFilter(accessPolicy, models);
if (!criteria || Object.keys(criteria).length === 0) {
// No given criteria, just return raw SQL
return {
[QUERY_CONJUNCTIONS.RAW]: rawSqlFilter,
};
}

return dbConditions;
return {
...criteria,
[QUERY_CONJUNCTIONS.AND]: {
[QUERY_CONJUNCTIONS.RAW]: rawSqlFilter,
},
};
};
53 changes: 35 additions & 18 deletions packages/central-server/src/apiV2/import/importUserPermissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ import {
ObjectValidator,
constructRecordExistsWithCode,
constructRecordExistsWithField,
DatabaseError,
} from '@tupaia/utils';
import { assertBESAdminAccess } from '../../permissions';
import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions';
import { assertUserEntityPermissionUpsertPermissions } from '../userEntityPermissions/assertUserEntityPermissionPermissions';

const extractItems = filePath => {
const { Sheets: sheets } = xlsx.readFile(filePath);
return xlsx.utils.sheet_to_json(Object.values(sheets)[0]);
};

async function create(transactingModels, items) {
async function create(req, transactingModels, items) {
const validator = new ObjectValidator({
user_email: [constructRecordExistsWithField(transactingModels.user, 'email')],
entity_code: [
Expand Down Expand Up @@ -48,31 +48,50 @@ async function create(transactingModels, items) {
excelRowNumber++;
await validator.validate(item, constructImportValidationError);

const { user_email, entity_code, permission_group_name } = item;
const {
user_email: email,
entity_code: entityCode,
permission_group_name: permissionGroupName,
} = item;

const user = await transactingModels.user.findOne({ email: user_email });
const entity = await transactingModels.entity.findOne({ code: entity_code });
const user = await transactingModels.user.findOne({ email });
const entity = await transactingModels.entity.findOne({ code: entityCode });
const permissionGroup = await transactingModels.permissionGroup.findOne({
name: permission_group_name,
name: permissionGroupName,
});

const existingRecord = await transactingModels.userEntityPermission.findOne({
const userEntityPermissionData = {
user_id: user.id,
entity_id: entity.id,
permission_group_id: permissionGroup.id,
});
};

const existingRecord = await transactingModels.userEntityPermission.findOne(
userEntityPermissionData,
);
if (existingRecord) {
// Already added
console.info(
`User permission ${user.id} / ${entity.id} / ${permissionGroup.id} already exists, skipping`,
);
continue;
} else {
await transactingModels.userEntityPermission.create({
user_id: user.id,
entity_id: entity.id,
permission_group_id: permissionGroup.id,
});
const createUserEntityPermissionChecker = async accessPolicy => {
await assertUserEntityPermissionUpsertPermissions(
accessPolicy,
transactingModels,
userEntityPermissionData,
);
};

try {
await req.assertPermissions(
assertAnyPermissions([assertBESAdminAccess, createUserEntityPermissionChecker]),
);
} catch (error) {
throw constructImportValidationError(error.message);
}

await transactingModels.userEntityPermission.create(userEntityPermissionData);
}
}
}
Expand All @@ -83,8 +102,6 @@ async function create(transactingModels, items) {
export async function importUserPermissions(req, res) {
const { models } = req;

await req.assertPermissions(assertBESAdminAccess);

let items;
try {
items = extractItems(req.file.path);
Expand All @@ -93,7 +110,7 @@ export async function importUserPermissions(req, res) {
}

await models.wrapInTransaction(async transactingModels => {
await create(transactingModels, items);
await create(req, transactingModels, items);
respond(res, { message: `Imported User Permissions` });
});
}
35 changes: 32 additions & 3 deletions packages/central-server/src/apiV2/import/importUsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import {
} from '@tupaia/utils';
import { hashAndSaltPassword } from '@tupaia/auth';
import { VerifiedEmail } from '@tupaia/types';
import { assertBESAdminAccess } from '../../permissions';
import {
TUPAIA_ADMIN_PANEL_PERMISSION_GROUP,
assertAnyPermissions,
assertBESAdminAccess,
hasTupaiaAdminPanelAccessToCountry,
} from '../../permissions';

export async function importUsers(req, res) {
await req.assertPermissions(assertBESAdminAccess);

try {
const { models } = req;
if (!req.file) {
Expand Down Expand Up @@ -70,6 +73,32 @@ export async function importUsers(req, res) {
countryName,
);
}

const createUserPermissionChecker = accessPolicy => {
if (!hasTupaiaAdminPanelAccessToCountry(accessPolicy, countryEntity.code)) {
throw new Error(
`Need ${TUPAIA_ADMIN_PANEL_PERMISSION_GROUP} access to ${countryEntity.name}`,
);
}

if (!accessPolicy.allows(countryEntity.code, permissionGroup.name)) {
throw new Error(`Need ${permissionGroup.name} access to ${countryEntity.name}`);
}
};

try {
await req.assertPermissions(
assertAnyPermissions([assertBESAdminAccess, createUserPermissionChecker]),
);
} catch (error) {
throw new ImportValidationError(
error.message,
excelRowNumber,
'permission_group',
countryName,
);
}

await transactingModels.userEntityPermission.findOrCreate({
user_id: user.id,
entity_id: countryEntity.id,
Expand Down
Loading

0 comments on commit cb6a1eb

Please sign in to comment.