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

Add permissions to the invite link #258

Merged
merged 3 commits into from
Apr 23, 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
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# These users will be the default owners for everything in the repo.
# Unless a later match takes precedence, the following users will be
# requested for review when someone opens a pull request.
* @norrisng-bc @TimCsaky @jatindersingh93 @wilwong89 @kyle1morel
* @norrisng-bc @TimCsaky @jatindersingh93
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
16 changes: 8 additions & 8 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,
permCodes: req.body.permCodes
});
res.status(201).json(response.token);
} catch (e) {
Expand Down Expand Up @@ -169,6 +169,8 @@ const controller = {
});
}

// if permCodes in db is `null` then just assign READ
const permCodes = !invite.permCodes ? [Permissions.READ] : invite.permCodes;
if (invite.type === ResourceType.OBJECT) {
// Check for object existence
await objectService.read(invite.resource).catch(() => {
Expand All @@ -181,9 +183,8 @@ const controller = {
});

// Grant invitation permission and cleanup
await objectPermissionService.addPermissions(invite.resource, [
{ userId: userId, permCode: Permissions.READ }
], invite.createdBy);
await objectPermissionService.addPermissions(invite.resource,
permCodes.map(permCode => ({ userId, permCode })), invite.createdBy);
} else if (invite.type === ResourceType.BUCKET) {
// Check for object existence
await bucketService.read(invite.resource).catch(() => {
Expand All @@ -196,9 +197,8 @@ const controller = {
});

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

// Cleanup invite on success
Expand Down
15 changes: 15 additions & 0 deletions app/src/db/migrations/20240305000000_014-invitePermissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
exports.up = function (knex) {
norrisng-bc marked this conversation as resolved.
Show resolved Hide resolved
return Promise.resolve()
// Add permCodes to the table
.then(() => knex.schema.alterTable('invite', table => {
table.jsonb('permCodes');
}));
};

exports.down = function (knex) {
norrisng-bc marked this conversation as resolved.
Show resolved Hide resolved
return Promise.resolve()
// Drop permCodes column from Invite table
.then(() => knex.schema.alterTable('invite', table => {
table.dropColumn('permCodes');
}));
};
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' },
permCodes: { 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
permCodes:
title: Permission Code
type: array
items:
type: string
description: Optional array of permCode. Defaults to 'READ', if unspecified. Accepts any of `"READ", "CREATE", "UPDATE"` for bucket or `"READ", "UPDATE"` for objects
example: ["READ", "CREATE", "UPDATE"]
Request-UpdateBucket:
title: Request Update Bucket
type: object
Expand Down
2 changes: 1 addition & 1 deletion app/src/routes/v1/bucket.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ router.get('/', bucketValidator.searchBuckets, (req, res, next) => {
});

/** Updates a bucket */
router.patch('/:bucketId', express.json(), bucketValidator.updateBucket, hasPermission(Permissions.UPDATE),
router.patch('/:bucketId', express.json(), bucketValidator.updateBucket, hasPermission(Permissions.MANAGE),
(req, res, next) => {
bucketController.updateBucket(req, res, next);
}
Expand Down
4 changes: 3 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,13 @@ 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,
// if permCodes provided set as unique permCodes otherwise just ['READ']
permCodes: data.permCodes ? Array.from(new Set(data.permCodes)) : ['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,
permCodes: Joi.alternatives()
.conditional('bucketId', {
not: false,
then: Joi.array().items(...Object.values(InviteBucketAllowedPermissions)).min(1),
otherwise: Joi.array().items(...Object.values(InviteObjectAllowedPermissions)).min(1)
}),

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

Expand Down
32 changes: 8 additions & 24 deletions app/tests/unit/controllers/invite.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,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 @@ -445,22 +446,16 @@ describe('useInvite', () => {
params: { token: TOKEN }
};

inviteReadSpy.mockResolvedValue({ email: email, resource: RESOURCE, type: ResourceType.OBJECT });
objectReadSpy.mockRejectedValue({});

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

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 +466,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, permCodes: ['READ']
});
objectAddPermissionsSpy.mockResolvedValue({});
objectReadSpy.mockResolvedValue({});
Expand All @@ -485,9 +480,6 @@ describe('useInvite', () => {
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(objectReadSpy).toHaveBeenCalledTimes(1);
expect(objectReadSpy).toHaveBeenCalledWith(RESOURCE);
expect(next).toHaveBeenCalledTimes(0);
Expand All @@ -502,22 +494,18 @@ describe('useInvite', () => {
params: { token: TOKEN }
};

inviteReadSpy.mockResolvedValue({ email: email, resource: RESOURCE, type: ResourceType.BUCKET });
bucketReadSpy.mockRejectedValue({});

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));
});

it('should 200 when bucket grant successful', async () => {
Expand All @@ -528,25 +516,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, permCodes: ['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(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(1);
expect(bucketReadSpy).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(0);
expect(res.json).toHaveBeenCalledWith({ resource: RESOURCE, type: ResourceType.BUCKET });
expect(res.status).toHaveBeenCalledWith(200);
Expand Down
Loading