Skip to content
Merged
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
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
190 changes: 190 additions & 0 deletions docs/FIELD_POLICY.md
Original file line number Diff line number Diff line change
@@ -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:
<service-name>: # must match a service in your compose
fields:
<ENV_VAR_NAME>:
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`: `"<service>.<FIELD>"` — 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): <NAMES>. 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.
1 change: 1 addition & 0 deletions docs/MARKETPLACE_PUBLISH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
73 changes: 73 additions & 0 deletions src/bin/backfill_field_policy.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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<String> = 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(())
}
Loading
Loading