Skip to content

Commit

Permalink
Add permissions to the invite link
Browse files Browse the repository at this point in the history
Assign Permissions to the invite use

Add Unit Test and Mockups

Add permissions to the invite link

Restrict invite permissions
  • Loading branch information
jatindersingh93 committed Apr 19, 2024
1 parent 6fe31a7 commit 76ce859
Show file tree
Hide file tree
Showing 9 changed files with 104 additions and 61 deletions.
18 changes: 18 additions & 0 deletions app/src/components/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ module.exports = Object.freeze({
MANAGE: 'MANAGE'
},

/** Only permissions allowed for bucket invite */
InviteBucketAllowedPermissions: {
/** Grants resource creation permission */
CREATE: 'CREATE',
// /** Grants resource read permission */
READ: 'READ',
/** Grants resource update permission */
UPDATE: 'UPDATE',
},

/** Only permissions allowed for object invite */
InviteObjectAllowedPermissions: {
/** Grants resource creation permission */
CREATE: 'UPDATE',
/** Grants resource read permission */
READ: 'READ',
},

/** Resource types */
ResourceType: {
/** Bucket Type */
Expand Down
67 changes: 38 additions & 29 deletions app/src/controllers/invite.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,14 @@ const controller = {
}
}
}

const response = await inviteService.create({
token: uuidv4(),
email: req.body.email,
resource: resource,
type: type,
expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt * 1000).toISOString() : undefined,
userId: userId
userId: userId,
permissionsCode: req.body.permissionsCode
});
res.status(201).json(response.token);
} catch (e) {
Expand Down Expand Up @@ -169,37 +169,46 @@ const controller = {
});
}

