diff --git a/actions/cf-preview/lib.mjs b/actions/cf-preview/lib.mjs index e090bbb..203a7ad 100644 --- a/actions/cf-preview/lib.mjs +++ b/actions/cf-preview/lib.mjs @@ -187,6 +187,9 @@ const RESOURCE_KINDS = { }; // Kinds that can reach Production state and are never allowed without an explicit allowlist entry. const HIGH_RISK = ['services', 'hyperdrive', 'secrets_store_secrets']; +// Kinds a caller may share with Production through an exact `kind:BINDING=identity` preview_binding_allow entry. +// Wider than Shared Dev's list on purpose: a Secrets Store secret can be approved for Preview only (never D1). +const PREVIEW_SHAREABLE_KINDS = [...DEV_SHAREABLE_KINDS, 'secrets_store_secrets']; function bindingKey(kind, b) { return b.binding ?? b.name ?? JSON.stringify(b); @@ -201,7 +204,9 @@ function queueProducers(cfg) { * `allow` is a list of "kind:binding" strings the caller has explicitly approved * (e.g. "services:ID_SERVICE"), `denyResources` extra Production identities to reject. An entry * "kind:BINDING=identity" (e.g. "r2_buckets:CACHE=prod-bucket") additionally lets that exact binding share that - * exact Production resource; only storage kinds in DEV_SHAREABLE_KINDS qualify, never D1. + * exact Production resource; only storage kinds in DEV_SHAREABLE_KINDS plus secrets_store_secrets (identity + * `store_id/secret_name`) qualify, never D1. For a high-risk kind that exact entry also satisfies the explicit-allow + * requirement of the binding it names; a bare `kind:BINDING` entry never waives the Production collision. */ export function auditPreviewConfig(cfg, { allow = [], denyResources = [] } = {}) { const errors = []; @@ -231,15 +236,17 @@ export function auditPreviewConfig(cfg, { allow = [], denyResources = [] } = {}) for (const b of prev) { const key = `${kind}:${bindingKey(kind, b)}`; const mine = ids(b).filter(Boolean); + let sharedExact = mine.length > 0; if (!mine.length) errors.push(`${key}: Preview binding needs an explicit resource id (Native Previews do not auto-provision; Cloudflare rejects it with 10021)`); for (const id of mine) { // An exact "kind:BINDING=identity" entry for a shareable storage kind approves sharing that one // resource under that one binding; it waives the Production-collision and denylist checks only. - if (DEV_SHAREABLE_KINDS.includes(kind) && allowed.has(`${key}=${id}`)) continue; + if (PREVIEW_SHAREABLE_KINDS.includes(kind) && allowed.has(`${key}=${id}`)) continue; + sharedExact = false; if (prodIds.has(id)) errors.push(`${key}: Preview binds the same resource as Production (${id})`); if (deny.has(id)) errors.push(`${key}: Preview binds denylisted Production resource (${id})`); } - if (HIGH_RISK.includes(kind) && !allowed.has(key)) { + if (HIGH_RISK.includes(kind) && !allowed.has(key) && !(sharedExact && PREVIEW_SHAREABLE_KINDS.includes(kind))) { errors.push(`${key}: high-risk binding needs explicit allow entry "${key}"`); } } @@ -399,7 +406,7 @@ export function auditProdConfig(effective, source, { allow = [] } = {}) { for (const b of source.previews?.[kind] ?? []) { const key = `${kind}:${bindingKey(kind, b)}`; ident(b).filter(Boolean).forEach((id) => { - if (DEV_SHAREABLE_KINDS.includes(kind) && allowed.has(`${key}=${id}`)) return; + if (PREVIEW_SHAREABLE_KINDS.includes(kind) && allowed.has(`${key}=${id}`)) return; previewIds.add(id); }); } diff --git a/docs/cf-worker-v2.md b/docs/cf-worker-v2.md index 2620875..890a0b9 100644 --- a/docs/cf-worker-v2.md +++ b/docs/cf-worker-v2.md @@ -129,6 +129,7 @@ Production Version, bound to Production resources), set `preview_worker_name` to Adapters can add bindings at build time (the Astro Cloudflare adapter adds a `SESSION` KV), so preflight audits both the source config and the build-generated (redirected) config. * `services`, `hyperdrive`, `secrets_store_secrets` in `previews` need an explicit `preview_binding_allow` entry (e.g. `services:ID`): Preview service bindings reach the target's **Production** deployment. * To deliberately share one Production storage resource (R2, KV, Vectorize, Analytics Engine) with Previews, add an exact `preview_binding_allow` entry `kind:BINDING=identity` (e.g. `r2_buckets:CACHE=my-bucket`), the same grammar as `dev_binding_allow`. It waives the Production-collision and `preview_deny_resources` checks for that binding and resource only; a different resource or binding still fails, and a Production D1 can never be shared. Isolation inside the shared resource (e.g. a key prefix) is the caller's responsibility. +* A Production **Secrets Store secret** can be shared the same way with the exact entry `secrets_store_secrets:BINDING=/` (e.g. `secrets_store_secrets:FATSECRET_CLIENT_ID=/FATSECRET_CLIENT_ID`). It waives the Production-collision and `preview_deny_resources` checks and the high-risk allow requirement for that one binding+identity only. A bare `secrets_store_secrets:BINDING` entry only satisfies the high-risk check (a Preview-only secret) and never waives the collision; a different store/secret/binding still fails. Shared Dev (`dev_binding_allow`) is unchanged and does not support secrets. * No `routes`, `triggers` or queue consumers in `previews`; `assets` and `compatibility_*` stay top-level. * Preview secrets are **not** inherited from Production: `wrangler preview base-config secret put `. diff --git a/scripts/cf-preview/lib.test.mjs b/scripts/cf-preview/lib.test.mjs index 5d3949a..f8b75d8 100644 --- a/scripts/cf-preview/lib.test.mjs +++ b/scripts/cf-preview/lib.test.mjs @@ -122,6 +122,41 @@ test('preflight: exact kind:BINDING=identity allow shares one Production R2 buck assert.equal(auditPreviewConfig(g, { allow: ['r2_buckets:ASSETS_R2=other-bucket'] }).errors.length, 1); assert.equal(auditPreviewConfig(g, { allow: ['r2_buckets:OTHER=prod-bucket'] }).errors.length, 1); }); +const withSecret = (prevName = 'FS_ID', prevStore = 'store-1', prevSecret = 'FS_ID') => { + const g = good(); + g.secrets_store_secrets = [{ binding: 'FS_ID', store_id: 'store-1', secret_name: 'FS_ID' }]; + g.previews.secrets_store_secrets = [{ binding: prevName, store_id: prevStore, secret_name: prevSecret }]; + return g; +}; +test('preflight: Secrets Store secret shared with Production needs the exact store_id/secret_name entry', () => { + const entry = 'secrets_store_secrets:FS_ID=store-1/FS_ID'; + const g = withSecret(); + assert.ok(auditPreviewConfig(g).errors.some((e) => /same resource as Production/.test(e))); + // a bare kind:BINDING entry satisfies the high-risk check but never waives the Production collision + const bare = auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID'] }).errors; + assert.equal(bare.length, 1); + assert.match(bare[0], /same resource as Production/); + // the exact entry waives the collision and satisfies the high-risk check for that binding + assert.deepEqual(auditPreviewConfig(g, { allow: [entry] }).errors, []); + assert.deepEqual(auditPreviewConfig(g, { allow: [entry], denyResources: ['store-1/FS_ID'] }).errors, []); + // another secret / store / binding is not approved + assert.ok(auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID=store-1/OTHER'] }).errors.length > 0); + assert.ok(auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID=store-2/FS_ID'] }).errors.length > 0); + assert.ok(auditPreviewConfig(g, { allow: ['secrets_store_secrets:OTHER=store-1/FS_ID'] }).errors.length > 0); +}); +test('preflight: a Preview-only Secrets Store secret still needs the explicit high-risk allow', () => { + const g = withSecret('FS_ID', 'store-1', 'FS_ID_PREVIEW'); + assert.match(auditPreviewConfig(g).errors[0], /high-risk binding needs explicit allow entry/); + assert.deepEqual(auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID'] }).errors, []); + // an exact entry for a different identity does not approve it + assert.ok(auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID=store-1/FS_ID'] }).errors.length > 0); +}); +test('production audit: an approved shared Secrets Store secret is not a Preview-only resource', () => { + const g = withSecret(); + const src = g; + assert.ok(auditProdConfig(g, src, {}).errors.length > 0); + assert.deepEqual(auditProdConfig(g, src, { allow: ['secrets_store_secrets:FS_ID=store-1/FS_ID'] }).errors, []); +}); test('preflight: a Production D1 can never be approved for sharing', () => { const g = good(); g.previews.d1_databases[0].database_id = 'prod-id';