canopycms-cdk 0.0.68-int.100 → 0.0.68-int.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -127,6 +127,29 @@ export interface AssetSupportProps {
127
127
  * @default false
128
128
  */
129
129
  readonly autoDeleteObjects?: boolean;
130
+ /**
131
+ * Deny every `s3:PutObject` to `assets/*`, `asset-originals/*` and `asset-meta/*` that carries
132
+ * no `If-None-Match`, so no principal can replace an object under those content-addressed
133
+ * prefixes. Standalone mode only: with a BYO `bucket` the prop is refused, and the same statement
134
+ * belongs in that bucket's own policy (see docs/deploying-to-aws.md).
135
+ *
136
+ * Opt-in because the Deny binds every writer: the CMS Lambda, which runs the `canopycms` your app
137
+ * installs, and with `lazyPublicTransforms` the transform Lambda, which bundles the `canopycms`
138
+ * this package was built with and calls S3 through the Lambda runtime's AWS SDK. Upgrade and
139
+ * deploy both to releases whose stores write create-only, then enable this. It also denies every
140
+ * unconditional write, such as a plain `aws s3 cp`, and every multipart upload (`UploadPart`
141
+ * carries no `If-None-Match`). Replication is authorized as `s3:ReplicateObject`, so it is not
142
+ * denied; a replica bucket takes the same statement.
143
+ *
144
+ * Create-only holds only as far as `s3:DeleteObject` does: a delete then a create replaces an
145
+ * object, so keep Delete on `assets/*` and `asset-originals/*` to a break-glass role. That is
146
+ * also how a bad derivative is replaced: delete it, then rerun `materialize-assets`. On a
147
+ * versioned bucket the delete writes a delete marker and the bad version stays restorable; a
148
+ * replica keeps the bad version until the rematerialized object replicates.
149
+ *
150
+ * @default false
151
+ */
152
+ readonly enforceCreateOnlyWrites?: boolean;
130
153
  /**
131
154
  * A replica of the bucket's `assets/` prefix. Both public behaviors fail over
132
155
  * to it on 500, 502, 503 or 504 from the primary, never on a miss: the
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { Construct } from 'constructs';
5
- import { Duration, RemovalPolicy, Stack, Token, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3, } from 'aws-cdk-lib';
5
+ import { Duration, RemovalPolicy, Stack, Token, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_iam as iam, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3, } from 'aws-cdk-lib';
6
6
  import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
7
7
  // This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
8
8
  // output is real ESM - `__dirname` is not a global there. Derive it from
@@ -42,6 +42,8 @@ const PREFIXES = {
42
42
  public: 'assets',
43
43
  transform: 'assets/t',
44
44
  };
45
+ /** The content-addressed prefixes, whose objects no correct writer ever replaces. */
46
+ const CREATE_ONLY_PREFIXES = [PREFIXES.public, PREFIXES.originals, PREFIXES.meta];
45
47
  /**
46
48
  * The tag on every object the transform Lambda writes, copied as literals for
47
49
  * the same reason as `PREFIXES`. Source of truth is `LAZY_TRANSFORM_TAG` in
@@ -519,6 +521,13 @@ export class AssetSupport extends Construct {
519
521
  '`lazyPublicTransforms` and run `canopycms materialize-assets` in your release ' +
520
522
  'pipeline instead.');
521
523
  }
524
+ if (props.bucket && props.enforceCreateOnlyWrites) {
525
+ throw new Error('AssetSupport: `enforceCreateOnlyWrites` writes its Deny into the policy of a bucket this ' +
526
+ 'construct creates, and cannot write one on a BYO `bucket`. Drop the prop and add the ' +
527
+ "statement to your bucket's own policy: Deny s3:PutObject to any principal on " +
528
+ `${CREATE_ONLY_PREFIXES.map((prefix) => `${prefix}/*`).join(', ')} under the condition ` +
529
+ '{ Null: { "s3:if-none-match": "true" } }.');
530
+ }
522
531
  // Fail closed: a bundle without the marker has no linux/arm64 sharp and
523
532
  // throws at cold start on the first image request, far from the cause.
524
533
  // `pnpm test` leaves exactly such a fixture on disk.
@@ -610,6 +619,16 @@ export class AssetSupport extends Construct {
610
619
  : undefined,
611
620
  });
612
621
  }
622
+ if (props.enforceCreateOnlyWrites) {
623
+ this.bucket.addToResourcePolicy(new iam.PolicyStatement({
624
+ sid: 'DenyAssetOverwrites',
625
+ effect: iam.Effect.DENY,
626
+ principals: [new iam.StarPrincipal()],
627
+ actions: ['s3:PutObject'],
628
+ resources: CREATE_ONLY_PREFIXES.map((prefix) => this.bucket.arnForObjects(`${prefix}/*`)),
629
+ conditions: { Null: { 's3:if-none-match': 'true' } },
630
+ }));
631
+ }
613
632
  if (lazy) {
614
633
  const transform = this.buildTransformLambda(props);
615
634
  this.transformFunction = transform.fn;
@@ -76,8 +76,8 @@ export interface CanopyCmsAttachOptions {
76
76
  *
77
77
  * It must set `x-forwarded-host` from the viewer's Host, or sign-in
78
78
  * redirects point at the Function URL and 403. It must not gate on HTTP
79
- * Basic auth: the API answers 401 without `WWW-Authenticate`, and a browser
80
- * that sees that drops its cached Basic credential and prompts again.
79
+ * Basic auth; the CMS authenticates these routes itself. A Basic-gated site sets
80
+ * the CMS config's `unauthenticatedStatus: 419` (see docs/deploying-to-aws.md).
81
81
  *
82
82
  * @default - a function that sets `x-forwarded-host` and nothing else
83
83
  */