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

[PoC] Implement parser for permissions framework #519

Draft
wants to merge 9 commits 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
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module.exports = {
'@typescript-eslint/indent': 'off',
'@typescript-eslint/quotes': 'off',
'import/prefer-default-export': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
settings: {
'import/resolver': {
Expand Down
3 changes: 2 additions & 1 deletion hrm-domain/hrm-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"pg-hstore": "^2.3.4",
"pg-promise": "^10.11.1",
"twilio": "^3.58.0",
"twilio-flex-token-validator": "^1.5.5"
"twilio-flex-token-validator": "^1.5.5",
"typescript-parsec": "^0.3.4"
},
"devDependencies": {
"@tech-matters/testing": "^1.0.0",
Expand Down
12 changes: 8 additions & 4 deletions hrm-domain/hrm-service/service-tests/search-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { randomBytes } from 'crypto';
import { mockingProxy, mockSuccessfulTwilioAuthentication } from '@tech-matters/testing';

import { db } from '../src/connection-pool';
import { ConditionsSets, RulesFile } from '../src/permissions/rulesMap';
import { TKConditionsSets, RulesFile } from '../src/permissions/rulesMap';
import {
headers,
getRequest,
Expand All @@ -31,6 +31,7 @@ import {
} from './server';
import { SearchParameters as ContactSearchParameters } from '../src/contact/contact-data-access';
import { SearchParameters as CaseSearchParameters } from '../src/case/caseService';
import { TargetKind } from '../src/permissions/actions';

useOpenRules();
const server = getServer();
Expand Down Expand Up @@ -98,7 +99,10 @@ afterEach(async () => {
await cleanUpDB();
});

const overridePermissions = (key: string, permissions: ConditionsSets) => {
const overridePermissions = <T extends TargetKind>(
key: string,
permissions: TKConditionsSets<T>,
) => {
useOpenRules();
const rules: RulesFile = {
...(defaultConfig.permissions?.rules() as RulesFile),
Expand All @@ -107,10 +111,10 @@ const overridePermissions = (key: string, permissions: ConditionsSets) => {
setRules(rules);
};

const overrideViewContactPermissions = (permissions: ConditionsSets) =>
const overrideViewContactPermissions = (permissions: TKConditionsSets<'contact'>) =>
overridePermissions('viewContact', permissions);

const overrideViewCasePermissions = (permissions: ConditionsSets) =>
const overrideViewCasePermissions = (permissions: TKConditionsSets<'case'>) =>
overridePermissions('viewCase', permissions);

describe('search contacts permissions', () => {
Expand Down
12 changes: 6 additions & 6 deletions hrm-domain/hrm-service/src/case/caseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
} from './case-data-access';
import { randomUUID } from 'crypto';
import type { Contact } from '../contact/contact-data-access';
import { setupCanForRules } from '../permissions/setupCanForRules';
import type { InitializedCan } from '../permissions/initializeCanForRules';
import type { TwilioUser } from '@tech-matters/twilio-worker-auth';
import {
bindApplyTransformations as bindApplyContactTransformations,
Expand Down Expand Up @@ -211,7 +211,7 @@ const caseRecordToCase = (record: CaseRecordWithLegacyCategoryContacts): CaseSer
};

const mapContactTransformations =
({ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser }) =>
({ can, user }: { can: InitializedCan; user: TwilioUser }) =>
(caseRecord: CaseRecord) => {
const applyTransformations = bindApplyContactTransformations(can, user);
const withTransformedContacts = {
Expand Down Expand Up @@ -254,7 +254,7 @@ export const updateCase = async (
body: Partial<CaseService>,
accountSid: CaseService['accountSid'],
workerSid: CaseService['twilioWorkerId'],
{ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser },
{ can, user }: { can: InitializedCan; user: TwilioUser },
): Promise<CaseService> => {
const caseFromDB: CaseRecord = await getById(id, accountSid);
if (!caseFromDB) {
Expand All @@ -279,7 +279,7 @@ export const updateCase = async (
export const getCase = async (
id: number,
accountSid: string,
{ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser },
{ can, user }: { can: InitializedCan; user: TwilioUser },
): Promise<CaseService | undefined> => {
const caseFromDb = await getById(id, accountSid);

Expand Down Expand Up @@ -348,7 +348,7 @@ const generalizedSearchCases =
user,
searchPermissions,
}: {
can: ReturnType<typeof setupCanForRules>;
can: InitializedCan;
user: TwilioUser;
searchPermissions: SearchPermissions;
},
Expand Down Expand Up @@ -412,7 +412,7 @@ export const getCasesByProfileId = async (
profileId: Profile['id'],
query: Pick<PaginationQuery, 'limit' | 'offset'>,
ctx: {
can: ReturnType<typeof setupCanForRules>;
can: InitializedCan;
user: TwilioUser;
searchPermissions: SearchPermissions;
},
Expand Down
20 changes: 10 additions & 10 deletions hrm-domain/hrm-service/src/contact/contactService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { retrieveCategories } from './categories';
import { PaginationQuery, getPaginationElements } from '../search';
import type { NewContactRecord } from './sql/contact-insert-sql';
import { ContactRawJson, ReferralWithoutContactId } from './contact-json';
import { setupCanForRules } from '../permissions/setupCanForRules';
import type { InitializedCan } from '../permissions/initializeCanForRules';
import { actionsMaps } from '../permissions';
import type { TwilioUser } from '@tech-matters/twilio-worker-auth';
import { connectContactToCsamReports, CSAMReport } from '../csam-report/csam-report';
Expand Down Expand Up @@ -203,7 +203,7 @@ const permissionsBasedTransformations: PermissionsBasedTransformation[] = [
];

export const bindApplyTransformations =
(can: ReturnType<typeof setupCanForRules>, user: TwilioUser) =>
(can: InitializedCan, user: TwilioUser) =>
(contact: Contact): WithLegacyCategories<Contact> => {
const permissionsBasedTransformed = permissionsBasedTransformations.reduce(
(transformed, { action, transformation }) =>
Expand All @@ -220,7 +220,7 @@ export const bindApplyTransformations =
export const getContactById = async (
accountSid: string,
contactId: number,
{ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser },
{ can, user }: { can: InitializedCan; user: TwilioUser },
) => {
const contact = await getById(accountSid, contactId);

Expand All @@ -230,7 +230,7 @@ export const getContactById = async (
export const getContactByTaskId = async (
accountSid: string,
taskId: string,
{ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser },
{ can, user }: { can: InitializedCan; user: TwilioUser },
) => {
const contact = await getByTaskSid(accountSid, taskId);

Expand Down Expand Up @@ -309,7 +309,7 @@ export const createContact = async (
createdBy: string,
finalize: boolean,
newContact: CreateContactPayload,
{ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser },
{ can, user }: { can: InitializedCan; user: TwilioUser },
): Promise<WithLegacyCategories<Contact>> => {
for (let retries = 1; retries < 4; retries++) {
try {
Expand Down Expand Up @@ -457,7 +457,7 @@ export const patchContact = async (
finalize: boolean,
contactId: string,
contactPatch: PatchPayload,
{ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser },
{ can, user }: { can: InitializedCan; user: TwilioUser },
): Promise<WithLegacyCategories<Contact>> => {
const { referrals, rawJson, ...restOfPatch } =
adaptLegacyCategories<PatchPayload>(contactPatch);
Expand Down Expand Up @@ -499,7 +499,7 @@ export const connectContactToCase = async (
updatedBy: string,
contactId: string,
caseId: string,
{ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser },
{ can, user }: { can: InitializedCan; user: TwilioUser },
): Promise<WithLegacyCategories<Contact>> => {
const updated: Contact | undefined = await connectToCase(accountSid, contactId, caseId);
if (!updated) {
Expand All @@ -514,7 +514,7 @@ export const addConversationMediaToContact = async (
accountSid: string,
contactId: string,
conversationMediaPayload: ConversationMedia[],
{ can, user }: { can: ReturnType<typeof setupCanForRules>; user: TwilioUser },
{ can, user }: { can: InitializedCan; user: TwilioUser },
): Promise<WithLegacyCategories<Contact>> => {
const contact = await getById(accountSid, parseInt(contactId));
if (!contact) {
Expand Down Expand Up @@ -654,7 +654,7 @@ const generalizedSearchContacts =
user,
searchPermissions,
}: {
can: ReturnType<typeof setupCanForRules>;
can: InitializedCan;
user: TwilioUser;
searchPermissions: SearchPermissions;
},
Expand Down Expand Up @@ -717,7 +717,7 @@ export const getContactsByProfileId = async (
profileId: Profile['id'],
query: Pick<PaginationQuery, 'limit' | 'offset'>,
ctx: {
can: ReturnType<typeof setupCanForRules>;
can: InitializedCan;
user: TwilioUser;
searchPermissions: SearchPermissions;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

import { TwilioUser } from '@tech-matters/twilio-worker-auth';
import { Actions, TargetKind, isValidSetOfActionsForTarget } from './actions';
import { setupCanForRules } from './setupCanForRules';
import { getContactById } from '../contact/contactService';
import { getCase as getCaseById } from '../case/caseService';
import { assertExhaustive } from '../contact-job/assertExhaustive';
Expand All @@ -25,6 +24,7 @@ import {
isS3StoredConversationMedia,
} from '../conversation-media/conversation-media';
import { TResult, newErr, newOk } from '@tech-matters/types';
import type { InitializedCan } from '../permissions/initializeCanForRules';

export const canPerformActionsOnObject = async <T extends TargetKind>({
accountSid,
Expand All @@ -38,7 +38,7 @@ export const canPerformActionsOnObject = async <T extends TargetKind>({
objectId: number;
targetKind: T;
actions: string[];
can: ReturnType<typeof setupCanForRules>;
can: InitializedCan;
user: TwilioUser;
}): Promise<TResult<boolean>> => {
try {
Expand Down
15 changes: 6 additions & 9 deletions hrm-domain/hrm-service/src/permissions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ export { SafeRouter, publicEndpoint } from './safe-router';
export { rulesMap } from './rulesMap';
export { Actions, actionsMaps, getActions } from './actions';

import { setupCanForRules } from './setupCanForRules';
import { InitializedCan, initializeCanForRules } from './initializeCanForRules';
import { RulesFile } from './rulesMap';
import type { Request, Response, NextFunction } from 'express';
import { getSearchPermissions, SearchPermissions } from './search-permissions';

const canCache: Record<string, ReturnType<typeof setupCanForRules>> = {};
const canCache: Record<string, InitializedCan> = {};

export type Permissions = {
rules: (accountSid: string) => RulesFile;
Expand All @@ -36,10 +36,7 @@ export type Permissions = {
* Applies the permissions if valid.
* @throws Will throw if initializedCan is not a function
*/
export const applyPermissions = (
req: Request,
initializedCan: ReturnType<typeof setupCanForRules>,
) => {
export const applyPermissions = (req: Request, initializedCan: InitializedCan) => {
if (typeof initializedCan !== 'function')
throw new Error(`Error in looked up permission rules: can is not a function.`);

Expand All @@ -52,11 +49,11 @@ export const setupPermissions =
const { accountSid } = <any>req;
if (lookup.cachePermissions) {
canCache[accountSid] =
canCache[accountSid] ?? setupCanForRules(lookup.rules(accountSid));
canCache[accountSid] ?? initializeCanForRules(lookup.rules(accountSid));
const initializedCan = canCache[accountSid];
applyPermissions(req, initializedCan);
} else {
applyPermissions(req, setupCanForRules(lookup.rules(accountSid)));
applyPermissions(req, initializeCanForRules(lookup.rules(accountSid)));
}
//@ts-ignore TODO: Improve our custom Request type to override Express.Request
req.searchPermissions = getSearchPermissions(req, lookup.rules(accountSid));
Expand All @@ -65,5 +62,5 @@ export const setupPermissions =

export type RequestWithPermissions = SafeRouterRequest &
SearchPermissions & {
can: ReturnType<typeof setupCanForRules>;
can: InitializedCan;
};
59 changes: 59 additions & 0 deletions hrm-domain/hrm-service/src/permissions/initializeCanForRules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Copyright (C) 2021-2023 Technology Matters
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { actionsMaps, Actions, isTargetKind, type TargetKind } from './actions';
import { parseConditionsSets } from './parser/parser';
import type { TKConditionsSets, RulesFile } from './rulesMap';
import type { TwilioUser } from '@tech-matters/twilio-worker-auth';

// const setupAllow = <T extends TargetKind>(kind: T, conditionsSets: ConditionsSets<T>) => {
const setupAllow = <T extends TargetKind>(
kind: T,
conditionsSets: TKConditionsSets<T>,
) => {
// We could do type validation on target depending on targetKind if we ever want to make sure the "allow" is called on a proper target (same as cancan used to do)
const parsedConditionsSets = parseConditionsSets(kind)(conditionsSets);

return (performer: TwilioUser, target: any) => {
const ctx = { curentTimestamp: new Date() };

// If every condition is true for at least one set, the action is allowed
return parsedConditionsSets.some(
cs => cs.length && cs.every(c => c(performer, target, ctx)),
);
};
};

export const initializeCanForRules = (rules: RulesFile) => {
const actionCheckers = {} as { [action in Actions]: ReturnType<typeof setupAllow> };

const targetKinds = Object.keys(actionsMaps);
targetKinds.forEach((targetKind: string) => {
if (!isTargetKind(targetKind))
throw new Error(`Invalid target kind ${targetKind} found in initializeCanForRules`);

const actionsForTK = Object.values(actionsMaps[targetKind]);
actionsForTK.forEach(action => {
// console.log('action', action, 'targetKind', targetKind, 'rules[action]', rules[action])
actionCheckers[action] = setupAllow(targetKind, rules[action]);
});
});

return (performer: TwilioUser, action: Actions, target: any) =>
actionCheckers[action](performer, target);
};

export type InitializedCan = ReturnType<typeof initializeCanForRules>;
Loading
Loading