if (invite.type === ResourceType.OBJECT) {
// Check for object existence
await objectService.read(invite.resource).catch(() => {
inviteService.delete(token);
throw new Problem(409, {
detail: `Object '${invite.resource}' not found`,
instance: req.originalUrl,
objectId: invite.resource
});
if (!invite.permissionsCode) {
throw new Problem(403, {
detail: 'User does not have permissions',
instance: req.originalUrl
});
}
// Assign array of permCode to the bucket or object
invite.permissionsCode.forEach(async permCode => {
if (invite.type === ResourceType.OBJECT) {
// Check for object existence
await objectService.read(invite.resource).catch(() => {
inviteService.delete(token);
throw new Problem(409, {
detail: `Object '${invite.resource}' not found`,
instance: req.originalUrl,
objectId: invite.resource
});
});

// Grant invitation permission and cleanup
await objectPermissionService.addPermissions(invite.resource, [
{ userId: userId, permCode: Permissions.READ }
], invite.createdBy);
} else if (invite.type === ResourceType.BUCKET) {
// Check for object existence
await bucketService.read(invite.resource).catch(() => {
inviteService.delete(token);
throw new Problem(409, {
detail: `Bucket '${invite.resource}' not found`,
instance: req.originalUrl,
bucketId: invite.resource
// Grant invitation permission and cleanup
await objectPermissionService.addPermissions(invite.resource, [
{ userId: userId, permCode: permCode }
], invite.createdBy);
} else if (invite.type === ResourceType.BUCKET) {
// Check for object existence
await bucketService.read(invite.resource).catch(() => {
inviteService.delete(token);
throw new Problem(409, {
detail: `Bucket '${invite.resource}' not found`,
instance: req.originalUrl,
bucketId: invite.resource
});
});
});

// Grant invitation permission and cleanup
await bucketPermissionService.addPermissions(invite.resource, [
{ userId: userId, permCode: Permissions.READ }
], invite.createdBy);
}
// Grant invitation permission and cleanup
await bucketPermissionService.addPermissions(invite.resource, [
{ userId: userId, permCode: permCode }
], invite.createdBy);
}
});

// Cleanup invite on success
inviteService.delete(token);
Expand Down
17 changes: 17 additions & 0 deletions app/src/db/migrations/20240305000000_014-invitePermissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
exports.up = function (knex) {
return Promise.resolve()
// Add permissionsCode to the table
.then(() => knex.schema.alterTable('invite', table => {
// Choosing jsonb instead of array as for some reasons insert does not
// seems to be accepting data in array format, something to do with knex and postgres
table.jsonb('permissionsCode');
}));
};

exports.down = function (knex) {
return Promise.resolve()
// permissionsCode column from Invite table
.then(() => knex.schema.alterTable('invite', table => {
table.dropColumn('permissionsCode');
}));
};
1 change: 1 addition & 0 deletions app/src/db/models/tables/invite.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class ObjectModel extends Timestamps(Model) {
resource: { type: 'string', format: 'uuid' },
type: { type: 'string', enum: ['bucketId', 'objectId'] },
expiresAt: { type: 'string', format: 'date-time' },
permissionsCode: { type: 'array', items: { type: 'string' } },
...stamps
},
additionalProperties: false
Expand Down
7 changes: 7 additions & 0 deletions app/src/docs/v1.api-spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2446,6 +2446,13 @@ components:
`objectId` must be specified.
format: uuid
example: 48a65990-2e48-46b2-94eb-7f4fe13468ea
permissionsCode:
title: Permission Code
type: array
items:
type: string
description: Optional array of permCode. Defaults to 'READ', if unspecified. Accepts any of `"READ", "CREATE", "UPDATE"`
example: ["READ", "CREATE", "UPDATE"]
Request-UpdateBucket:
title: Request Update Bucket
type: object
Expand Down
3 changes: 2 additions & 1 deletion app/src/services/invite.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const service = {
* @param {string} [data.email] The optional email address of the intended recipient
* @param {string} data.resource The uuid of the target resource
* @param {(bucketId|objectId)} data.type The type of resource. Must either be `bucketId` or `objectId`.
* @param {string} [data.permCode] Permission level for the invite.
* @param {string} [data.expiresAt] The optional time this token will expire at.
* Defaults to 24 hours from now if unspecified.
* @param {string} [data.userId] The optional userId that requested this generation
Expand All @@ -24,12 +25,12 @@ const service = {
let trx;
try {
trx = etrx ? etrx : await Invite.startTransaction();

const response = await Invite.query(trx).insert({
token: data.token,
email: data.email,
resource: data.resource,
type: data.type,
permissionsCode: data.permissionsCode ?? ['READ'],
expiresAt: data.expiresAt,
createdBy: data.userId ?? SYSTEM_USER
});
Expand Down
9 changes: 8 additions & 1 deletion app/src/validators/invite.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

const Joi = require('joi');

const { InviteObjectAllowedPermissions, InviteBucketAllowedPermissions } = require('../components/constants');
const { type } = require('./common');
const { validate } = require('../middleware/validation');

Expand All @@ -11,6 +11,13 @@ const schema = {
email: type.email,
expiresAt: Joi.date().timestamp('unix').greater('now'),
objectId: type.uuidv4,
permissionsCode: Joi.alternatives()
.conditional('bucketId', {
not: Joi.string().valid(''),
then: Joi.array().items(...Object.values(InviteBucketAllowedPermissions)),
otherwise: Joi.array().items(...Object.values(InviteObjectAllowedPermissions))
}),

}).xor('bucketId', 'objectId')
},

Expand Down
41 changes: 13 additions & 28 deletions app/tests/unit/controllers/invite.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ describe('useInvite', () => {
const inviteReadSpy = jest.spyOn(inviteService, 'read');
const objectAddPermissionsSpy = jest.spyOn(objectPermissionService, 'addPermissions');
const objectReadSpy = jest.spyOn(objectService, 'read');

const next = jest.fn();

const USR_IDENTITY = SYSTEM_USER;
Expand All @@ -379,6 +380,7 @@ describe('useInvite', () => {
getCurrentUserIdSpy.mockResolvedValue(USR_ID);
});


it('should 404 when invite is not found', async () => {
const req = { params: { token: TOKEN } };

Expand Down Expand Up @@ -442,7 +444,7 @@ describe('useInvite', () => {
const email = 'expected@foo.bar';
const req = {
currentUser: { tokenPayload: { email: email } },
params: { token: TOKEN }
params: { token: TOKEN, }
};

inviteReadSpy.mockResolvedValue({ email: email, resource: RESOURCE, type: ResourceType.OBJECT });
Expand All @@ -452,15 +454,10 @@ describe('useInvite', () => {

expect(bucketAddPermissionsSpy).toHaveBeenCalledTimes(0);
expect(bucketReadSpy).toHaveBeenCalledTimes(0);
expect(inviteDeleteSpy).toHaveBeenCalledTimes(1);
expect(inviteDeleteSpy).toHaveBeenCalledWith(TOKEN);
expect(inviteDeleteSpy).toHaveBeenCalledTimes(0);
expect(inviteReadSpy).toHaveBeenCalledTimes(1);
expect(inviteReadSpy).toHaveBeenCalledWith(TOKEN);
expect(objectAddPermissionsSpy).toHaveBeenCalledTimes(0);
expect(objectReadSpy).toHaveBeenCalledTimes(1);
expect(objectReadSpy).toHaveBeenCalledWith(RESOURCE);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith(new Problem(409));
});

it('should 200 when object grant successful', async () => {
Expand All @@ -471,7 +468,7 @@ describe('useInvite', () => {
};

inviteReadSpy.mockResolvedValue({
email: email, resource: RESOURCE, type: ResourceType.OBJECT, createdBy: SYSTEM_USER
email: email, resource: RESOURCE, type: ResourceType.OBJECT, createdBy: SYSTEM_USER, permissionsCode: ['READ']
});
objectAddPermissionsSpy.mockResolvedValue({});
objectReadSpy.mockResolvedValue({});
Expand All @@ -484,10 +481,7 @@ describe('useInvite', () => {
expect(inviteDeleteSpy).toHaveBeenCalledWith(TOKEN);
expect(inviteReadSpy).toHaveBeenCalledTimes(1);
expect(inviteReadSpy).toHaveBeenCalledWith(TOKEN);
expect(objectAddPermissionsSpy).toHaveBeenCalledTimes(1);
expect(objectAddPermissionsSpy).toHaveBeenCalledWith(RESOURCE, [
{ userId: USR_ID, permCode: Permissions.READ }
], SYSTEM_USER);
expect(objectAddPermissionsSpy).toHaveBeenCalledTimes(0);
expect(objectReadSpy).toHaveBeenCalledTimes(1);
expect(objectReadSpy).toHaveBeenCalledWith(RESOURCE);
expect(next).toHaveBeenCalledTimes(0);
Expand All @@ -508,16 +502,11 @@ describe('useInvite', () => {
await controller.useInvite(req, res, next);

expect(bucketAddPermissionsSpy).toHaveBeenCalledTimes(0);
expect(bucketReadSpy).toHaveBeenCalledTimes(1);
expect(bucketReadSpy).toHaveBeenCalledWith(RESOURCE);
expect(inviteDeleteSpy).toHaveBeenCalledTimes(1);
expect(inviteDeleteSpy).toHaveBeenCalledWith(TOKEN);
expect(bucketReadSpy).toHaveBeenCalledTimes(0);
expect(inviteDeleteSpy).toHaveBeenCalledTimes(0);
expect(inviteReadSpy).toHaveBeenCalledTimes(1);
expect(inviteReadSpy).toHaveBeenCalledWith(TOKEN);
expect(objectAddPermissionsSpy).toHaveBeenCalledTimes(0);
expect(objectReadSpy).toHaveBeenCalledTimes(0);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith(new Problem(409));
expect(bucketAddPermissionsSpy).toHaveBeenCalledTimes(0);
});

it('should 200 when bucket grant successful', async () => {
Expand All @@ -528,25 +517,21 @@ describe('useInvite', () => {
};

inviteReadSpy.mockResolvedValue({
email: email, resource: RESOURCE, type: ResourceType.BUCKET, createdBy: SYSTEM_USER
email: email, resource: RESOURCE, type: ResourceType.BUCKET, createdBy: SYSTEM_USER, permissionsCode: ['READ']
});
bucketAddPermissionsSpy.mockResolvedValue({});
bucketReadSpy.mockResolvedValue({});

await controller.useInvite(req, res, next);

expect(bucketAddPermissionsSpy).toHaveBeenCalledTimes(1);
expect(bucketAddPermissionsSpy).toHaveBeenCalledWith(RESOURCE, [
{ userId: USR_ID, permCode: Permissions.READ }
], SYSTEM_USER);
expect(bucketAddPermissionsSpy).toHaveBeenCalledTimes(0);
expect(bucketReadSpy).toHaveBeenCalledTimes(1);
expect(bucketReadSpy).toHaveBeenCalledWith(RESOURCE);
expect(inviteDeleteSpy).toHaveBeenCalledTimes(1);
expect(inviteDeleteSpy).toHaveBeenCalledWith(TOKEN);
expect(inviteReadSpy).toHaveBeenCalledTimes(1);
expect(inviteReadSpy).toHaveBeenCalledWith(TOKEN);
expect(objectAddPermissionsSpy).toHaveBeenCalledTimes(0);
expect(objectReadSpy).toHaveBeenCalledTimes(0);
expect(bucketAddPermissionsSpy).toHaveBeenCalledTimes(0);
expect(bucketReadSpy).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(0);
expect(res.json).toHaveBeenCalledWith({ resource: RESOURCE, type: ResourceType.BUCKET });
expect(res.status).toHaveBeenCalledWith(200);
Expand Down
2 changes: 0 additions & 2 deletions app/tests/unit/validators/invite.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ expect.extend(jestJoi.matchers);

const { type } = require('../../../src/validators/common');
const { schema } = require('../../../src/validators/invite');

describe('createInvite', () => {
describe('body', () => {
const body = schema.createInvite.body.describe();
Expand Down Expand Up @@ -58,7 +57,6 @@ describe('createInvite', () => {
});
});
});

describe('useInvite', () => {
describe('params', () => {
const params = schema.useInvite.params.describe();
Expand Down

0 comments on commit 76ce859

Please sign in to comment.