packages/utilities/s3-bucketpolicy-helper/lib/index.ts (188 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import { IPrincipal, PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam';
import { IBucket } from 'aws-cdk-lib/aws-s3';
export interface IRestrictObjectPrefixToRoles {
/**
* S3 Bucket to use for the resource ARN while constructing the policy
*/
s3Bucket: IBucket;
/**
* S3 Object key prefix to restrict.
*/
s3Prefix: string;
/**
* Array of Role ARNs to provide Read Access to the prefix.
* Must be resolvable to AROAs using the MdaaRoleResolver class included.
*/
readRoleIds?: string[];
/**
* Array of Role ARNs to provide Read/Write access to the prefix.
* Must be resolvable to AROAs using the MdaaRoleResolver class included.
*/
readWriteRoleIds?: string[];
/**
* Array of Role ARNs to provide Read/Write/Super (Permanent Delete) access to the prefix.
* Must be resolvable to AROAs using the MdaaRoleResolver class included.
*/
readWriteSuperRoleIds?: string[];
/**
* Array of Principals to provide Read Access to the prefix.
*/
readPrincipals?: IPrincipal[];
/**
* Array of Principals to provide Read/Write access to the prefix.
*/
readWritePrincipals?: IPrincipal[];
/**
* Array of Principals to provide Read/Write/Super (Permanent Delete) access to the prefix.
*/
readWriteSuperPrincipals?: IPrincipal[];
}
export interface IRestrictBucketToRoles {
/**
* S3 Bucket to use for the resource ARN while constructing the policy
*/
s3Bucket: IBucket;
/**
* Array of Role ARNs to provide Access to the bucket.
* Must be resolvable to AROAs using the MdaaRoleResolver class included.
*/
roleExcludeIds: string[];
/**
* Set of principals to exclude from the Deny Restrictions.
* NOTE: this doesn't permit or deny that principal
*/
principalExcludes?: string[];
/**
* S3 object prefixes to exclude from the Deny Restrictions.
* Results in the deny applying to *All Prefixes* except the ones here.
*/
prefixExcludes?: string[];
/**
* S3 object prefixes to include in the Deny Restrictions.
* Results in the deny only applying to the the prefixes specified here.
*/
prefixIncludes?: string[];
}
/** Helper class for generating S3 bucket policy statements which grant access to specific object prefixes */
export class RestrictObjectPrefixToRoles {
static readonly READ_ACTIONS = ['s3:GetObject*'];
static readonly READ_WRITE_ACTIONS = [
...RestrictObjectPrefixToRoles.READ_ACTIONS,
's3:PutObject',
's3:PutObjectTagging',
's3:DeleteObject',
];
static readonly READ_WRITE_SUPER_ACTIONS = [
...RestrictObjectPrefixToRoles.READ_WRITE_ACTIONS,
's3:DeleteObjectVersion',
];
static readonly BUCKET_ALLOW_ACTIONS = ['s3:List*', 's3:GetBucket*'];
static readonly BUCKET_DENY_ACTIONS = ['s3:PutObject*', 's3:GetObject*', 's3:DeleteObject*'];
private _readStatements: PolicyStatement[] = [];
private _readWriteStatements: PolicyStatement[] = [];
private _readWriteSuperStatements: PolicyStatement[] = [];
private _formattedPrefix: string;
constructor(props: IRestrictObjectPrefixToRoles) {
this._formattedPrefix = '/' + this.formatS3Prefix(props.s3Prefix) + '/*';
// Covers our case where two / get resolved because our prefix is actually /
this._formattedPrefix = this._formattedPrefix.replace(/\/\//, '/');
// FEDERATED / READ
if (props.readRoleIds != undefined && props.readRoleIds.length > 0) {
// Construct our User:Id roles for read
const statement = this._readStatementScaffold(props);
statement.addCondition('StringLike', { 'aws:userId': props.readRoleIds.map(x => `${x}:*`) });
statement.addAnyPrincipal();
this._readStatements.push(statement);
}
// FEDERATED / READWRITE
if (props.readWriteRoleIds != undefined && props.readWriteRoleIds.length > 0) {
const statement = this._readWriteStatementScaffold(props);
statement.addCondition('StringLike', { 'aws:userId': props.readWriteRoleIds.map(x => `${x}:*`) });
statement.addAnyPrincipal();
this._readWriteStatements.push(statement);
}
// FEDERATED / READWRITESUPER
if (props.readWriteSuperRoleIds != undefined && props.readWriteSuperRoleIds.length > 0) {
const statement = this._readWriteSuperStatementScaffold(props);
statement.addCondition('StringLike', { 'aws:userId': props.readWriteSuperRoleIds.map(x => `${x}:*`) });
statement.addAnyPrincipal();
this._readWriteSuperStatements.push(statement);
}
// NONFEDERATED / READ
if (props.readPrincipals != undefined && props.readPrincipals.length > 0) {
const statement = this._readStatementScaffold(props);
props.readPrincipals.forEach(principal => {
statement.addPrincipals(principal);
});
this._readStatements.push(statement);
}
// NONFEDERATED / READWRITE
if (props.readWritePrincipals != undefined && props.readWritePrincipals.length > 0) {
const statement = this._readWriteStatementScaffold(props);
props.readWritePrincipals.forEach(principal => {
statement.addPrincipals(principal);
});
this._readWriteStatements.push(statement);
}
// NONFEDERATED / READWRITESUPER
if (props.readWriteSuperPrincipals != undefined && props.readWriteSuperPrincipals.length > 0) {
const statement = this._readWriteSuperStatementScaffold(props);
props.readWriteSuperPrincipals.forEach(principal => {
statement.addPrincipals(principal);
});
this._readWriteSuperStatements.push(statement);
}
}
private _readStatementScaffold(props: IRestrictObjectPrefixToRoles): PolicyStatement {
return new PolicyStatement({
sid: `${props.s3Prefix.replace(/\\W/g, '')}_Read`,
effect: Effect.ALLOW,
resources: [props.s3Bucket.bucketArn + this._formattedPrefix],
actions: RestrictObjectPrefixToRoles.READ_ACTIONS,
});
}
private _readWriteStatementScaffold(props: IRestrictObjectPrefixToRoles): PolicyStatement {
return new PolicyStatement({
sid: `${props.s3Prefix.replace(/\\W/g, '')}_ReadWrite`,
effect: Effect.ALLOW,
resources: [props.s3Bucket.bucketArn + this._formattedPrefix],
actions: RestrictObjectPrefixToRoles.READ_WRITE_ACTIONS,
});
}
private _readWriteSuperStatementScaffold(props: IRestrictObjectPrefixToRoles): PolicyStatement {
return new PolicyStatement({
sid: `${props.s3Prefix.replace(/\\W/g, '')}_ReadWriteSuper`,
effect: Effect.ALLOW,
resources: [props.s3Bucket.bucketArn + this._formattedPrefix],
actions: RestrictObjectPrefixToRoles.READ_WRITE_SUPER_ACTIONS,
});
}
public readStatements(): PolicyStatement[] {
return this._readStatements;
}
public readWriteStatements(): PolicyStatement[] {
return this._readWriteStatements;
}
public readWriteSuperStatements(): PolicyStatement[] {
return this._readWriteSuperStatements;
}
public statements(): PolicyStatement[] {
return [...this._readStatements, ...this._readWriteStatements, ...this._readWriteSuperStatements];
}
public formatS3Prefix(prefix: string): string {
let rawPrefix = prefix;
// Removes trailing slashes
rawPrefix = rawPrefix.endsWith('/') ? rawPrefix.slice(0, -1) : rawPrefix;
// Removes leading slashes
rawPrefix = rawPrefix.startsWith('/') ? rawPrefix.substring(1) : rawPrefix;
return rawPrefix;
}
}
/** Helper class for generating bucket policy statements
* which allow or deny access to an entire bucket. Used to
* create bucket-level default deny statements to block accesses
* not granted in the bucket policy. */
export class RestrictBucketToRoles {
public readonly denyStatement: PolicyStatement;
public readonly allowStatement: PolicyStatement;
private resource: string[] = [];
private notResource: string[] = [];
private denyConditionalNotEquals: {
'aws:userId'?: string[];
'aws:PrincipalArn'?: string[];
} = {};
constructor(props: IRestrictBucketToRoles) {
// Statement allowing access to the bucket for the AROAs
this.allowStatement = new PolicyStatement({
sid: `BucketAllow`,
effect: Effect.ALLOW,
resources: [props.s3Bucket.bucketArn + '/*', props.s3Bucket.bucketArn],
actions: RestrictObjectPrefixToRoles.BUCKET_ALLOW_ACTIONS,
});
this.allowStatement.addAnyPrincipal();
this.allowStatement.addCondition('StringLike', { 'aws:userId': props.roleExcludeIds.map(x => `${x}:*`) });
// Constuct our deny statement.
// prefixIncludes denotes we want to include a prefix in our deny meaning Resource
if (props.prefixIncludes) {
this.resource = props.prefixIncludes.map(prefix => {
return `${props.s3Bucket.bucketArn}/${this.formatS3Prefix(prefix)}`;
});
} else {
this.resource = [props.s3Bucket.bucketArn + '/*'];
}
// prefixExcludes denote we want to exclude a prefix in our deny meaning notResource
if (props.prefixExcludes) {
this.notResource = props.prefixExcludes.map(prefix => {
return `${props.s3Bucket.bucketArn}/${this.formatS3Prefix(prefix)}`;
});
}
if (this.notResource.length > 0) {
this.denyStatement = new PolicyStatement({
sid: `BucketDeny`,
effect: Effect.DENY,
notResources: this.notResource,
actions: RestrictObjectPrefixToRoles.BUCKET_DENY_ACTIONS,
});
} else {
this.denyStatement = new PolicyStatement({
sid: `BucketDeny`,
effect: Effect.DENY,
resources: this.resource,
actions: RestrictObjectPrefixToRoles.BUCKET_DENY_ACTIONS,
});
}
this.denyStatement.addAnyPrincipal();
// Build our conditionals.
this.denyConditionalNotEquals['aws:userId'] = props.roleExcludeIds.map(x => `${x}:*`);
if (props.principalExcludes && props.principalExcludes.length > 0) {
this.denyConditionalNotEquals['aws:PrincipalArn'] = [...new Set(props.principalExcludes)].sort((a, b) =>
a.localeCompare(b),
);
}
// Construct our conditional for our deny
if (Object.keys(this.denyConditionalNotEquals).length == 1) {
this.denyStatement.addCondition('StringNotLike', this.denyConditionalNotEquals);
} else {
this.denyStatement.addCondition('ForAnyValue:StringNotLike', this.denyConditionalNotEquals);
}
}
private formatS3Prefix(prefix: string): string {
let rawPrefix = prefix;
// Removes trailing slashes
rawPrefix = rawPrefix.endsWith('/') ? rawPrefix.slice(0, -1) : rawPrefix;
// Removes leading slashes
rawPrefix = rawPrefix.startsWith('/') ? rawPrefix.substring(1) : rawPrefix;
return `${rawPrefix}/*`;
}
}