Skip to content

Commit 35dbdeb

Browse files
authored
Merge pull request #309 from bcgov/feature/cascade-permission-changes
Feature: optionally cascade add/remove bucket permissions operations
2 parents 1983271 + 40fb4cd commit 35dbdeb

File tree

10 files changed

+179
-37
lines changed

10 files changed

+179
-37
lines changed

app/src/controllers/bucketPermission.js

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const errorToProblem = require('../components/errorToProblem');
2+
const utils = require('../db/models/utils');
23
const {
34
addDashesToUuid,
45
mixedQueryToArray,
@@ -7,14 +8,15 @@ const {
78
isTruthy
89
} = require('../components/utils');
910
const { NIL: SYSTEM_USER } = require('uuid');
10-
const { bucketPermissionService, userService } = require('../services');
11+
const { bucketPermissionService, userService, bucketService } = require('../services');
1112

1213
const SERVICE = 'BucketPermissionService';
1314

1415
/**
1516
* The Permission Controller
1617
*/
1718
const controller = {
19+
1820
/**
1921
* @function searchPermissions
2022
* Searches for bucket permissions
@@ -89,11 +91,36 @@ const controller = {
8991
*/
9092
async addPermissions(req, res, next) {
9193
try {
92-
const userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, SYSTEM_USER));
93-
const response = await bucketPermissionService.addPermissions(
94-
addDashesToUuid(req.params.bucketId),req.body, userId
95-
);
96-
res.status(201).json(response);
94+
const currUserId = await userService.getCurrentUserId(
95+
getCurrentIdentity(req.currentUser, SYSTEM_USER), SYSTEM_USER);
96+
const currBucketId = addDashesToUuid(req.params.bucketId);
97+
98+
if (isTruthy(req.query.recursive)) {
99+
100+
const parentBucket = await bucketService.read(currBucketId);
101+
102+
// Only apply permissions to child buckets that currentUser can MANAGE
103+
// If the current user is SYSTEM_USER, apply permissions to all child buckets
104+
const childBuckets = currUserId !== SYSTEM_USER ?
105+
await bucketService.getChildrenWithManagePermissions(currBucketId, currUserId) :
106+
await bucketService.searchChildBuckets(parentBucket, true, currUserId);
107+
108+
const allBuckets = [parentBucket, ...childBuckets];
109+
110+
const responses = await utils.trxWrapper(async (trx) => {
111+
return await Promise.all(
112+
allBuckets.map(b =>
113+
bucketPermissionService.addPermissions(b.bucketId, req.body, currUserId, trx)
114+
)
115+
);
116+
});
117+
res.status(201).json(responses.flat());
118+
}
119+
else {
120+
const response = await bucketPermissionService.addPermissions(
121+
currBucketId, req.body, currUserId);
122+
res.status(201).json(response);
123+
}
97124
} catch (e) {
98125
next(errorToProblem(SERVICE, e));
99126
}
@@ -112,14 +139,41 @@ const controller = {
112139
const userArray = mixedQueryToArray(req.query.userId);
113140
const userIds = userArray ? userArray.map(id => addDashesToUuid(id)) : userArray;
114141
const permissions = mixedQueryToArray(req.query.permCode);
115-
const response = await bucketPermissionService.removePermissions(req.params.bucketId, userIds, permissions);
116-
res.status(200).json(response);
142+
143+
const currUserId = await userService.getCurrentUserId(
144+
getCurrentIdentity(req.currentUser, SYSTEM_USER), SYSTEM_USER);
145+
const currBucketId = addDashesToUuid(req.params.bucketId);
146+
147+
if (isTruthy(req.query.recursive)) {
148+
149+
const parentBucket = await bucketService.read(currBucketId);
150+
// Only apply permissions to child buckets that currentUser can MANAGE
151+
// If the current user is SYSTEM_USER, apply permissions to all child buckets
152+
const childBuckets = currUserId !== SYSTEM_USER ?
153+
await bucketService.getChildrenWithManagePermissions(currBucketId, currUserId) :
154+
await bucketService.searchChildBuckets(parentBucket, true, currUserId);
155+
156+
const allBuckets = [parentBucket, ...childBuckets];
157+
158+
const responses = await utils.trxWrapper(async (trx) => {
159+
return await Promise.all(
160+
allBuckets.map(b =>
161+
bucketPermissionService.removePermissions(b.bucketId, userIds, permissions, trx)
162+
)
163+
);
164+
});
165+
res.status(200).json(responses.flat());
166+
}
167+
else {
168+
const response = await bucketPermissionService.removePermissions(currBucketId, userIds, permissions);
169+
res.status(200).json(response);
170+
}
171+
117172
} catch (e) {
118173
next(errorToProblem(SERVICE, e));
119174
}
120175
},
121176

122-
123177
};
124178

125179
module.exports = controller;

app/src/controllers/invite.js

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ const { v4: uuidv4, NIL: SYSTEM_USER } = require('uuid');
33

44
const { AuthType, Permissions, ResourceType } = require('../components/constants');
55
const errorToProblem = require('../components/errorToProblem');
6-
const { addDashesToUuid, getCurrentIdentity } = require('../components/utils');
6+
const { addDashesToUuid, getCurrentIdentity, isTruthy } = require('../components/utils');
7+
const utils = require('../db/models/utils');
78
const {
89
bucketPermissionService,
910
bucketService,
@@ -29,7 +30,6 @@ const controller = {
2930
*/
3031
async createInvite(req, res, next) {
3132
let resource, type;
32-
3333
try {
3434
// Reject if expiresAt is more than 30 days away
3535
const maxExpiresAt = Math.floor(Date.now() / 1000) + 2592000;
@@ -120,12 +120,13 @@ const controller = {
120120
type: type,
121121
expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt * 1000).toISOString() : undefined,
122122
userId: userId,
123-
permCodes: req.body.permCodes
123+
permCodes: req.body.permCodes,
124+
recursive: (req.body.bucketId && isTruthy(req.body.recursive)) ?? false
124125
});
125126
res.status(201).json(response.token);
126127
} catch (e) {
127128
if (e.statusCode === 404) {
128-
next(errorToProblem(SERVICE, new Problem(409, {
129+
next(errorToProblem(SERVICE, new Problem(404, {
129130
detail: `Resource type '${type}' not found`,
130131
instance: req.originalUrl,
131132
resource: resource
@@ -186,7 +187,7 @@ const controller = {
186187
await objectPermissionService.addPermissions(invite.resource,
187188
permCodes.map(permCode => ({ userId, permCode })), invite.createdBy);
188189
} else if (invite.type === ResourceType.BUCKET) {
189-
// Check for object existence
190+
// Check for bucket existence
190191
await bucketService.read(invite.resource).catch(() => {
191192
inviteService.delete(token);
192193
throw new Problem(409, {
@@ -196,14 +197,37 @@ const controller = {
196197
});
197198
});
198199

199-
// Grant invitation permission and cleanup
200-
await bucketPermissionService.addPermissions(invite.resource,
201-
permCodes.map(permCode => ({ userId, permCode })), invite.createdBy);
200+
// Grant invitation permission
201+
// if invite was for specified folder AND all subfolders
202+
if (invite.recursive) {
203+
const parentBucket = await bucketService.read(invite.resource);
204+
// Only apply permissions to child buckets that inviter can MANAGE
205+
// If the invite was created via basic auth, apply permissions to all child buckets
206+
const childBuckets = invite.createdBy !== SYSTEM_USER ?
207+
await bucketService.getChildrenWithManagePermissions(invite.resource, invite.createdBy) :
208+
await bucketService.searchChildBuckets(parentBucket, true, invite.createdBy);
209+
210+
const allBuckets = [parentBucket, ...childBuckets];
211+
212+
await utils.trxWrapper(async (trx) => {
213+
return await Promise.all(
214+
allBuckets.map(b =>
215+
bucketPermissionService.addPermissions(b.bucketId, permCodes.map(
216+
permCode => ({ userId, permCode })), invite.createdBy, trx)
217+
)
218+
);
219+
});
220+
}
221+
// else not recursive
222+
else {
223+
await bucketPermissionService.addPermissions(invite.resource,
224+
permCodes.map(permCode => ({ userId, permCode })), invite.createdBy);
225+
}
202226
}
203227

204228
// Cleanup invite on success
205229
inviteService.delete(token);
206-
res.status(200).json({ resource: invite.resource, type: invite.type });
230+
res.status(200).json({ resource: invite.resource, type: invite.type, recursive: invite.recursive });
207231
} catch (e) {
208232
if (e.statusCode === 404) {
209233
next(errorToProblem(SERVICE, new Problem(404, {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @param { import("knex").Knex } knex
3+
* @returns { Promise<void> }
4+
*/
5+
exports.up = function (knex) {
6+
return Promise.resolve()
7+
// add recursive column to invite table
8+
.then(() => knex.schema.alterTable('invite', table => {
9+
table.boolean('recursive').notNullable().defaultTo(false);
10+
}));
11+
};
12+
13+
/**
14+
* @param { import("knex").Knex } knex
15+
* @returns { Promise<void> }
16+
*/
17+
exports.down = function (knex) {
18+
return Promise.resolve()
19+
// drop recursive column from invite table
20+
.then(() => knex.schema.alterTable('invite', table => {
21+
table.dropColumn('recursive');
22+
}));
23+
};

app/src/db/models/tables/invite.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class ObjectModel extends Timestamps(Model) {
2323
type: { type: 'string', enum: ['bucketId', 'objectId'] },
2424
expiresAt: { type: 'string', format: 'date-time' },
2525
permCodes: { type: 'array', items: { type: 'string' } },
26+
recursive: { type: 'boolean' },
2627
...stamps
2728
},
2829
additionalProperties: false

app/src/docs/v1.api-spec.yaml

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,10 @@ paths:
199199
summary: Deletes a bucket
200200
description: >-
201201
Deletes the bucket record (and child objects) from the COMS database, based on bucketId.
202-
When calling this endpoint using OIDC token authentication, the user requires the DELETE
202+
When calling this endpoint using OIDC token authentication, the user requires the `DELETE`
203203
permission for the bucket.
204-
Providing the 'recursive' parameter will also delete all sub-folders.
205-
The recursive option also requires the OIDC user to have the DELETE permission
204+
Providing the `recursive` parameter will also delete all sub-folders.
205+
The `recursive` option also requires the OIDC user to have the `DELETE` permission
206206
on all of these sub-folders otherwise the entire operation will fail.
207207
This request does not dispatch an S3 operation to request the deletion of the associated
208208
bucket(s) or delete any objects in object storage, it simply 'de-registers' knowledge of the bucket/folder
@@ -1137,12 +1137,15 @@ paths:
11371137
arbitrary array of permCode and user tuples. This is an idempotent
11381138
operation, so users that already have a requested permission will remain
11391139
unaffected. Only permissions successfully added to COMS will appear in
1140-
the response.
1140+
the response. The `recursive` option will also apply the specified
1141+
permissions to all child buckets where the current user has `MANAGE`
1142+
permissions.
11411143
operationId: bucketAddPermissions
11421144
tags:
11431145
- Permission
11441146
parameters:
11451147
- $ref: "#/components/parameters/Path-BucketId"
1148+
- $ref: "#/components/parameters/Query-Recursive"
11461149
requestBody:
11471150
description: >-
11481151
An array of bucket permissions, each containing a `userId` and
@@ -1175,16 +1178,18 @@ paths:
11751178
of users and subset of permissions to revoke. This is an idempotent
11761179
operation, so users that already lack the specified permission(s) will
11771180
remain unaffected. Only permissions successfully removed from COMS will
1178-
appear in the response. WARNING: Specifying no parameters will delete
1179-
all permissions associated with a bucket; it is possible to lock
1180-
yourself out of your own bucket!
1181+
appear in the response. The `recursive` option will also remove the specified
1182+
permissions from all child buckets where the current user has `MANAGE`
1183+
permissions. WARNING: Specifying no parameters will delete all permissions
1184+
associated with a bucket; it is possible to lock yourself out of your own bucket!
11811185
operationId: bucketRemovePermissions
11821186
tags:
11831187
- Permission
11841188
parameters:
11851189
- $ref: "#/components/parameters/Path-BucketId"
11861190
- $ref: "#/components/parameters/Query-UserId"
11871191
- $ref: "#/components/parameters/Query-PermCode"
1192+
- $ref: "#/components/parameters/Query-Recursive"
11881193
responses:
11891194
"200":
11901195
description: Returns an array of deleted permissions
@@ -2583,6 +2588,11 @@ components:
25832588
`objectId` must be specified.
25842589
format: uuid
25852590
example: ac246e31-c807-496c-bc93-cd8bc2f1b2b4
2591+
recursive:
2592+
type: boolean
2593+
description: >-
2594+
Optionally apply permCodes to all sub-folders in the spcified bucket
2595+
example: true
25862596
email:
25872597
type: string
25882598
description: >-
@@ -2609,7 +2619,7 @@ components:
26092619
type: array
26102620
items:
26112621
type: string
2612-
description: Optional array of permCode. Defaults to 'READ', if unspecified. Accepts any of `"READ", "CREATE", "UPDATE"` for bucket or `"READ", "UPDATE"` for objects
2622+
description: Optional array of permCode. Defaults to `READ`, if unspecified. Accepts any of `"READ", "CREATE", "UPDATE"` for bucket or `"READ", "UPDATE"` for objects
26132623
example: ["READ", "CREATE", "UPDATE"]
26142624
Request-UpdateBucket:
26152625
title: Request Update Bucket

app/src/services/bucket.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const { v4: uuidv4, NIL: SYSTEM_USER } = require('uuid');
22

33
const bucketPermissionService = require('./bucketPermission');
44
const { Bucket } = require('../db/models');
5+
const { Permissions } = require('../components/constants');
56

67
/**
78
* The Bucket DB Service
@@ -173,6 +174,25 @@ const service = {
173174
}
174175
},
175176

177+
/**
178+
* Gets all child bucket records for a given bucket, where the specified user
179+
* has MANAGE permission on said child buckets.
180+
* @param {string} parentBucketId bucket id of the parent bucket
181+
* @param {string} userId user id
182+
* @param {object} [etrx=undefined] An optional Objection Transaction object
183+
* @returns {Promise<object[]>} An array of bucket records that are children of the parent,
184+
* where the user has MANAGE permissions.
185+
*/
186+
getChildrenWithManagePermissions: async (parentBucketId, userId, etrx = undefined) => {
187+
const parentBucket = await service.read(parentBucketId);
188+
const allChildren = await service.searchChildBuckets(parentBucket, true, userId, etrx);
189+
190+
const filteredChildren = allChildren.filter(bucket =>
191+
bucket.bucketPermission?.some(perm => perm.userId === userId && perm.permCode === Permissions.MANAGE)
192+
);
193+
return filteredChildren;
194+
},
195+
176196
/**
177197
* @function searchBuckets
178198
* Search and filter for specific bucket records
@@ -199,11 +219,13 @@ const service = {
199219

200220
/**
201221
* @function searchChildBuckets
202-
* Get db records for each bucket that acts as a sub-folder of the provided bucket
222+
* Get db records for each bucket that acts as a sub-folder of the provided bucket,
223+
* and is accessible to a given user
203224
* @param {object} parentBucket a bucket model (record) from the COMS db
204225
* @param {boolean} returnPermissions also return current user's permissions for each bucket
226+
* @param {string} userId uuid of the user
205227
* @param {object} [etrx=undefined] An optional Objection Transaction object
206-
* @returns {Promise<object[]>} An array of bucket records
228+
* @returns {Promise<object[]>} An array of bucket records that the given user can access
207229
* @throws If there are no records found
208230
*/
209231
searchChildBuckets: async (parentBucket, returnPermissions = false, userId, etrx = undefined) => {
@@ -215,9 +237,14 @@ const service = {
215237
if (returnPermissions) {
216238
query
217239
.withGraphJoined('bucketPermission')
218-
.whereIn('bucketPermission.bucketId', builder => {
219-
builder.distinct('bucketPermission.bucketId')
220-
.where('bucketPermission.userId', userId);
240+
.modify(query => {
241+
if (userId !== SYSTEM_USER) {
242+
query
243+
.whereIn('bucketPermission.bucketId', builder => {
244+
builder.distinct('bucketPermission.bucketId')
245+
.where('bucketPermission.userId', userId);
246+
});
247+
}
221248
});
222249
}
223250
})

app/src/services/invite.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ const service = {
3333
// if permCodes provided set as unique permCodes otherwise just ['READ']
3434
permCodes: data.permCodes ? Array.from(new Set(data.permCodes)) : ['READ'],
3535
expiresAt: data.expiresAt,
36-
createdBy: data.userId ?? SYSTEM_USER
36+
createdBy: data.userId ?? SYSTEM_USER,
37+
recursive: data.recursive ?? false
3738
});
3839

3940
if (!etrx) await trx.commit();

app/src/validators/bucketPermission.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const schema = {
4545
}).required()
4646
).required(),
4747
query: Joi.object({
48+
recursive: type.truthy,
4849
})
4950
},
5051

@@ -55,6 +56,7 @@ const schema = {
5556
query: Joi.object({
5657
userId: scheme.guid,
5758
permCode: scheme.permCode,
59+
recursive: type.truthy
5860
})
5961
}
6062
};

0 commit comments

Comments
 (0)