From 2afbecf374ac2671bf6df948869aeaa02160436b Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 12 Sep 2026 13:11:06 +0300 Subject: [PATCH 1/4] feat(marketplace): strip generated-field values from stored stack_definition (P2) Defense-in-depth for the field-policy secret handling. At publish, blank the value of every `mutability: generated` field before the version is persisted, so the stored (and federated) template never carries the author's secret values. The installer regenerates them per buyer; if regeneration ever doesn't run, the field is empty (fail-closed) instead of leaking the author's secret. - new `strip_generated_field_values` applied at all three write sites in creator.rs (create, update, resubmit), keyed on the contract's generated fields. - reusable key-set strippers in redact.rs (JSON + YAML), value replaced with "". - tests: generated blanked, fixed/editable/undeclared untouched, YAML + ProjectForm shapes, no-op without generated fields. Co-Authored-By: Claude Opus 4.8 --- src/helpers/redact.rs | 106 ++++++++++++++++++++++++ src/routes/marketplace/creator.rs | 130 ++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) diff --git a/src/helpers/redact.rs b/src/helpers/redact.rs index f3345a6e..ce3ba7e7 100644 --- a/src/helpers/redact.rs +++ b/src/helpers/redact.rs @@ -110,6 +110,112 @@ pub fn redact_yaml_string(yaml: &str) -> String { } } +// ─── generated-field stripping (publish-time, key-set driven) ─────────────────── +// +// Unlike the name-heuristic redaction above, these replace the values of an +// EXPLICIT set of keys (the author-declared `mutability: generated` fields from +// config_contract) with `replacement`. Used at publish time so the stored +// `stack_definition` never carries the author's secret values for fields the +// installer will regenerate — fail-closed if regeneration ever doesn't run. + +use std::collections::BTreeSet; + +/// Replace, in place, the values of env entries whose key is in `keys`. +/// Handles the same shapes as [`redact_sensitive_json_values`] plus `KEY=value` +/// strings in environment arrays. +pub fn strip_json_values_for_keys( + value: &mut serde_json::Value, + keys: &BTreeSet, + replacement: &str, +) { + match value { + serde_json::Value::Object(map) => { + // ProjectForm var entry: {"key": "NAME", "value": "..."}. + if let Some(name) = map.get("key").and_then(|v| v.as_str()) { + if keys.contains(name) { + if let Some(val) = map.get_mut("value") { + if !val.is_null() { + *val = serde_json::Value::String(replacement.to_string()); + } + } + return; + } + } + for (key, val) in map.iter_mut() { + if keys.contains(key) && !val.is_null() { + *val = serde_json::Value::String(replacement.to_string()); + } else { + strip_json_values_for_keys(val, keys, replacement); + } + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + if let serde_json::Value::String(s) = item { + if let Some(eq) = s.find('=') { + if keys.contains(&s[..eq]) { + *s = format!("{}={}", &s[..eq], replacement); + continue; + } + } + } + strip_json_values_for_keys(item, keys, replacement); + } + } + _ => {} + } +} + +fn strip_yaml_values_for_keys( + value: &mut serde_yaml::Value, + keys: &BTreeSet, + replacement: &str, +) { + match value { + serde_yaml::Value::Mapping(map) => { + for (key, val) in map.iter_mut() { + if let serde_yaml::Value::String(k) = key { + if keys.contains(k) && !val.is_null() { + *val = serde_yaml::Value::String(replacement.to_string()); + continue; + } + } + strip_yaml_values_for_keys(val, keys, replacement); + } + } + serde_yaml::Value::Sequence(seq) => { + for item in seq.iter_mut() { + if let serde_yaml::Value::String(s) = item { + if let Some(eq) = s.find('=') { + if keys.contains(&s[..eq]) { + *s = format!("{}={}", &s[..eq], replacement); + } + } + } else { + strip_yaml_values_for_keys(item, keys, replacement); + } + } + } + _ => {} + } +} + +/// Parse a compose YAML string, blank the values of `keys`, re-serialize. +/// Returns the original string on parse failure. +pub fn strip_yaml_string_for_keys( + yaml: &str, + keys: &BTreeSet, + replacement: &str, +) -> String { + match serde_yaml::from_str::(yaml) { + Ok(mut value) => { + strip_yaml_values_for_keys(&mut value, keys, replacement); + serde_yaml::to_string(&value).unwrap_or_else(|_| yaml.to_string()) + } + Err(_) => yaml.to_string(), + } +} + // ─── tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index 651b21a7..15cee1c2 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -217,6 +217,11 @@ pub async fn create_handler( // Optional initial version if let Some(def) = req.stack_definition { let version = req.version.unwrap_or("1.0.0".to_string()); + // P2: never persist author secret values for generated fields. + let def = match req.config_contract.as_ref() { + Some(cc) => strip_generated_field_values(&def, req.definition_format.as_deref(), cc), + None => def, + }; db::marketplace::upsert_latest_version( pg_pool.get_ref(), &template.id, @@ -538,6 +543,42 @@ pub(crate) fn missing_generated_secret_fields( .collect() } +/// Defense-in-depth (P2): blank the values of every `mutability: generated` field +/// in `stack_definition` before it is persisted, so the stored/federated template +/// never carries the author's secret values. The installer regenerates these per +/// buyer; if regeneration ever fails, the field is empty (fail-closed) rather than +/// leaking the author's secret. `fixed`/`editable`/undeclared fields are untouched; +/// with no generated fields this returns the input unchanged. +pub(crate) fn strip_generated_field_values( + stack_definition: &serde_json::Value, + definition_format: Option<&str>, + config_contract: &serde_json::Value, +) -> serde_json::Value { + let contract: crate::cli::config_parser::ConfigContract = + serde_json::from_value(config_contract.clone()).unwrap_or_default(); + let generated: std::collections::BTreeSet = contract + .services + .values() + .flat_map(|target| target.secret_keys()) + .collect(); + if generated.is_empty() { + return stack_definition.clone(); + } + + if definition_format == Some("yaml") { + if let Some(yaml_text) = stack_definition.as_str() { + let stripped = + crate::helpers::redact::strip_yaml_string_for_keys(yaml_text, &generated, ""); + return serde_json::Value::String(stripped); + } + return stack_definition.clone(); + } + + let mut value = stack_definition.clone(); + crate::helpers::redact::strip_json_values_for_keys(&mut value, &generated, ""); + value +} + fn ensure_contract_declares_generated_secrets( stack_definition: &serde_json::Value, definition_format: Option<&str>, @@ -743,6 +784,17 @@ pub async fn update_handler( .clone() .or(current_version.update_mode_capabilities.clone()); + // P2: strip author secret values for generated fields before persisting, + // against the effective contract (this request's, else the stored one). + let effective_contract = match req.config_contract.clone() { + Some(cc) => cc, + None => db::marketplace::get_config_contract(pg_pool.get_ref(), id) + .await + .unwrap_or(serde_json::Value::Null), + }; + let stack_definition = + strip_generated_field_values(&stack_definition, definition_format, &effective_contract); + db::marketplace::upsert_latest_version( pg_pool.get_ref(), &id, @@ -1169,6 +1221,13 @@ pub async fn resubmit_handler( &resolved_config_contract, )?; + // P2: strip author secret values for generated fields before persisting. + let stack_definition = strip_generated_field_values( + &stack_definition, + definition_format.as_deref(), + &resolved_config_contract, + ); + let version = db::marketplace::resubmit_with_new_version( pg_pool.get_ref(), &id, @@ -1624,6 +1683,77 @@ pub async fn complete_onboarding_handler( mod field_policy_gate_tests { use super::*; + fn generated_contract() -> serde_json::Value { + serde_json::json!({ + "services": { + "auth": { + "fields": { + "JWT_SECRET": { "mutability": "generated", "type": "hex", "length": 32 }, + "LOG_LEVEL": { "mutability": "editable" }, + "POSTGRES_HOST": { "mutability": "fixed" } + } + } + } + }) + } + + #[test] + fn strip_blanks_generated_values_in_json_definition() { + let def = serde_json::json!({ + "services": { + "auth": { + "environment": { + "JWT_SECRET": "author-secret-DO-NOT-SHIP", + "LOG_LEVEL": "warning", + "POSTGRES_HOST": "db.internal" + } + } + } + }); + let out = strip_generated_field_values(&def, None, &generated_contract()); + let env = &out["services"]["auth"]["environment"]; + assert_eq!(env["JWT_SECRET"], "", "generated value must be blanked"); + assert_eq!(env["LOG_LEVEL"], "warning", "editable value must survive"); + assert_eq!( + env["POSTGRES_HOST"], "db.internal", + "fixed value must survive" + ); + } + + #[test] + fn strip_blanks_generated_values_in_yaml_definition() { + let yaml = "services:\n auth:\n environment:\n JWT_SECRET: author-secret-DO-NOT-SHIP\n LOG_LEVEL: warning\n"; + let def = serde_json::Value::String(yaml.to_string()); + let out = strip_generated_field_values(&def, Some("yaml"), &generated_contract()); + let text = out.as_str().expect("yaml stays a string"); + assert!( + !text.contains("author-secret-DO-NOT-SHIP"), + "generated value gone" + ); + assert!(text.contains("warning"), "editable value survives"); + } + + #[test] + fn strip_blanks_projectform_key_value_pairs() { + let def = serde_json::json!([ + { "key": "JWT_SECRET", "value": "author-secret-DO-NOT-SHIP" }, + { "key": "POSTGRES_HOST", "value": "db.internal" } + ]); + let out = strip_generated_field_values(&def, None, &generated_contract()); + assert_eq!(out[0]["value"], "", "generated pair blanked"); + assert_eq!(out[1]["value"], "db.internal", "fixed pair survives"); + } + + #[test] + fn strip_is_noop_without_generated_fields() { + let def = serde_json::json!({ + "services": { "auth": { "environment": { "JWT_SECRET": "x" } } } + }); + let empty = serde_json::json!({ "services": {} }); + let out = strip_generated_field_values(&def, None, &empty); + assert_eq!(out, def, "no generated fields → unchanged"); + } + #[test] fn deployment_status_completed_and_running_count_as_successful() { assert!(is_successful_deployment_status("completed")); From e3151a02b2f1d4bb65005038bc6acd490761eb2e Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 12 Sep 2026 18:17:16 +0300 Subject: [PATCH 2/4] feat(marketplace): P1 backfill to re-gate the existing catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-shot pass (dry-run by default) over the latest version of every template: for each secret-shaped env key without a `generated` policy, attach one under the key`s own service and strip the author value from the stored stack_definition (reusing the publish-time strip). Existing templates then regenerate secrets per buyer instead of shipping the author`s. - helpers/field_policy_backfill.rs: service-aware planning for YAML compose; JSON/ProjectForm definitions are reported as needs-manual (no unsafe guessing). - bin/backfill_field_policy.rs: DRY-RUN by default; --apply to write. - tests: augment+strip per service, no-op when declared/no-secrets, JSON→manual. Co-Authored-By: Claude Opus 4.8 --- src/bin/backfill_field_policy.rs | 73 ++++++ src/helpers/field_policy_backfill.rs | 325 +++++++++++++++++++++++++++ src/helpers/mod.rs | 1 + 3 files changed, 399 insertions(+) create mode 100644 src/bin/backfill_field_policy.rs create mode 100644 src/helpers/field_policy_backfill.rs diff --git a/src/bin/backfill_field_policy.rs b/src/bin/backfill_field_policy.rs new file mode 100644 index 00000000..d350b853 --- /dev/null +++ b/src/bin/backfill_field_policy.rs @@ -0,0 +1,73 @@ +//! P1 one-shot: re-gate the existing marketplace catalog. +//! +//! For the latest version of every template, attach a `generated` policy to each +//! secret-shaped env key that lacks one and strip the author's value from the +//! stored `stack_definition`, so existing templates regenerate secrets per buyer +//! instead of shipping the author's. See `helpers::field_policy_backfill`. +//! +//! DRY-RUN by default — prints what would change and writes nothing. +//! Pass `--apply` to actually update rows. +//! +//! Usage: +//! DATABASE_URL=postgres://… cargo run --bin backfill_field_policy # dry-run +//! DATABASE_URL=postgres://… cargo run --bin backfill_field_policy -- --apply # write + +use stacker::helpers::field_policy_backfill; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let apply = std::env::args().any(|a| a == "--apply"); + let dry_run = !apply; + + let db_url = std::env::var("DATABASE_URL") + .map_err(|_| "set DATABASE_URL to the stacker Postgres".to_string())?; + let pool = sqlx::PgPool::connect(&db_url).await?; + + let report = field_policy_backfill::run(&pool, dry_run) + .await + .map_err(|e| e.to_string())?; + + let mode = if dry_run { + "DRY-RUN (no writes)" + } else { + "APPLIED" + }; + eprintln!("=== field-policy backfill — {mode} ==="); + eprintln!("scanned latest versions: {}", report.scanned); + eprintln!( + "templates {}: {}", + if dry_run { + "that WOULD change" + } else { + "changed" + }, + report.changed.len() + ); + for c in &report.changed { + let keys: Vec = c + .added + .iter() + .map(|(svc, key)| format!("{svc}.{key}")) + .collect(); + eprintln!(" {} [{}] +{}", c.slug, c.version_id, keys.join(", ")); + } + if !report.needs_manual.is_empty() { + eprintln!( + "\nNEEDS MANUAL REVIEW (non-YAML definitions, {} template(s)):", + report.needs_manual.len() + ); + for m in &report.needs_manual { + eprintln!( + " {} [{}] — {} — secret-shaped: [{}]", + m.slug, + m.version_id, + m.reason, + m.secret_shaped_keys.join(", ") + ); + } + } + if dry_run { + eprintln!("\nRe-run with --apply to write these changes."); + } + Ok(()) +} diff --git a/src/helpers/field_policy_backfill.rs b/src/helpers/field_policy_backfill.rs new file mode 100644 index 00000000..f3efe332 --- /dev/null +++ b/src/helpers/field_policy_backfill.rs @@ -0,0 +1,325 @@ +//! P1 backfill: re-gate the EXISTING marketplace catalog. +//! +//! The publish gate (`routes::marketplace::creator`) only runs at submit time, so +//! templates approved before it shipped can still carry the author's literal +//! secret values and never regenerate per buyer. This one-shot pass walks the +//! latest version of every template and, for each secret-shaped env key that is +//! not already declared `generated`, (a) attaches a `generated` policy under the +//! key's own service and (b) strips the author's value from the stored +//! `stack_definition` (reusing the publish-time strip). The installer then +//! regenerates per buyer; a value that somehow isn't regenerated is empty +//! (fail-closed), never the author's secret. +//! +//! Dry-run by default: `run(pool, dry_run=true)` reports what WOULD change and +//! mutates nothing. Only `dry_run=false` writes. +//! +//! Scope: YAML compose definitions are handled service-aware. JSON/ProjectForm +//! definitions are reported as `needs_manual` and left untouched — their service +//! mapping is not reliable enough to auto-place a policy, and stripping without a +//! matching policy would break installs. + +use std::collections::{BTreeMap, BTreeSet}; + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::cli::config_parser::{ConfigContract, FieldPolicy, Mutability}; +use crate::console::commands::cli::init::is_secret_env_key; + +#[derive(Debug, sqlx::FromRow)] +struct VersionRow { + id: Uuid, + slug: String, + stack_definition: serde_json::Value, + definition_format: Option, + config_contract: Option, +} + +#[derive(Debug, Default)] +pub struct BackfillReport { + pub scanned: usize, + pub changed: Vec, + pub needs_manual: Vec, +} + +#[derive(Debug)] +pub struct ChangedTemplate { + pub slug: String, + pub version_id: Uuid, + /// `(service, KEY)` pairs that gained a generated policy + had their value stripped. + pub added: Vec<(String, String)>, +} + +#[derive(Debug)] +pub struct ManualTemplate { + pub slug: String, + pub version_id: Uuid, + pub reason: String, + pub secret_shaped_keys: Vec, +} + +/// Collect `service -> {env keys}` from a YAML compose document. +fn yaml_service_env_keys(yaml_text: &str) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + let Ok(doc) = serde_yaml::from_str::(yaml_text) else { + return out; + }; + let Some(services) = doc.get("services").and_then(|s| s.as_mapping()) else { + return out; + }; + for (svc_name, svc_val) in services { + let (Some(name), Some(env)) = (svc_name.as_str(), svc_val.get("environment")) else { + continue; + }; + let keys = out.entry(name.to_string()).or_default(); + match env { + serde_yaml::Value::Mapping(map) => { + for (k, _) in map { + if let Some(k) = k.as_str() { + keys.insert(k.to_string()); + } + } + } + serde_yaml::Value::Sequence(seq) => { + for item in seq { + if let Some(entry) = item.as_str() { + if let Some(eq) = entry.find('=') { + keys.insert(entry[..eq].to_string()); + } + } + } + } + _ => {} + } + } + out +} + +/// Compute the augmented contract + stripped definition for one version. +/// Returns `Ok(None)` when nothing needs changing, `Err(reason)` when the +/// definition can't be handled automatically (JSON/ProjectForm). +pub fn plan_version( + stack_definition: &serde_json::Value, + definition_format: Option<&str>, + config_contract: &serde_json::Value, +) -> Result)>, Vec> { + let mut contract: ConfigContract = + serde_json::from_value(config_contract.clone()).unwrap_or_default(); + + if definition_format != Some("yaml") { + // JSON/ProjectForm — flag secret-shaped keys for manual review, don't guess. + let mut manual = Vec::new(); + // best-effort flat scan for reporting only + collect_flat_secret_keys(stack_definition, &mut manual); + let declared: BTreeSet = contract + .services + .values() + .flat_map(|t| t.secret_keys()) + .collect(); + manual.retain(|k| !declared.contains(k)); + if manual.is_empty() { + return Ok(None); + } + return Err(manual); + } + + let Some(yaml_text) = stack_definition.as_str() else { + return Ok(None); + }; + + let per_service = yaml_service_env_keys(yaml_text); + let mut added: Vec<(String, String)> = Vec::new(); + + for (svc, keys) in &per_service { + for key in keys { + if !is_secret_env_key(key) { + continue; + } + let already = contract + .services + .get(svc) + .and_then(|t| t.fields.get(key)) + .map(|p| p.mutability == Mutability::Generated) + .unwrap_or(false); + if already { + continue; + } + contract + .services + .entry(svc.clone()) + .or_default() + .fields + .insert(key.clone(), FieldPolicy::default_generated_secret()); + added.push((svc.clone(), key.clone())); + } + } + + if added.is_empty() { + return Ok(None); + } + + let new_contract = serde_json::to_value(&contract).map_err(|e| vec![e.to_string()])?; + // Reuse the publish-time strip so at-rest author secrets are removed too. + let new_definition = crate::routes::marketplace::creator::strip_generated_field_values( + stack_definition, + definition_format, + &new_contract, + ); + Ok(Some((new_contract, new_definition, added))) +} + +fn collect_flat_secret_keys(value: &serde_json::Value, out: &mut Vec) { + match value { + serde_json::Value::Object(map) => { + if let Some(name) = map.get("key").and_then(|v| v.as_str()) { + if map.contains_key("value") && is_secret_env_key(name) { + out.push(name.to_string()); + return; + } + } + for (k, v) in map { + if (k == "environment" || k == "env") && v.is_object() { + if let Some(env) = v.as_object() { + for ek in env.keys() { + if is_secret_env_key(ek) { + out.push(ek.clone()); + } + } + } + } + collect_flat_secret_keys(v, out); + } + } + serde_json::Value::Array(arr) => { + for item in arr { + collect_flat_secret_keys(item, out); + } + } + _ => {} + } +} + +/// Walk the latest version of every template; plan (and, unless `dry_run`, apply) +/// the backfill. +pub async fn run(pool: &PgPool, dry_run: bool) -> Result { + let rows = sqlx::query_as::<_, VersionRow>( + r#"SELECT stv.id, st.slug, stv.stack_definition, stv.definition_format, stv.config_contract + FROM stack_template_version stv + JOIN stack_template st ON st.id = stv.template_id + WHERE stv.is_latest = true"#, + ) + .fetch_all(pool) + .await + .map_err(|e| format!("failed to list versions: {e}"))?; + + let mut report = BackfillReport { + scanned: rows.len(), + ..Default::default() + }; + + for row in rows { + let contract = row + .config_contract + .clone() + .unwrap_or(serde_json::Value::Null); + match plan_version( + &row.stack_definition, + row.definition_format.as_deref(), + &contract, + ) { + Ok(None) => {} + Ok(Some((new_contract, new_definition, added))) => { + if !dry_run { + sqlx::query( + r#"UPDATE stack_template_version + SET stack_definition = $2, config_contract = $3 + WHERE id = $1"#, + ) + .bind(row.id) + .bind(&new_definition) + .bind(&new_contract) + .execute(pool) + .await + .map_err(|e| format!("update {} failed: {e}", row.id))?; + } + report.changed.push(ChangedTemplate { + slug: row.slug, + version_id: row.id, + added, + }); + } + Err(manual) => report.needs_manual.push(ManualTemplate { + slug: row.slug, + version_id: row.id, + reason: "non-yaml definition; service mapping not auto-resolved".to_string(), + secret_shaped_keys: manual, + }), + } + } + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn plans_yaml_augment_and_strip_per_service() { + let yaml = "services:\n auth:\n environment:\n JWT_SECRET: author-secret\n LOG_LEVEL: warning\n db:\n environment:\n POSTGRES_PASSWORD: author-pw\n"; + let def = json!(yaml); + let (contract, stripped, added) = + plan_version(&def, Some("yaml"), &serde_json::Value::Null) + .expect("plannable") + .expect("has changes"); + + // both secret-shaped keys get a generated policy under their own service + let added_keys: BTreeSet<(String, String)> = added.into_iter().collect(); + assert!(added_keys.contains(&("auth".to_string(), "JWT_SECRET".to_string()))); + assert!(added_keys.contains(&("db".to_string(), "POSTGRES_PASSWORD".to_string()))); + + // stored definition no longer carries the author values + let text = stripped.as_str().unwrap(); + assert!(!text.contains("author-secret")); + assert!(!text.contains("author-pw")); + assert!(text.contains("warning"), "non-secret survives"); + + // contract now declares them generated (round-trips through secret_keys) + let parsed: ConfigContract = serde_json::from_value(contract).unwrap(); + let generated: BTreeSet = parsed + .services + .values() + .flat_map(|t| t.secret_keys()) + .collect(); + assert!(generated.contains("JWT_SECRET")); + assert!(generated.contains("POSTGRES_PASSWORD")); + } + + #[test] + fn noop_when_already_declared() { + let yaml = "services:\n auth:\n environment:\n JWT_SECRET: x\n"; + let def = json!(yaml); + let contract = json!({"services":{"auth":{"fields":{"JWT_SECRET":{"mutability":"generated","type":"hex","length":32}}}}}); + assert!(plan_version(&def, Some("yaml"), &contract) + .unwrap() + .is_none()); + } + + #[test] + fn json_definition_reported_as_manual() { + let def = json!({"services":{"auth":{"environment":{"JWT_SECRET":"x"}}}}); + let err = plan_version(&def, Some("json"), &serde_json::Value::Null).unwrap_err(); + assert_eq!(err, vec!["JWT_SECRET".to_string()]); + } + + #[test] + fn noop_when_no_secret_shaped_keys() { + let yaml = + "services:\n app:\n environment:\n LOG_LEVEL: info\n PORT: '8080'\n"; + let def = json!(yaml); + assert!(plan_version(&def, Some("yaml"), &serde_json::Value::Null) + .unwrap() + .is_none()); + } +} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index f655ca5f..d741d19f 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -37,4 +37,5 @@ pub mod bake; pub mod bake_registry; pub mod cloud_init; pub mod compose_yaml; +pub mod field_policy_backfill; pub mod rate_limit; From 40d79462ae07ecc4b500c77ec741f5e2862c9ff4 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sun, 13 Sep 2026 11:21:32 +0300 Subject: [PATCH 3/4] docs: author guide for field_policy (config_contract) + publish-guide link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public, author-facing usage guide: how template authors declare per-field policy (fixed/editable/generated + generated types incl. derived_jwt) so each buyer gets freshly generated secrets, how to satisfy the publish gate, the legacy shorthand, and the validate/init workflow. Cross-linked from MARKETPLACE_PUBLISH.md. No private internals — public-safe. Co-Authored-By: Claude Opus 4.8 --- docs/FIELD_POLICY.md | 190 ++++++++++++++++++++++++++++++++++++ docs/MARKETPLACE_PUBLISH.md | 1 + 2 files changed, 191 insertions(+) create mode 100644 docs/FIELD_POLICY.md diff --git a/docs/FIELD_POLICY.md b/docs/FIELD_POLICY.md new file mode 100644 index 00000000..13a856e3 --- /dev/null +++ b/docs/FIELD_POLICY.md @@ -0,0 +1,190 @@ +# Field policy — per-buyer secrets for marketplace templates + +This guide is for **template authors**. It explains how to declare a *field +policy* in your `stacker.yml` so that every buyer of your template gets their own +freshly generated secrets — instead of your development values. + +If you publish to the marketplace, this is also what the publish check enforces: +a submission is **rejected** until every secret-shaped field has a policy. + +--- + +## Why you need it + +Your `stacker.yml` / compose almost certainly contains values like `JWT_SECRET`, +`POSTGRES_PASSWORD`, or `DASHBOARD_PASSWORD`. Those are *your* development values. +Without a field policy they would be copied verbatim into every buyer's +deployment — so every buyer, and you, would share the same secrets. That is a +security problem the moment more than one person installs your template. + +A field policy tells the platform **who controls each field's final value**: + +- values you want kept constant (a hostname, a log level default), +- values a buyer may override, +- and **secrets that must be generated fresh, per install** — the buyer never + sees your value, and no two buyers share one. + +Your literal values are never shipped to buyers for generated fields; the +platform generates a new value for each installation. + +--- + +## Where it goes + +Declare it under `config_contract` in your `stacker.yml`. Fields are grouped by +service — the service name must match the service in your compose: + +```yaml +config_contract: + services: + : # must match a service in your compose + fields: + : + mutability: fixed | editable | generated + # ...type/constraints depending on mutability +``` + +--- + +## `mutability` — who controls the value + +| `mutability` | Meaning | Use for | +|---|---|---| +| `fixed` | Your value is baked in; the buyer never changes it. | Constants: internal hostnames, ports, feature flags. | +| `editable` | Your value is a **default**; the buyer may override it. | Tunables: `LOG_LEVEL`, region, replica count. | +| `generated` | The system produces a **fresh value per install**; the buyer never enters it and never sees yours. | Every secret: passwords, API keys, JWT signing keys. | + +Fields you don't declare default to `fixed` (today's copy-through behavior) — so +you only *must* declare your secrets, but declaring the rest makes intent explicit. + +--- + +## Generated field `type`s + +Every `generated` field needs a `type` that says how to produce the value: + +| `type` | Produces | Constraints | +|---|---|---| +| `hex` | random hex string | `length` (characters) | +| `base64` | random base64 string | `length` | +| `alphanumeric` | random `[A-Za-z0-9]` string | `min_length` | +| `uuid` | a UUID v4 | — | +| `enum` | one of a fixed set | `values: [..]` (required) | +| `derived_jwt` | a JWT signed with another field | `signing_key`, `claims`, `alg` | + +For `derived_jwt`: + +- `signing_key`: `"."` — a reference to another field (usually a + `generated` secret) whose resolved value signs this token. It resolves first. +- `claims`: the JWT claims object. +- `alg`: `HS256`, `HS384`, or `HS512` (HMAC). + +`editable` fields may also carry `type` + `values` to constrain what a buyer can +enter (e.g. an `enum`). + +--- + +## Worked example + +A Supabase-style stack: + +```yaml +config_contract: + services: + auth: + fields: + POSTGRES_HOST: + mutability: fixed # constant, shipped as-is + LOG_LEVEL: + mutability: editable # buyer may change; your value is the default + type: enum + values: [debug, info, warn, error] + JWT_SECRET: + mutability: generated # fresh per buyer + type: hex + length: 32 + DASHBOARD_PASSWORD: + mutability: generated + type: alphanumeric + min_length: 20 + storage: + fields: + ANON_KEY: + mutability: generated + type: derived_jwt + signing_key: auth.JWT_SECRET # signed with this install's JWT_SECRET + claims: { role: anon, iss: supabase } + alg: HS256 +``` + +At install time each buyer gets a unique `JWT_SECRET`, a unique +`DASHBOARD_PASSWORD`, and an `ANON_KEY` signed by *their* `JWT_SECRET`. +`POSTGRES_HOST` is constant; `LOG_LEVEL` is `warn` unless the buyer picks another. + +--- + +## The publish requirement + +When you submit to the marketplace, the platform scans your template for +secret-shaped environment variables. If any of them lacks a `mutability: +generated` policy, the submission is rejected with: + +``` +config_contract is missing a `mutability: generated` policy for secret-shaped +field(s): . Declare a generator for each in config_contract before publishing. +``` + +Add a `generated` policy for each listed field and resubmit. This is the single +most common publish rejection related to secrets. + +--- + +## What buyers receive + +- `generated` → a fresh, unique value minted for their install (never yours). +- `editable` → the value they chose, or your default if they chose nothing. +- `fixed` → your value, unchanged. + +--- + +## Legacy shorthand (still supported) + +Older templates used three plain lists instead of the `fields` map. These still +parse and map as follows, so you don't have to rewrite them immediately: + +```yaml +config_contract: + services: + auth: + required: [POSTGRES_HOST] # -> mutability: fixed, required: true + optional: [LOG_LEVEL] # -> mutability: fixed, required: false + secret: [JWT_SECRET] # -> mutability: generated (alphanumeric, min 32) +``` + +Prefer the `fields` map for new templates — it lets you pick the right `type` and +length per secret. + +--- + +## Authoring workflow + +- **Validate** your config before submitting: + + ```bash + stacker config validate + ``` + +- **Local development**: `stacker init` generates a `scripts/generate-secrets.sh` + from the same policy, so your local runs fill empty secrets the same way the + marketplace install will — one declared policy drives both. +- Stacker can also **suggest** a starting contract for a stack you've built; run + `stacker config validate` first and follow its guidance. + +--- + +## Keep real secrets out of your repo anyway + +The field policy governs what **buyers** receive — it does not excuse committing +real credentials. Keep secrets in gitignored `.env` files and reference them with +`${VAR}` interpolation. See [MARKETPLACE_PUBLISH.md](./MARKETPLACE_PUBLISH.md) +for the full publishing walkthrough. diff --git a/docs/MARKETPLACE_PUBLISH.md b/docs/MARKETPLACE_PUBLISH.md index 5fcc6fd3..1f587b8f 100644 --- a/docs/MARKETPLACE_PUBLISH.md +++ b/docs/MARKETPLACE_PUBLISH.md @@ -241,6 +241,7 @@ re-purchase. | Reason | Fix | |---|---| | Embedded secrets | Replace hardcoded credentials with env vars; use `${VAR}` interpolation | +| Undeclared secret fields | Declare a `mutability: generated` policy for each secret so buyers get their own values — see [FIELD_POLICY.md](./FIELD_POLICY.md) | | Insecure defaults | Disable insecure flags (e.g. `--api.insecure=true`); restrict bind addresses; require passwords | | Stack doesn't deploy | Test on a fresh server before resubmitting; check `stacker deploy --target local` works clean | | Vague metadata | Use a specific business-problem name; describe concrete use cases | From db5910b8d645790f1848b741db5d07bf8ca2baea Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sun, 13 Sep 2026 11:36:06 +0300 Subject: [PATCH 4/4] build: ship the backfill_field_policy binary in the image The runtime image built only server/console/cleanup-notify, so the catalog backfill tool could not be run via `docker exec stacker backfill_field_policy`. Build and copy it alongside the others. Co-Authored-By: Claude Opus 4.8 --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2da14be1..a04089a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,8 @@ ENV SQLX_OFFLINE=true RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev; \ cargo build --release --bin server; \ cargo build --release --bin console --features explain; \ - cargo build --release --bin cleanup-notify + cargo build --release --bin cleanup-notify; \ + cargo build --release --bin backfill_field_policy #RUN ls -la /app/target/release/ >&2 @@ -56,6 +57,7 @@ RUN mkdir ./files && chmod 0777 ./files COPY --from=builder /app/target/release/server . COPY --from=builder /app/target/release/console . COPY --from=builder /app/target/release/cleanup-notify . +COPY --from=builder /app/target/release/backfill_field_policy . COPY --from=builder /app/.env . COPY --from=builder /app/configuration.yaml . COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx