Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/bb-file-bucket-removal-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@aws-blocks/bb-file-bucket": patch
---

Fix `FileBucket` silently dropping bucket contents in non-sandbox stacks. Previously an explicit `removalPolicy: 'destroy'` enabled `autoDeleteObjects` in any stack, which provisions a hidden `Custom::S3AutoDeleteObjects` Lambda whose delete behavior cannot be overridden by stack-level retention Aspects (`RemovalPolicies.of(stack).retain()`). `autoDeleteObjects` now defaults to sandbox-only, so prod buckets honor retention.

Behavior change: in a non-sandbox stack, `removalPolicy: 'destroy'` now sets `RemovalPolicy.DESTROY` without auto-empty. `cdk destroy` on a non-empty prod bucket will fail with `DELETE_FAILED` (S3 rejects deleting a non-empty bucket) instead of silently wiping it. Callers relying on `'destroy'` for full teardown of a populated prod bucket should either empty it first or opt in explicitly (below).

New `FileBucketOptions.autoDeleteObjects?: boolean` escape hatch: defaults to sandbox-only behavior, but can be set `true` to force auto-empty for a genuinely-ephemeral prod bucket, or `false` to disable it even in sandbox. Only takes effect when the bucket is being destroyed.
1 change: 1 addition & 0 deletions packages/bb-file-bucket/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const FileBucketErrors: {

// @public (undocumented)
export interface FileBucketOptions {
autoDeleteObjects?: boolean;
bucket?: ExternalBucketRef;
corsRules?: CorsRule[];
lifecycleRules?: LifecycleRule[];
Expand Down
87 changes: 85 additions & 2 deletions packages/bb-file-bucket/src/index.cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ class StubBlocksStack extends cdk.Stack {
}
}

function setup(): { stack: StubBlocksStack; parent: Scope } {
const app = new cdk.App();
function setup(opts?: { sandbox?: boolean }): { stack: StubBlocksStack; parent: Scope } {
// Sandbox mode is read via `stack.node.tryGetContext('sandboxMode')`, so
// seed it on the app context when requested.
const app = new cdk.App(
opts?.sandbox ? { context: { sandboxMode: 'true' } } : undefined,
);
// S3 bucket names must be lowercase. The default-mode FileBucket derives
// its bucket name from the scope chain, so keep ids lowercase.
const stack = new StubBlocksStack(app, 'teststack');
Expand Down Expand Up @@ -82,3 +86,82 @@ test('CDK: fromExisting skips derived-name validation even when the chain is ove
}),
);
});

// ── removalPolicy / autoDeleteObjects (R6 regression) ────────────────────────
//
// autoDeleteObjects provisions a hidden `Custom::S3AutoDeleteObjects` Lambda
// whose delete behavior stack-level retention Aspects cannot override. It must
// be attached ONLY in sandbox mode — never to a prod bucket, even with an
// explicit `removalPolicy: 'destroy'` — or a "retained" prod bucket would be
// silently emptied on `cdk destroy`.

test('CDK: sandbox default enables DESTROY + auto-delete Lambda', () => {
const { stack, parent } = setup({ sandbox: true });
new FileBucket(parent, 'uploads');
const template = Template.fromStack(stack);
// Sandbox cleanup ergonomics preserved: bucket is dropped and auto-emptied.
template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Delete' });
template.resourceCountIs('Custom::S3AutoDeleteObjects', 1);
});

test('CDK: non-sandbox explicit destroy sets DESTROY but NO auto-delete Lambda (regression)', () => {
const { stack, parent } = setup();
new FileBucket(parent, 'uploads', { removalPolicy: 'destroy' });
const template = Template.fromStack(stack);
// Bucket is marked for deletion, but the un-overridable auto-delete Lambda
// must NOT be present in a prod stack — this is the core fix.
template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Delete' });
template.resourceCountIs('Custom::S3AutoDeleteObjects', 0);
});

test('CDK: non-sandbox explicit retain sets RETAIN and no auto-delete Lambda', () => {
const { stack, parent } = setup();
new FileBucket(parent, 'uploads', { removalPolicy: 'retain' });
const template = Template.fromStack(stack);
template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Retain' });
template.resourceCountIs('Custom::S3AutoDeleteObjects', 0);
});

test('CDK: non-sandbox default omits DESTROY (Aspect-overridable RETAIN) and no auto-delete Lambda', () => {
const { stack, parent } = setup();
new FileBucket(parent, 'uploads');
const template = Template.fromStack(stack);
// No explicit removalPolicy → CDK default RETAIN, driven by Aspects.
// Assert the positive default (RETAIN) so the intent is load-bearing rather
// than relying on the absence of a 'Delete' policy.
template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Retain' });
template.resourceCountIs('Custom::S3AutoDeleteObjects', 0);
});

// ── autoDeleteObjects explicit opt-in / opt-out (escape hatch) ───────────────
//
// The sandbox-only default can be overridden per-bucket. Opting in lets a
// genuinely-ephemeral prod bucket auto-empty on destroy; opting out disables
// auto-empty even in sandbox. The DESTROY invariant still holds — auto-delete
// never appears without the bucket being destroyed.

test('CDK: non-sandbox destroy with autoDeleteObjects:true opts into the auto-delete Lambda', () => {
const { stack, parent } = setup();
new FileBucket(parent, 'uploads', { removalPolicy: 'destroy', autoDeleteObjects: true });
const template = Template.fromStack(stack);
template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Delete' });
template.resourceCountIs('Custom::S3AutoDeleteObjects', 1);
});

