Skip to content

Commit 412f104

Browse files
committed
Manage legacy public ACL's
Sync Bucket and Object public status
1 parent 13ccaa1 commit 412f104

File tree

4 files changed

+133
-85
lines changed

4 files changed

+133
-85
lines changed

app/src/components/utils.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ const utils = {
496496

497497
/**
498498
* @function stripDelimit
499-
* Yields a string `s` that will never have a trailing delimiter. Returns an empty string if falsy.
499+
* Yields a string `s` that will never have a trailing delimiter.
500500
* @param {string} s The input string
501501
* @returns {string} The string `s` without the trailing delimiter, or an empty string.
502502
*/
@@ -505,6 +505,23 @@ const utils = {
505505
else return '';
506506
},
507507

508+
/**
509+
* @function trimResourcePath
510+
* Yields a string `s` without trailing delimiters or asterixes.
511+
* @param {string} s The input string
512+
* @returns {string} The string `s` without trailing delimiters or asterix, or an empty string.
513+
*/
514+
trimResourcePath(s) {
515+
switch (true) {
516+
case s.endsWith(DELIMITER):
517+
return utils.stripDelimit(s.slice(0, -1));
518+
case s.endsWith(DELIMITER + '*'):
519+
return utils.stripDelimit(s.slice(0, -2));
520+
default:
521+
return s;
522+
}
523+
},
524+
508525
/**
509526
* @function toLowerKeys
510527
* Converts all key names for all objects in an array to lowercase

app/src/controllers/bucket.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ const controller = {
361361
};
362362
// Update S3 Policy
363363
await storageService.updatePublic(data).catch((e) => {
364-
log.warn('Failed to apply permission changes to S3' + e, { function: 'togglePublic', ...data });
364+
log.warn('Failed to apply permission changes to S3 ' + e, { function: 'togglePublic', ...data });
365365
});
366366

367367
// Child bucket cannot be non-public when parent is public

app/src/services/storage.js

Lines changed: 112 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const {
22
CopyObjectCommand,
3+
DeleteBucketPolicyCommand,
34
DeleteObjectCommand,
45
DeleteObjectTaggingCommand,
56
GetBucketEncryptionCommand,
@@ -14,7 +15,6 @@ const {
1415
PutObjectAclCommand,
1516
GetBucketPolicyCommand,
1617
PutBucketPolicyCommand,
17-
DeleteBucketPolicyCommand,
1818
PutObjectCommand,
1919
PutBucketEncryptionCommand,
2020
PutObjectTaggingCommand,
@@ -360,7 +360,7 @@ const objectStorageService = {
360360
Bucket: data.bucket,
361361
ContinuationToken: continuationToken,
362362
MaxKeys: maxKeys,
363-
Prefix: filePath ?? prefix // Must filter via "prefix" - https://stackoverflow.com/a/56569856
363+
Prefix: filePath ?? prefix // Must filter via 'prefix' - https://stackoverflow.com/a/56569856
364364
};
365365

366366
return this._getS3Client(data).send(new ListObjectsV2Command(params));
@@ -384,7 +384,7 @@ const objectStorageService = {
384384
Bucket: data.bucket,
385385
KeyMarker: keyMarker,
386386
MaxKeys: maxKeys,
387-
Prefix: filePath ?? prefix // Must filter via "prefix" - https://stackoverflow.com/a/56569856
387+
Prefix: filePath ?? prefix // Must filter via 'prefix' - https://stackoverflow.com/a/56569856
388388
};
389389

390390
return this._getS3Client(data).send(new ListObjectVersionsCommand(params));
@@ -476,105 +476,127 @@ const objectStorageService = {
476476

477477
/**
478478
* removes public ACL, where ACL grants ALLUSERS READ permission,
479-
* for an object
480-
* will also remove this type of ACL set by other applications
479+
* for all objects at or below a given path
480+
* note: will also remove this type of ACL set by other applications
481481
*/
482-
async removePublicACL(data, key) {
483-
const existingAcl = await this._getS3Client(data).send(new GetObjectAclCommand({
484-
Bucket: data.bucket,
485-
Key: key
486-
}));
487-
const existingGrants = existingAcl.Grants;
488-
const hasPublicAcl = existingGrants.some(grant => grant.Grantee?.URI === ALLUSERS && grant.Permission === 'READ');
489-
if (hasPublicAcl) {
490-
await this._getS3Client(data).send(new PutObjectAclCommand({
491-
AccessControlPolicy: {
492-
Grants: existingGrants
493-
.filter(grant => grant.Grantee?.URI !== ALLUSERS && grant.Permission !== 'READ')
494-
.map(grant => {
495-
return {
496-
Grantee: grant.Grantee,
497-
Permission: grant.Permission,
498-
};
499-
}),
500-
Owner: existingAcl.Owner
501-
},
502-
Bucket: data.bucket,
503-
Key: key,
504-
}));
505-
return true;
482+
async removePublicACL(bucketId, path, recursive = false) {
483+
try {
484+
const data = await utils.getBucket(bucketId);
485+
const key = utils.stripDelimit(path);
486+
let objects = [];
487+
488+
// list all objects at and below provided path (key)
489+
if (recursive) {
490+
const s3Response = await this.listAllObjectVersions({ filePath: key, bucketId: bucketId, precisePath: false });
491+
log.info(`Found ${s3Response.Versions.length} object versions and ${s3Response.DeleteMarkers.length}
492+
delete markers in S3 for bucketId ${data.bucketId}`, { function: 'syncBucketRecursive' });
493+
const s3Keys = [...new Set([
494+
...s3Response.DeleteMarkers.map(object => object.Key),
495+
...s3Response.Versions.map(object => object.Key),
496+
])];
497+
objects = s3Keys;
498+
} else {
499+
objects = [key];
500+
}
501+
log.info(`Removing ACL's from ${objects.length} objects`, { function: 'removePublicACL' });
502+
503+
// for each object update existing ACL to remove READ on ALLUSERS
504+
for (const objKey of objects.slice(0, 1000)) { // limit to 1000 to avoid timeouts
505+
const existingAcl = await this._getS3Client(data).send(new GetObjectAclCommand({
506+
Bucket: data.bucket,
507+
Key: objKey
508+
}));
509+
const existingGrants = existingAcl.Grants;
510+
const hasPublicAcl = existingGrants.some(
511+
grant => grant.Grantee?.URI === ALLUSERS && grant.Permission === 'READ'
512+
);
513+
if (hasPublicAcl) {
514+
await this._getS3Client(data).send(new PutObjectAclCommand({
515+
AccessControlPolicy: {
516+
Grants: existingGrants
517+
.filter(grant => grant.Grantee?.URI !== ALLUSERS && grant.Permission !== 'READ')
518+
.map(grant => {
519+
return {
520+
Grantee: grant.Grantee,
521+
Permission: grant.Permission,
522+
};
523+
}),
524+
Owner: existingAcl.Owner
525+
},
526+
Bucket: data.bucket,
527+
Key: objKey,
528+
}));
529+
}
530+
}
531+
} catch (error) {
532+
log.error('Error in removePublicACL:', error);
533+
throw error;
506534
}
507535
},
508536

509537
/**
510538
* @function updatePublic
511539
* Updates the S3 Bucket Policies for the given resource
540+
* any non-COMS Bucket Policies (Sid starts with `coms::`) are preserved
541+
* NOTE: This function will remove legacy ACL's for all resources at or below path when setting to private
542+
* to ensure objects are not inadvertently left public due to ACL settings
512543
* @param {string} path the path of the resource
513544
* @param {boolean} whether given resource should be set to public
514545
* @param {string} bucketId of COMS bucket for the resource
515546
* @returns {Promise<object>} The S3 PutBucketPolicyCommand response
516547
*/
517-
async updatePublic({ path, public: publicFlag = false, bucketId }) {
548+
async updatePublic({ path, public: setAsPublic = false, bucketId }) {
518549
const data = await utils.getBucket(bucketId);
519550
const isPrefix = data.key + '/' === path;
520551
const resource = data.bucket + '/' + path;
521-
let existingStatement = [];
552+
let existingPolicies = [];
522553
let s3Response = [];
523554

524555
// Get existing policy
525556
try {
526557
const existingPolicy = await this._getS3Client(data).send(new GetBucketPolicyCommand({ Bucket: data.bucket }));
527-
existingStatement = JSON.parse(existingPolicy.Policy).Statement;
558+
existingPolicies = JSON.parse(existingPolicy.Policy).Statement;
559+
// If COMS policy found for any parent prefix, throw error, exceptions from inherited policies are not permitted
560+
const parentPolicy = existingPolicies.find(policy =>
561+
`coms::${resource}`.startsWith(policy.Sid) &&
562+
`coms::${resource}` !== policy.Sid);
563+
if (parentPolicy) {
564+
throw new Error(`Unable to override Public status set on folder: ${parentPolicy.Resource}`);
565+
}
528566
} catch (e) {
529567
log.debug('No existing policy found', { function: 'updatePublic' });
530568
}
531569

532-
// If COMS policy found for any parent prefix, throw error. no exceptions to inherited policies permitted
533-
const parentPolicy = existingStatement.find(policy =>
534-
`coms::${resource}`.startsWith(policy.Sid) && `coms::${resource}` !== policy.Sid);
535-
if (parentPolicy) {
536-
throw new Error(`Unable to override Public status set on folder: ${parentPolicy.Resource}`);
537-
}
538-
539570
// --- Update policy
540-
// if making public add Allow rule for this resource
541-
if (publicFlag) {
542-
543-
// include any existing non-coms-managed rules for this resource or below
544-
const newStatement = existingStatement
545-
.filter(policy => !policy.Sid.startsWith(`coms::${resource}`));
546-
571+
// keep non-coms and sibling policies
572+
let newPolicies = existingPolicies.filter(policy => {
573+
return !policy.Sid.startsWith(utils.trimResourcePath(`coms::${resource}`));
574+
});
575+
// if making resource private and no other policies remain, remove entire policy
576+
if (newPolicies.length === 0 && !setAsPublic) {
577+
await this._getS3Client(data).send(new DeleteBucketPolicyCommand({ Bucket: data.bucket }));
578+
}
579+
// if making public add Allow rule for this resource (and below)
580+
if (setAsPublic) {
547581
const resourceKey = isPrefix ? resource + '*' : resource; // prefixes/need/trailing/wildcard/*
548-
newStatement
582+
newPolicies
549583
.push({
550584
Action: 's3:GetObject',
551585
Resource: resourceKey,
552586
Effect: 'Allow',
553587
Principal: '*',
554588
Sid: 'coms::' + resource,
555589
});
556-
557-
// put new policy
558-
if (newStatement.length > 0) {
559-
s3Response = await this._getS3Client(data).send(new PutBucketPolicyCommand({
560-
Bucket: data.bucket,
561-
Policy: JSON.stringify({ Version: '2012-10-17', Statement: newStatement })
562-
}));
563-
}
564-
}
565-
566-
// else setting to private
567-
// note: logic is confusing. But here, if setting to private, we are only deleting a COMS policy
568-
else if (existingStatement.length > 0) {
569-
await this._getS3Client(data).send(new DeleteBucketPolicyCommand({ Bucket: data.bucket }));
570-
s3Response = [];
571590
}
572-
573-
// If updating a single file,
574-
// Remove public ACL for file if present (phase out previous implementation)
575-
if (isPrefix === false) {
576-
await this.removePublicACL(data, path);
591+
if (newPolicies.length > 0) {
592+
s3Response = await this._getS3Client(data).send(new PutBucketPolicyCommand({
593+
Bucket: data.bucket,
594+
Policy: JSON.stringify({ Version: '2012-10-17', Statement: newPolicies })
595+
}));
577596
}
597+
// -- remove all ACL's for this resource and below as precaution to user
598+
// if updating a single object or setting a folder private,
599+
if (!isPrefix || !setAsPublic) await this.removePublicACL(bucketId, path, isPrefix);
578600

579601
return s3Response;
580602
},
@@ -589,10 +611,10 @@ const objectStorageService = {
589611
async getPublic({ path, bucketId }) {
590612
const data = await utils.getBucket(bucketId);
591613
const resource = data.bucket + '/' + path;
592-
// if resource is an object, check for public ACL's
614+
const hasPublicPolicy = await this.hasEffectivePublicPolicy(resource, data);
615+
// if resource is an object, check for public ACL's (ACL's cannot apply to prefixes)
593616
const hasPublicAcl = data.key !== resource ? await this.hasPublicAcl(data, path) : false;
594617
// Check for COMS Bucket Policy for this resource
595-
const hasPublicPolicy = await this.hasEffectivePublicPolicy(resource, data);
596618
return hasPublicAcl || hasPublicPolicy;
597619
},
598620

@@ -606,20 +628,29 @@ const objectStorageService = {
606628
try {
607629
const existingPolicy = await this._getS3Client(data).send(new GetBucketPolicyCommand({ Bucket: data.bucket }));
608630
const statement = JSON.parse(existingPolicy.Policy).Statement;
609-
// order policies by string length to get most specific
610-
const sortedpolicies = statement.sort((a, b) => b.Resource.length - a.Resource.length);
611-
let policy;
612-
for (let i = 0; i < sortedpolicies.length; i++) {
613-
policy = sortedpolicies[i];
614-
if (resource.startsWith(policy.Resource || `${policy.Resource}*`) &&
615-
policy.Sid.startsWith('coms::') &&
616-
policy.Principal === '*') {
617-
break;
618-
}
631+
// A Deny policy on resource or above, which override Allow policies will set public status to false
632+
const denyPolicies = statement
633+
.filter(p => {
634+
return p.Effect === 'Deny' &&
635+
resource.startsWith(utils.trimResourcePath(p.Resource)) &&
636+
p.Principal === '*';
637+
});
638+
if (denyPolicies.length > 0) {
639+
return false;
640+
}
641+
// Any Allow policy on resource or above will set public status to true
642+
else {
643+
const allowPolicies = statement
644+
.filter(p => {
645+
return p.Effect === 'Allow' &&
646+
resource.startsWith(utils.trimResourcePath(p.Resource)) &&
647+
p.Principal === '*';
648+
})
649+
.sort((a, b) => b.Resource.length - a.Resource.length);
650+
return (allowPolicies.length > 0) ? true : false;
619651
}
620-
return policy.Effect === 'Allow' ? true : false;
621652
} catch (e) {
622-
log.debug('No existing policies found', { function: 'getPublic' });
653+
log.info('No existing effective policies found', { function: 'getPublic' });
623654
return false;
624655
}
625656
},

app/src/services/sync.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,14 @@ const service = {
9898
// 1. Sync Object
9999
const object = await service.syncObject(path, bucketId, userId, trx)
100100
.then(obj => obj.object);
101-
log.info(`Synchronized object at path ${path} in bucket ${bucketId}`,
101+
log.debug(`Synchronized object at path ${path} in bucket ${bucketId}`,
102102
{ function: 'syncJob', objectId: object?.id });
103103

104104
// 2. Sync Object Versions
105105
let versions = [];
106106
if (object) {
107107
versions = await service.syncVersions(object, userId, trx);
108-
log.info(`Synchronized ${versions.length} versions for object id ${object.id}`, { function: 'syncJob' });
108+
log.debug(`Synchronized ${versions.length} versions for object id ${object.id}`, { function: 'syncJob' });
109109
}
110110

111111
// 3. Sync Version Metadata & Tags

0 commit comments

Comments
 (0)