test('CDK: sandbox default with autoDeleteObjects:false opts out of the auto-delete Lambda', () => {
const { stack, parent } = setup({ sandbox: true });
new FileBucket(parent, 'uploads', { autoDeleteObjects: false });
const template = Template.fromStack(stack);
// Sandbox still drops the bucket, but the caller disabled auto-empty.
template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Delete' });
template.resourceCountIs('Custom::S3AutoDeleteObjects', 0);
});

test('CDK: autoDeleteObjects:true without destroy does NOT provision the auto-delete Lambda', () => {
const { stack, parent } = setup();
// No destroy → auto-delete cannot apply (invariant: auto-delete requires DESTROY).
new FileBucket(parent, 'uploads', { autoDeleteObjects: true });
const template = Template.fromStack(stack);
template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Retain' });
template.resourceCountIs('Custom::S3AutoDeleteObjects', 0);
});
17 changes: 14 additions & 3 deletions packages/bb-file-bucket/src/index.cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,22 @@ export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends
// In sandbox mode, default to DESTROY + autoDeleteObjects so
// `cdk destroy` can fully clean up without manual bucket emptying.
// Explicit `removalPolicy` from the customer takes precedence.
// `autoDeleteObjects: true` is only valid paired with DESTROY (CDK
// validates this at construct time), so we tie the two together.
const isSandbox = cdk.Stack.of(this).node.tryGetContext('sandboxMode') === 'true';
const destroy = options?.removalPolicy === 'destroy' || (isSandbox && options?.removalPolicy === undefined);

// `autoDeleteObjects` defaults to sandbox-only — never in a prod stack,
// even with an explicit `removalPolicy: 'destroy'`, unless the caller
// deliberately opts in via `options.autoDeleteObjects: true`. CDK's L2
// `s3.Bucket` implements auto-delete via a hidden
// `Custom::S3AutoDeleteObjects` Lambda with a hardcoded delete behavior
// that stack-level retention Aspects (`RemovalPolicies.of(stack).retain()`)
// cannot override. Attaching it to a prod bucket would silently empty the
// bucket on `cdk destroy` even when the stack intends to retain it, so the
// safe default is sandbox-only. It only ever applies when the bucket is
// actually being destroyed (`destroy`), preserving CDK's construct-time
// invariant that auto-delete requires DESTROY.
const autoDeleteObjects = destroy && (options?.autoDeleteObjects ?? isSandbox);

// Bucket name is derived from the scope chain. Validate against S3's
// naming rules at synth so an invalid name fails here rather than at
// `cdk deploy` (where CloudFormation rejects it with a cryptic error).
Expand All @@ -66,7 +77,7 @@ export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends
: options?.removalPolicy === 'retain'
? RemovalPolicy.RETAIN
: undefined,
autoDeleteObjects: destroy,
autoDeleteObjects,
cors: options?.corsRules?.map((rule: CorsRule) => ({
allowedOrigins: rule.allowedOrigins,
allowedMethods: rule.allowedMethods.map(m => httpMethodMap[m]),
Expand Down
36 changes: 32 additions & 4 deletions packages/bb-file-bucket/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@ export interface FileBucketOptions {
* CDK's default applies (RETAIN — the bucket and its contents are
* preserved on `cdk destroy`).
*
* Pass `'destroy'` for sandbox / ephemeral stacks where the bucket
* should be dropped on teardown. This also enables `autoDeleteObjects`
* so CloudFormation can empty the bucket before deletion — required
* since S3 rejects DELETE on a non-empty bucket.
* Pass `'destroy'` to drop the bucket on teardown (`RemovalPolicy.DESTROY`).
* By default `autoDeleteObjects` (which empties the bucket before deletion)
* is enabled **only in sandbox mode**. In a prod stack, `'destroy'` sets
* DESTROY but does NOT auto-empty — S3 rejects deleting a non-empty bucket,
* so a populated prod bucket is preserved rather than silently wiped. To
* opt a genuinely-ephemeral prod bucket into auto-empty behavior, set
* {@link autoDeleteObjects} explicitly.
*
* Why sandbox-only by default: CDK implements `autoDeleteObjects` via a
* hidden `Custom::S3AutoDeleteObjects` Lambda whose delete behavior
* stack-level retention Aspects (`RemovalPolicies.of(stack).retain()`)
* cannot override. Keeping it out of prod stacks by default ensures those
* Aspects remain effective unless a caller deliberately opts out.
*
* Pass `'retain'` to set the policy explicitly (identical to omitting
* it today, but robust against stack-layer policy overrides).
Expand All @@ -37,6 +46,25 @@ export interface FileBucketOptions {
* Ignored by the mock and browser runtimes (no AWS resource to retain).
*/
removalPolicy?: 'destroy' | 'retain';
/**
* Explicitly control whether the bucket is emptied before deletion via
* CDK's `autoDeleteObjects`. Only takes effect when the bucket is being
* destroyed (`removalPolicy: 'destroy'`, or sandbox default).
*
* When omitted, defaults to sandbox-only: auto-empty is enabled in sandbox
* mode and disabled in prod. Set `true` to force auto-empty for a
* deliberately-ephemeral prod bucket, or `false` to disable it even in
* sandbox.
*
* ⚠️ Enabling this provisions a hidden `Custom::S3AutoDeleteObjects` Lambda
* whose delete behavior stack-level retention Aspects
* (`RemovalPolicies.of(stack).retain()`) cannot override. Only opt in for a
* bucket you genuinely intend to be throwaway — this is a deliberate,
* visible footgun rather than the default.
*
* Ignored by the mock and browser runtimes.
*/
autoDeleteObjects?: boolean;
/** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
logger?: ChildLogger;
}
Expand Down
Loading