per-environment object storage: the primitive, and a purge guard that sees it
A store may now belong to a named config environment instead of to the app as a whole. Its bucket, its IAM principal and the bundle it delivers into are all its own, so one instance gets its own bucket by being the only instance bound to its environment. Nothing on a box changes: config delivery already resolves per (account, app, environment), which is why the environment — and not the route host — is what a store is keyed on. The app's own store does not move. Same key, same names, same default bundle, byte for byte; every existing record, bucket and credential is untouched and every existing test passes unchanged. API shape: Key/Get/Put and the four walks keep their signatures and delegate to KeyIn/GetIn/PutIn/GrantIn/RotateIn/RotateFinishIn/TeardownIn, which carry the implementations. Threading the environment through the existing names instead would have churned ~20 production call sites and dozens of test ones — including scanning, sweep and metering, which stay app-scoped on purpose. The purge guard is the one safety property here rather than a feature. release.PurgeApp read store.json alone; an app whose live estate was an environment store would have been swept by a box's auto-purge, leaving a paid bucket and a live IAM user that nothing remembers. It now reads every store under the app, and a listing that fails refuses the purge, the same direction as an unreadable record. Also refused up front: an (account, app, environment) whose IAM user name would exceed AWS's 64 characters. Learning that at CreateUser would leave a grant block written over an estate that was never created — the shape Teardown has to treat as live. The bucket half needed no new bound, since its regexp already carries S3's 63. Spec: docs/superpowers/specs/2026-09-19-per-environment-store-design.md Discussion: amadan carlos/platform #159
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 files changed,
+821
−51
docs/superpowers/specs/2026-09-19-per-environment-store-design.md+232 −0internal/objstore/objstore.go+68 −5internal/objstore/walk.go+187 −40internal/objstore/walk_env_test.go+304 −0internal/release/trash.go+30 −6
diff --git a/docs/superpowers/specs/2026-09-19-per-environment-store-design.md b/docs/superpowers/specs/2026-09-19-per-environment-store-design.md| new file mode 100644 |
| index 000000000..a42db9724 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-19-per-environment-store-design.md |
| @@ -0,0 +1,232 @@ |
| +# A store per environment: object storage an instance can own |
| + |
| +**Date:** 2026-09-19 |
| +**Status:** approved in direction (James, 2026-09-19). Two **(ruling)**s were |
| +taken in that conversation; everything else is a **(decision)** and can be |
| +overturned cheaply. |
| +**Discussion:** amadan `carlos/platform` #159. |
| +**Extends:** `2026-09-02-object-storage-self-serve-design.md` (the account |
| +entitlement) and the object-storage primitive it builds on. |
| + |
| +## 1. The problem, as found |
| + |
| +`store.json` is per app. `MintedBucket` is `prefix + acct + "-" + app` and |
| +`UserName` is `"carlos-store-" + acct + "-" + app` |
| +(`internal/objstore/walk.go:62,69`), and `writeEnv` delivers the coordinates |
| +through `appconfig.Apply` (`walk.go:689`) — the app's **default** bundle. So |
| +every instance of an app shares one bucket and one credential, and there is |
| +no way to give one instance its own. |
| + |
| +That is correct for an app whose instances are one deployment. It is the |
| +blocker for a managed, multi-tenant product, where one app has one instance |
| +per customer: the only options are one bucket with per-tenant key prefixes, |
| +where isolation becomes app logic and one path bug crosses tenants, or the |
| +app bringing its own S3 and re-implementing what the platform already does. |
| + |
| +### What is already right, and must stay right |
| + |
| +- **The entitlement is per ACCOUNT, not per app** (`store/entitlements/<sqid>.json`). |
| + Approval is already orthogonal to whatever unit owns a store, so nothing |
| + here adds an approval step. |
| +- **The walks are idempotent and resumable**, and their lost-CAS retries are |
| + guarded on the key the walk started from — the one shape the walk comments |
| + call "NOT survivable" (`walk.go:258-289`). |
| +- **The record's three null-or-present blocks** are what make "declared", |
| + "granted" and "provisioned" distinguishable. |
| +- **`release.PurgeApp` hand-parses `grant`/`provisioned`** out of the store |
| + record and refuses an app's auto-purge while either is present |
| + (`internal/release/trash.go:202-221`). The field names are frozen. |
| +- **The per-app walk lock exists** (`internal/console/storelock.go`). |
| + |
| +## 2. The shape, in one paragraph |
| + |
| +A store may belong to a **named config environment** instead of to the app as |
| +a whole. `carlos store create --app a --environment e` declares one; the walk |
| +mints a bucket and an IAM user named for `(account, app, environment)` and |
| +delivers the coordinates into **that environment's bundle**, which is the |
| +bundle the instances bound to it already read. An instance gets its own |
| +bucket by being the only instance bound to its environment. Nothing on a box |
| +changes. |
| + |
| +## 3. Rulings |
| + |
| +- **(ruling, James 2026-09-19) A store is keyed on `(account, app, |
| + environment)`, not on a route host.** Config delivery already resolves per |
| + `(account, app, environment)` (`appconfig.LoadMerged`, `syncexec.go`'s |
| + `groupKey`); there is no per-host config source, and adding one means a new |
| + precedence layer inside the box-side sync that every route's config passes |
| + through. Keying on the environment reuses the delivery axis exactly as it |
| + is. |
| +- **(ruling, James 2026-09-19) Teardown stays an explicit verb.** Deleting an |
| + instance or abandoning an environment never destroys a bucket. |
| + `provision.DeleteRecord` already "deliberately leaves the parked database |
| + and blobs untouched", and `objstore.Teardown` is only ever called from a |
| + door a person drove. Nothing in this change becomes the platform's first |
| + automatic destroyer of tenant data. §8 covers what stops an abandoned |
| + store from billing silently. |
| + |
| +## 4. Wire shapes |
| + |
| +**(decision) The default store does not move.** With no `--environment`, every |
| +key, name and delivery target is byte-for-byte what it is today: |
| +`apps/<acct>/<app>/store.json`, bucket `<prefix><acct>-<app>`, user |
| +`carlos-store-<acct>-<app>`, delivered to `config.json`. Existing records, |
| +buckets and credentials are untouched, and every existing test stays green |
| +unchanged. |
| + |
| +**(decision) An environment store is a sibling collection.** |
| + |
| + apps/<acct>/<app>/store.json the app's own store (unchanged) |
| + apps/<acct>/<app>/stores/<env>.json one per named environment |
| + |
| +Nothing in the bucket nests keys under an instance today; the nearest |
| +precedent is `apps/<sqid>/<app>/sidecars/<name>.json`, a flat sibling |
| +collection, and this follows it. |
| + |
| +**(decision) The record shape is `objstore.Record`, unchanged**, with one |
| +additive field: |
| + |
| +```go |
| +// Environment is the named config bundle this store belongs to. "" is the |
| +// app's own store — the only shape that existed before 2026-09-19 — and is |
| +// omitted from the JSON, so every record written before this field existed |
| +// parses as the app store it is. |
| +Environment string `json:"environment,omitempty"` |
| +``` |
| + |
| +Like `App`, it is stamped by `Put` from the key it is written to and never |
| +trusted from a caller: a record that lies about its own identity is refused, |
| +not guessed at. |
| + |
| +**(decision) Names gain the environment as a third component.** |
| + |
| + MintedBucket: <prefix><acct>-<app>-<env> |
| + UserName: carlos-store-<acct>-<app>-<env> |
| + |
| +> **Implementation gate.** Both are bounded before this lands. S3 bucket |
| +> names are at most 63 characters and IAM user names at most 64; account, |
| +> app and environment names are each already bounded by their own |
| +> validators. The walk must refuse a combination that cannot be named, |
| +> **before** any backend call, with a sentence naming which part is too |
| +> long — never a half-provisioned estate discovered at `CreateBucket`. |
| + |
| +**(decision) Delivery targets the environment's bundle.** `writeEnv` calls |
| +`appconfig.ApplyEnvironment(..., env, ...)` when `Environment != ""`, and |
| +`appconfig.Apply` otherwise. The variable names are unchanged |
| +(`<PREFIX>_BUCKET`, `_REGION`, `_ENDPOINT`, `_ACCESS_KEY`, `_SECRET_KEY`), |
| +and the two key halves are still sealed before they are written. |
| + |
| +Because a named bundle overlays the default **per key**, an app with both an |
| +app store and an environment store delivers the environment's coordinates to |
| +instances bound to it and the app's to everyone else. That is the overlay |
| +doing exactly what it already does. |
| + |
| +## 5. What a member sees |
| + |
| +``` |
| +carlos store create --app a --environment e [--env-prefix P] |
| +carlos store status --app a [--environment e] |
| +carlos store grant --app a --environment e (operator, unchanged gates) |
| +carlos store rotate --app a --environment e [--finish] |
| +carlos store destroy --app a --environment e (explicit teardown, §8) |
| +``` |
| + |
| +**(decision) `status` with no `--environment` lists every store the app has**, |
| +the app's own first, then one line per environment: its bucket, its state, |
| +and which instances read it. An app with only the default store prints what |
| +it prints today. |
| + |
| +**(decision) The doors refuse what cannot be delivered.** A store for an |
| +environment name that fails `appconfig.ValidEnvironmentName` is refused. An |
| +environment bundle that does not exist yet is **not** refused: delivery |
| +creates it, matching "an environment does not require a default to exist". |
| + |
| +**(decision) Sharing is visible, not refused.** Two instances bound to one |
| +environment share its store, exactly as they share its config. `status` says |
| +which instances read each store, so a shared bucket is something you can see |
| +rather than something you discover. |
| + |
| +## 6. Authority |
| + |
| +Unchanged in every respect. `apiAccountApp` first, then the operator |
| +allowlist for `grant`, then the write budget, then the backend gates. The |
| +account entitlement remains the single approval: **in an approved account, |
| +creating an environment store provisions it with no new approval**, which is |
| +what lets a signup path mint one without an operator in the loop. |
| + |
| +**(decision) The walk lock key gains the environment** — |
| +`acct + "/" + app + "/" + env`. Two stores of one app share no estate: their |
| +buckets, users and delivered bundles are all distinct, so serialising across |
| +them would cost concurrency and buy nothing. That is the same argument |
| +`storelock.go` already makes for two apps in one account. |
| + |
| +## 7. The purge guard |
| + |
| +`release.PurgeApp` today does one `Get` of `store.json` and refuses the |
| +purge while `grant` or `provisioned` is present. With environment stores it |
| +must also **list `apps/<acct>/<app>/stores/`** and apply the same test to |
| +every record there. |
| + |
| +**This is the one change in this spec that is a safety property rather than a |
| +feature.** A miss means an app is auto-purged while a bucket, an IAM user and |
| +a live access key survive with nothing naming them. It lands in the first |
| +slice, with the test that proves it, and `release` keeps hand-parsing the two |
| +frozen field names rather than importing `objstore`. |
| + |
| +## 8. Metering, and the abandoned store |
| + |
| +**(decision) v1 sums every store of an app into the app's existing usage |
| +key.** `storagePass` gets one `Get` per app today and writes |
| +`day.Apps[acct+"/"+app]`. It will instead read the app store plus every |
| +record under `stores/`, size each, and sum. The `StorageDay` wire shape does |
| +not change, so nothing downstream of usage has to move, and an account's bill |
| +is the same arithmetic over more buckets. |
| + |
| +Per-store attribution — what discussion #140 asks for — is a later, additive |
| +change to the usage shape, and this spec deliberately does not make it. |
| + |
| +**Because teardown is explicit (§3), an abandoned environment store keeps |
| +billing.** Three things make that visible rather than silent: |
| + |
| +- `store status` lists every store of the app, including ones no instance |
| + reads. An orphan is on the screen. |
| +- A store whose environment no bundle and no instance references is named in |
| + `status` as **unused**, in those words. |
| +- The purge guard (§7) refuses to auto-purge the app while any store is |
| + provisioned, so the estate cannot be forgotten by being trashed. |
| + |
| +## 9. Scanning |
| + |
| +**(decision) v1 scans the app's own store only.** `Sweep` is |
| +`(acct, app)`-scoped and keeps one cursor per app |
| +(`internal/objstore/sweep.go`), and the scan-result namespace is the same |
| +pair. Extending both is real work with no bearing on whether a tenant can |
| +have its own bucket. |
| + |
| +What this spec will not do is let the surface lie about it: `store status` on |
| +an environment store says scanning is not available for it yet, and the scan |
| +doors refuse `--environment` with that sentence rather than silently scanning |
| +the wrong bucket or claiming a clean result. |
| + |
| +## 10. Slices |
| + |
| +1. **The primitive.** `Environment` on the record, the key layout, the name |
| + builders and their length refusal, `writeEnv`'s delivery target, and the |
| + walks threaded through. Plus §7's purge guard and its test. No surface yet. |
| +2. **The doors.** `--environment` on create/status/grant/rotate/finish, the |
| + lock key, `storeReply` carrying the environment, and the refusals of §5. |
| +3. **The CLI and the docs.** The verbs above, `status` listing every store, |
| + and `docs/site/cli.md` — a new verb spelling is not done until that page |
| + names it. |
| +4. **Teardown.** `store destroy --environment`, the account-teardown path |
| + covering every store, and the console destroy door. |
| +5. **Metering.** §8's sum, and the "unused" line. |
| + |
| +## 11. Non-goals |
| + |
| +- Per-host stores. §3's ruling. |
| +- Automatic teardown of anything. §3's ruling. |
| +- Per-store usage attribution (#140). |
| +- Scanning environment stores (§9). |
| +- Any change to the account entitlement, its approval queue or its |
| + notifications. |
diff --git a/internal/objstore/objstore.go b/internal/objstore/objstore.go| index 6299c2abf..514bc0cc8 100644 |
| --- a/internal/objstore/objstore.go |
| +++ b/internal/objstore/objstore.go |
| @@ -14,6 +14,7 @@ import ( |
| "regexp" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -128,16 +129,45 @@ type ScanBlock struct { |
| // it has never heard of, which TestPurgeGuardIgnoresScanningBlock asserts |
| // directly. That freedom does NOT extend to `grant` and `provisioned`. |
| type Record struct { |
| - Schema int `json:"schema"` |
| - App string `json:"app"` |
| + Schema int `json:"schema"` |
| + App string `json:"app"` |
| + // Environment names the config bundle this store belongs to and delivers |
| + // into. "" is the app's own store — the only shape that existed before |
| + // 2026-09-19 — and is omitted, so every record written before this field |
| + // existed parses as the app store it is. |
| + // |
| + // Stamped by Put from the key it is written to and never trusted from a |
| + // caller, exactly as App is: a record that disagrees with its own key is |
| + // refused rather than guessed at (GetIn). |
| + Environment string `json:"environment,omitempty"` |
| Declared *Declared `json:"declared,omitempty"` |
| Grant *GrantBlock `json:"grant,omitempty"` |
| Provisioned *Provisioned `json:"provisioned,omitempty"` |
| Scanning *ScanBlock `json:"scanning,omitempty"` |
| } |
| +// Key is the app's own store — KeyIn(acct, app, "") by another name, kept |
| +// because it is what every caller that has no environment in hand asks for. |
| func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" } |
| +// StoresPrefix lists an app's ENVIRONMENT stores. The app's own store is NOT |
| +// under it: store.json predates this collection and does not move (spec §4), |
| +// so a reader after every store of an app reads Key AND lists this prefix — |
| +// which is exactly what release.PurgeApp's guard does. |
| +func StoresPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "stores/" } |
| + |
| +// KeyIn is the record key for one store of an app: the app's own when env is |
| +// "", else the store belonging to that named config environment. A sibling |
| +// collection, not a key nested under an instance — nothing in the bucket |
| +// nests under an instance, and apps/<acct>/<app>/sidecars/<name>.json is the |
| +// shape this follows. |
| +func KeyIn(acct, app, env string) string { |
| + if env == "" { |
| + return Key(acct, app) |
| + } |
| + return StoresPrefix(acct, app) + env + ".json" |
| +} |
| + |
| var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`) |
| func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) } |
| @@ -151,13 +181,31 @@ func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey str |
| // Get reads the record. Absent is (nil, "", nil) — same idiom as |
| // appconfig.getIfExists, because "no store" is a state, not an error. |
| func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { |
| + return GetIn(ctx, s, acct, app, "") |
| +} |
| + |
| +// GetIn reads one store of an app: the app's own when env is "", else the |
| +// store belonging to that named config environment. Absent is (nil, "", nil), |
| +// as in Get — "no store" is a state, not an error, for either shape. |
| +// |
| +// A record whose Environment disagrees with the key it was read from is |
| +// REFUSED, not corrected: the field is stamped from the key by PutIn, so a |
| +// disagreement means bucket data was hand-edited, and guessing which half is |
| +// right would deliver one environment's credentials under another's name. |
| +// Same rule provision.GetRecord applies to a record that lies about its own |
| +// identity. |
| +func GetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) { |
| if !release.ValidAccount(acct) { |
| return nil, "", fmt.Errorf("objstore: invalid account %q", acct) |
| } |
| if !release.ValidApp(app) { |
| return nil, "", fmt.Errorf("objstore: invalid app %q", app) |
| } |
| - raw, etag, err := s.Get(ctx, Key(acct, app)) |
| + if env != "" && !appconfig.ValidEnvironmentName(env) { |
| + return nil, "", fmt.Errorf("objstore: invalid environment %q", env) |
| + } |
| + key := KeyIn(acct, app, env) |
| + raw, etag, err := s.Get(ctx, key) |
| if err != nil { |
| if errors.Is(err, os.ErrNotExist) { |
| return nil, "", nil |
| @@ -166,7 +214,11 @@ func Get(ctx context.Context, s release.Store, acct, app string) (*Record, strin |
| } |
| var rec Record |
| if err := json.Unmarshal(raw, &rec); err != nil { |
| - return nil, "", fmt.Errorf("objstore: %s: %w", Key(acct, app), err) |
| + return nil, "", fmt.Errorf("objstore: %s: %w", key, err) |
| + } |
| + if rec.Environment != env { |
| + return nil, "", fmt.Errorf("objstore: %s names environment %q, not %q — refusing to read a record that disagrees with its own key", |
| + key, rec.Environment, env) |
| } |
| return &rec, etag, nil |
| } |
| @@ -175,11 +227,22 @@ func Get(ctx context.Context, s release.Store, acct, app string) (*Record, strin |
| // create-only. Every writer is a read-modify-write, so a lost race is |
| // release.ErrCASConflict for the caller to surface, never to absorb. |
| func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { |
| + return PutIn(ctx, s, acct, app, "", rec, etag) |
| +} |
| + |
| +// PutIn writes one store of an app, stamping App and Environment from the key |
| +// it is writing to — neither is trusted from the caller, so a record can never |
| +// disagree with where it lives (GetIn refuses one that does). |
| +func PutIn(ctx context.Context, s release.Store, acct, app, env string, rec *Record, etag string) (string, error) { |
| + if env != "" && !appconfig.ValidEnvironmentName(env) { |
| + return "", fmt.Errorf("objstore: invalid environment %q", env) |
| + } |
| rec.Schema = Schema |
| rec.App = app |
| + rec.Environment = env |
| body, err := json.MarshalIndent(rec, "", " ") |
| if err != nil { |
| return "", err |
| } |
| - return s.Put(ctx, Key(acct, app), append(body, '\n'), etag) |
| + return s.Put(ctx, KeyIn(acct, app, env), append(body, '\n'), etag) |
| } |
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go| index dd50dc0c2..be0be2403 100644 |
| --- a/internal/objstore/walk.go |
| +++ b/internal/objstore/walk.go |
| @@ -60,13 +60,51 @@ type Deployment struct { |
| // MintedBucket is the name a store gets when the deployment mints one — the |
| // adopt case (an existing bucket named in the grant) never comes through here. |
| func MintedBucket(d Deployment, acct, app string) string { |
| - return d.BucketPrefix + acct + "-" + app |
| + return MintedBucketIn(d, acct, app, "") |
| +} |
| + |
| +// MintedBucketIn is MintedBucket for one store of an app. An environment |
| +// store carries the environment as a third component, so two stores of one |
| +// app can never resolve to one bucket. |
| +func MintedBucketIn(d Deployment, acct, app, env string) string { |
| + name := d.BucketPrefix + acct + "-" + app |
| + if env != "" { |
| + name += "-" + env |
| + } |
| + return name |
| } |
| // UserName is the IAM principal that owns an app's store credentials. One |
| // user per app: a key can be reaped or rotated without touching any other |
| // app, and the user's inline policy names exactly one bucket. |
| -func UserName(acct, app string) string { return "carlos-store-" + acct + "-" + app } |
| +func UserName(acct, app string) string { return UserNameIn(acct, app, "") } |
| + |
| +// UserNameIn is UserName for one store of an app — one principal per STORE, |
| +// which is what keeps an environment store's key reapable and rotatable |
| +// without touching the app's own, and its inline policy naming exactly one |
| +// bucket. |
| +func UserNameIn(acct, app, env string) string { |
| + name := "carlos-store-" + acct + "-" + app |
| + if env != "" { |
| + name += "-" + env |
| + } |
| + return name |
| +} |
| + |
| +// MaxIAMUserName is AWS's hard ceiling on an IAM user name. A name over it is |
| +// refused by CreateUser, deep in the walk and against live AWS — the worst |
| +// place to learn a store cannot be named, since the grant block is already |
| +// written by then and the caller is left with a record naming an estate that |
| +// was never created. |
| +const MaxIAMUserName = 64 |
| + |
| +// ValidUserName gates the principal name the same way ValidBucketName gates |
| +// the bucket: in front of the walk, before anything is created or recorded. |
| +// |
| +// The bucket half needs no separate ceiling — bucketNameRe's own length bound |
| +// IS S3's 63 — but nothing bounded the IAM name before environment stores |
| +// added a third component to it. |
| +func ValidUserName(name string) bool { return name != "" && len(name) <= MaxIAMUserName } |
| // EnvDelivered reports which of the delivered variables are NOT present in |
| // the app's config manifest AS THIS PACKAGE WRITES THEM — an empty result |
| @@ -90,7 +128,21 @@ func UserName(acct, app string) string { return "carlos-store-" + acct + "-" + a |
| // unsets or clobbers a delivered var must see the same "not delivered" the |
| // walk acts on. |
| func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix string, wantEndpoint bool) (missing []string, err error) { |
| - m, err := appconfig.Get(ctx, s, acct, app) |
| + return EnvDeliveredIn(ctx, s, acct, app, "", prefix, wantEndpoint) |
| +} |
| + |
| +// EnvDeliveredIn is EnvDelivered against the bundle a store actually delivers |
| +// into: the app's default bundle for the app's own store, and the named |
| +// environment's bundle for an environment store. Reading the default bundle |
| +// for an environment store would report every name missing on every walk — |
| +// the short-circuit would never hold and every re-run would mint a key. |
| +func EnvDeliveredIn(ctx context.Context, s release.Store, acct, app, env, prefix string, wantEndpoint bool) (missing []string, err error) { |
| + var m *appconfig.Manifest |
| + if env == "" { |
| + m, err = appconfig.Get(ctx, s, acct, app) |
| + } else { |
| + m, err = appconfig.LoadEnvironment(ctx, s, acct, app, env) |
| + } |
| if err != nil { |
| return nil, err |
| } |
| @@ -138,8 +190,18 @@ func ValidBucketName(name string) bool { return bucketNameRe.MatchString(name) } |
| // it must not re-choose the bucket. |
| func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| sealPub *ecdh.PublicKey, acct, app, actor, source, adoptBucket string, now time.Time) (*Record, error) { |
| + return GrantIn(ctx, s, p, d, sealPub, acct, app, "", actor, source, adoptBucket, now) |
| +} |
| + |
| +// GrantIn is Grant for one store of an app: the app's own when env is "", |
| +// else the store belonging to that named config environment, whose bucket, |
| +// IAM principal and delivered bundle are all its own. Every property Grant's |
| +// doc comment states holds here unchanged — the environment is another |
| +// coordinate of WHICH estate, never a change to how the walk behaves. |
| +func GrantIn(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| + sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket string, now time.Time) (*Record, error) { |
| // 1. resolve + write the grant block |
| - rec, etag, err := Get(ctx, s, acct, app) |
| + rec, etag, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return nil, err |
| } |
| @@ -158,7 +220,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| bucket = rec.Grant.Bucket // a re-run never re-chooses the bucket |
| } |
| if bucket == "" { |
| - bucket = MintedBucket(d, acct, app) |
| + bucket = MintedBucketIn(d, acct, app, env) |
| } |
| // Before anything is created or recorded: the name is about to become the |
| // IAM policy's resource ARN, so it is checked whether it was minted from a |
| @@ -166,9 +228,20 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| if !ValidBucketName(bucket) { |
| return nil, fmt.Errorf("objstore: invalid bucket name %q", bucket) |
| } |
| + // The principal's name is checked in the same breath, and for the same |
| + // reason: an environment store's name carries a third component, so a |
| + // long enough (account, app, environment) is unnameable to IAM. Learning |
| + // that at CreateUser would mean a grant block already written over an |
| + // estate that was never created — the shape Teardown has to treat as |
| + // live. The sentence names the parts, since the member can shorten one. |
| + user := UserNameIn(acct, app, env) |
| + if !ValidUserName(user) { |
| + return nil, fmt.Errorf("objstore: IAM user name %q is %d characters, over AWS's %d — shorten the account, app or environment name", |
| + user, len(user), MaxIAMUserName) |
| + } |
| if rec.Grant == nil { |
| rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, Source: source, At: now, By: actor} |
| - if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil { |
| + if etag, err = PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| return nil, err |
| } |
| } |
| @@ -193,7 +266,6 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| if rec.Scanning != nil { |
| deny = rec.Scanning.Enforce |
| } |
| - user := UserName(acct, app) |
| arn, err := p.EnsureUser(ctx, user, UserPath, "arn:aws:s3:::"+bucket, deny) |
| if err != nil { |
| return nil, fmt.Errorf("objstore: ensure user %s: %w", user, err) |
| @@ -206,7 +278,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| // wrong one, which only a re-walk (which rewrites the set, and unsets |
| // _ENDPOINT when there is no longer an endpoint) can put right. |
| if rec.Provisioned != nil && rec.Provisioned.Endpoint == d.Endpoint { |
| - missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, d.Endpoint != "") |
| + missing, err := EnvDeliveredIn(ctx, s, acct, app, env, rec.Declared.EnvPrefix, d.Endpoint != "") |
| if err != nil { |
| return nil, err |
| } |
| @@ -236,7 +308,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| return nil, fmt.Errorf("objstore: mint key for %s: %w", user, err) |
| } |
| // 5. deliver — the secret's ONLY landing place (§7) |
| - if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil { |
| + if err := writeEnv(ctx, s, sealPub, acct, app, env, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil { |
| return nil, err |
| } |
| // 6. record success — after the secret is recorded, never before (§5). |
| @@ -249,7 +321,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| } |
| rec.Provisioned = &Provisioned{Bucket: bucket, Region: region, Endpoint: d.Endpoint, |
| IAMUser: user, UserARN: arn, KeyID: id, PrevKeyID: prev, At: now} |
| - if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| if !errors.Is(err, release.ErrCASConflict) { |
| return nil, err |
| } |
| @@ -271,7 +343,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| // DELIVERED a credential of its own; overwriting it would strand |
| // exactly what this retry protects against, so that conflict is the |
| // caller's to see. |
| - fresh, freshEtag, gerr := Get(ctx, s, acct, app) |
| + fresh, freshEtag, gerr := GetIn(ctx, s, acct, app, env) |
| if gerr != nil { |
| return nil, err |
| } |
| @@ -283,7 +355,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| return nil, err |
| } |
| fresh.Provisioned = rec.Provisioned |
| - if _, perr := Put(ctx, s, acct, app, fresh, freshEtag); perr != nil { |
| + if _, perr := PutIn(ctx, s, acct, app, env, fresh, freshEtag); perr != nil { |
| return nil, perr |
| } |
| return fresh, nil |
| @@ -334,7 +406,15 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| // delivered key, which is the exact failure the retry exists to prevent. |
| func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, |
| acct, app string, now time.Time) (*Record, error) { |
| - rec, etag, err := Get(ctx, s, acct, app) |
| + return RotateIn(ctx, s, p, sealPub, acct, app, "", now) |
| +} |
| + |
| +// RotateIn is Rotate for one store of an app. Coordinates come from that |
| +// store's own record, so a rotation of an environment store never reaches for |
| +// the app store's bucket, principal or bundle. |
| +func RotateIn(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, |
| + acct, app, env string, now time.Time) (*Record, error) { |
| + rec, etag, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return nil, err |
| } |
| @@ -365,13 +445,13 @@ func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| } |
| // 4. deliver — same names, same single CAS write, same validation of the |
| // credential halves before sealing (writeEnv owns that rail). |
| - if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, |
| + if err := writeEnv(ctx, s, sealPub, acct, app, env, rec.Declared.EnvPrefix, |
| pr.Bucket, pr.Region, pr.Endpoint, id, secret); err != nil { |
| return nil, err |
| } |
| // 5. record: the key just retired from live becomes the pending one. |
| pr.PrevKeyID, pr.KeyID, pr.At = pr.KeyID, id, now |
| - if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| if !errors.Is(err, release.ErrCASConflict) { |
| return nil, err |
| } |
| @@ -382,7 +462,7 @@ func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| // names another key, a concurrent rotation delivered a credential of |
| // its own; overwriting it would strand exactly what we are protecting |
| // against, so the conflict is the caller's to see. |
| - fresh, freshEtag, gerr := Get(ctx, s, acct, app) |
| + fresh, freshEtag, gerr := GetIn(ctx, s, acct, app, env) |
| if gerr != nil { |
| return nil, err |
| } |
| @@ -390,7 +470,7 @@ func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| return nil, err |
| } |
| fresh.Provisioned = pr |
| - if _, perr := Put(ctx, s, acct, app, fresh, freshEtag); perr != nil { |
| + if _, perr := PutIn(ctx, s, acct, app, env, fresh, freshEtag); perr != nil { |
| return nil, perr |
| } |
| return fresh, nil |
| @@ -408,7 +488,13 @@ func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| // timer: only the member knows every host, worker and hibernating tenant has |
| // picked the new pair up. |
| func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) { |
| - rec, etag, err := Get(ctx, s, acct, app) |
| + return RotateFinishIn(ctx, s, p, acct, app, "") |
| +} |
| + |
| +// RotateFinishIn is RotateFinish for one store of an app — the pending key it |
| +// deletes is that store's own, named by that store's own record. |
| +func RotateFinishIn(ctx context.Context, s release.Store, p Provisioner, acct, app, env string) (*Record, error) { |
| + rec, etag, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return nil, err |
| } |
| @@ -426,7 +512,7 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app |
| // backend did would strand a live credential nothing names, which is the |
| // one thing the reap exists to clean up and the estate exists to avoid. |
| pr.PrevKeyID = "" |
| - if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| return nil, err |
| } |
| return rec, nil |
| @@ -458,7 +544,15 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app |
| // which is precisely the failure §9's ordering rule exists to prevent. Only a |
| // record with NO grant at all — declared, never acted on — dies trivially. |
| func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app string) error { |
| - rec, _, err := Get(ctx, s, acct, app) |
| + return TeardownIn(ctx, s, p, sc, acct, app, "") |
| +} |
| + |
| +// TeardownIn is Teardown for one store of an app. Tearing an environment |
| +// store down never touches the app's own store, or any other environment's: |
| +// the bucket, the principal and the bundle the vars come out of are all named |
| +// by this record alone. |
| +func TeardownIn(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app, env string) error { |
| + rec, _, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return err |
| } |
| @@ -474,7 +568,7 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| startBucket = rec.Grant.Bucket |
| } |
| if rec.Grant == nil && rec.Provisioned == nil { |
| - return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket) |
| + return deleteRecordIfUnchanged(ctx, s, acct, app, env, startKeyID, startBucket) |
| } |
| // Everything below spends backend authority, and a nil Provisioner is a |
| // deployment that has none — an ordinary shape (console Config. |
| @@ -493,7 +587,7 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| // name there is not a guess: EnsureUser is called with exactly |
| // UserName(acct, app), so a principal created by a grant that never |
| // recorded itself is still findable by name. |
| - bucket, user := "", UserName(acct, app) |
| + bucket, user := "", UserNameIn(acct, app, env) |
| switch { |
| case rec.Provisioned != nil: |
| bucket, user = rec.Provisioned.Bucket, rec.Provisioned.IAMUser |
| @@ -563,7 +657,52 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| // root). An app that was never configured has nothing of ours to remove |
| // and must not have that refusal strand its backend teardown — so if none |
| // of the names are present, this step is already converged. |
| - m, err := appconfig.Get(ctx, s, acct, app) |
| + if err := unsetDelivered(ctx, s, acct, app, env, unsets); err != nil { |
| + return err |
| + } |
| + // The scan sidecar, before the record. It is the only other object under |
| + // this app's prefix that this package writes, and an orphan left behind |
| + // makes the app NAME unusable: the claim gate refuses a name whose prefix |
| + // still holds any key, and the box-side auto-purge never reaches it |
| + // because the tombstone goes last. Delete is idempotent on an absent key, |
| + // so a store that was never swept costs one no-op. |
| + // |
| + // Only the app's own store has one. Scanning is (acct, app)-scoped |
| + // throughout — Sweep's cursor, ScanResultKey, the queue — so an |
| + // environment store has no sidecar to orphan, and deleting ScanKey here |
| + // would destroy the APP store's sweep cursor while tearing an |
| + // environment's estate down (2026-09-19 spec §9). |
| + if env == "" { |
| + if err := s.Delete(ctx, ScanKey(acct, app)); err != nil { |
| + return fmt.Errorf("objstore: delete scan sidecar for %s/%s: %w", acct, app, err) |
| + } |
| + } |
| + // Last (§9). Everything it named is gone. |
| + return deleteRecordIfUnchanged(ctx, s, acct, app, env, startKeyID, startBucket) |
| +} |
| + |
| +// unsetDelivered removes the delivered set from the bundle a store delivers |
| +// INTO — the app's default bundle, or the named environment's. |
| +// |
| +// The presence check is why this is one helper rather than two call sites: |
| +// appconfig REFUSES an unset-only apply against a bundle that does not exist, |
| +// because creating one would mint an empty manifest, which means "configured, |
| +// then fully unset" to Sync — which converges every route's env file to |
| +// empty, as root. A store whose bundle was never configured has nothing of |
| +// ours to remove and must not have that refusal strand its backend teardown. |
| +// An environment store is the likelier shape to meet it: its bundle exists |
| +// only because delivery created it, so a crashed grant can leave an estate |
| +// with no bundle at all. |
| +func unsetDelivered(ctx context.Context, s release.Store, acct, app, env string, unsets []string) error { |
| + var ( |
| + m *appconfig.Manifest |
| + err error |
| + ) |
| + if env == "" { |
| + m, err = appconfig.Get(ctx, s, acct, app) |
| + } else { |
| + m, err = appconfig.LoadEnvironment(ctx, s, acct, app, env) |
| + } |
| if err != nil { |
| return fmt.Errorf("objstore: read config for %s/%s: %w", acct, app, err) |
| } |
| @@ -574,22 +713,19 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| break |
| } |
| } |
| - if present { |
| + if !present { |
| + return nil |
| + } |
| + if env == "" { |
| if _, err := appconfig.Apply(ctx, s, acct, app, nil, unsets); err != nil { |
| return fmt.Errorf("objstore: remove store config for %s/%s: %w", acct, app, err) |
| } |
| + return nil |
| } |
| - // The scan sidecar, before the record. It is the only other object under |
| - // this app's prefix that this package writes, and an orphan left behind |
| - // makes the app NAME unusable: the claim gate refuses a name whose prefix |
| - // still holds any key, and the box-side auto-purge never reaches it |
| - // because the tombstone goes last. Delete is idempotent on an absent key, |
| - // so a store that was never swept costs one no-op. |
| - if err := s.Delete(ctx, ScanKey(acct, app)); err != nil { |
| - return fmt.Errorf("objstore: delete scan sidecar for %s/%s: %w", acct, app, err) |
| + if _, err := appconfig.ApplyEnvironment(ctx, s, acct, app, env, nil, unsets); err != nil { |
| + return fmt.Errorf("objstore: remove store config for %s/%s environment %s: %w", acct, app, env, err) |
| } |
| - // Last (§9). Everything it named is gone. |
| - return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket) |
| + return nil |
| } |
| // deleteRecordIfUnchanged is the final step of Teardown: delete the record, |
| @@ -619,8 +755,8 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| // kind guarded these walks until #381 — so if this reference ever stops |
| // resolving, the sentence above is a claim about nothing and this window is a |
| // whole walk wide again. |
| -func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, startKeyID, startBucket string) error { |
| - fresh, _, err := Get(ctx, s, acct, app) |
| +func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, env, startKeyID, startBucket string) error { |
| + fresh, _, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return fmt.Errorf("objstore: re-read store record for %s/%s: %w", acct, app, err) |
| } |
| @@ -637,7 +773,7 @@ func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, st |
| if keyID != startKeyID || bucket != startBucket { |
| return fmt.Errorf("objstore: store record for %s/%s changed during teardown — re-run: %w", acct, app, release.ErrCASConflict) |
| } |
| - if err := s.Delete(ctx, Key(acct, app)); err != nil { |
| + if err := s.Delete(ctx, KeyIn(acct, app, env)); err != nil { |
| return fmt.Errorf("objstore: delete store record for %s/%s: %w", acct, app, err) |
| } |
| return nil |
| @@ -655,7 +791,7 @@ func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, st |
| // longer uses. The set half is always non-empty, so this never trips |
| // applyAt's refusal to create a bundle on an unset-only call. |
| func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, |
| - acct, app, prefix, bucket, region, endpoint, keyID, secret string) error { |
| + acct, app, env, prefix, bucket, region, endpoint, keyID, secret string) error { |
| bucketName, regionName, endpointName, accessKeyName, secretKeyName := EnvNames(prefix) |
| sets := map[string]appconfig.Var{ |
| bucketName: {Kind: "env", Value: bucket}, |
| @@ -686,8 +822,19 @@ func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, |
| } |
| sets[half.name] = appconfig.Var{Kind: "secret", Envelope: env} |
| } |
| - if _, err := appconfig.Apply(ctx, s, acct, app, sets, unsets); err != nil { |
| - return fmt.Errorf("objstore: deliver store config for %s/%s: %w", acct, app, err) |
| + // The bundle the store belongs to, which is the bundle the instances |
| + // bound to it already read. ApplyEnvironment is the same one-write |
| + // contract as Apply, touches the same per-app generation object, and |
| + // applies the same refusal to create a bundle on an unset-only call — |
| + // the set half here is always non-empty, so that refusal is never met. |
| + if env == "" { |
| + if _, err := appconfig.Apply(ctx, s, acct, app, sets, unsets); err != nil { |
| + return fmt.Errorf("objstore: deliver store config for %s/%s: %w", acct, app, err) |
| + } |
| + return nil |
| + } |
| + if _, err := appconfig.ApplyEnvironment(ctx, s, acct, app, env, sets, unsets); err != nil { |
| + return fmt.Errorf("objstore: deliver store config for %s/%s environment %s: %w", acct, app, env, err) |
| } |
| return nil |
| } |
diff --git a/internal/objstore/walk_env_test.go b/internal/objstore/walk_env_test.go| new file mode 100644 |
| index 000000000..56363f7fd |
| --- /dev/null |
| +++ b/internal/objstore/walk_env_test.go |
| @@ -0,0 +1,304 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Environment stores (2026-09-19 spec): a store may belong to a named config |
| +// environment instead of to the app as a whole, and delivers into THAT |
| +// bundle. Everything here is about the boundary between the two — an |
| +// environment store and the app's own store share an app and nothing else. |
| + |
| +// declareIn is declare for an environment store: the member half of the |
| +// record, written at the environment's own key. |
| +func declareIn(t *testing.T, s release.Store, acct, app, env, prefix string) { |
| + t.Helper() |
| + rec := &Record{Declared: &Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: "member@example.com"}} |
| + if _, err := PutIn(context.Background(), s, acct, app, env, rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func envVars(t *testing.T, s release.Store, acct, app, env string) map[string]appconfig.Var { |
| + t.Helper() |
| + m, err := appconfig.LoadEnvironment(context.Background(), s, acct, app, env) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return m.Vars |
| +} |
| + |
| +// TestGrantInDeliversToTheEnvironmentBundle is the whole feature in one test: |
| +// the estate is named for the environment, and the credentials land where the |
| +// instances bound to that environment actually read — not in the app's |
| +// default bundle, which is what every instance NOT bound to it reads. |
| +func TestGrantInDeliversToTheEnvironmentBundle(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + priv, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + // Fix the secret the backend hands back, so the assertion below is about |
| + // the value that actually reached the bundle rather than about two |
| + // generated strings agreeing. |
| + p.MintSecret = "beta-minted-secret" |
| + declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") |
| + |
| + rec, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := rec.Provisioned.Bucket, "carlos-store-tito-bab-titogo-beta"; got != want { |
| + t.Errorf("bucket = %q, want %q", got, want) |
| + } |
| + if got, want := rec.Provisioned.IAMUser, "carlos-store-bab-titogo-beta"; got != want { |
| + t.Errorf("IAM user = %q, want %q", got, want) |
| + } |
| + if rec.Environment != "beta" { |
| + t.Errorf("record environment = %q, want %q", rec.Environment, "beta") |
| + } |
| + |
| + // Delivered into the environment's bundle, sealed, and openable to the |
| + // secret the backend actually minted. |
| + vars := envVars(t, s, "bab", "titogo", "beta") |
| + if got := vars["TITO_S3_BUCKET"].Value; got != "carlos-store-tito-bab-titogo-beta" { |
| + t.Errorf("TITO_S3_BUCKET in the beta bundle = %q", got) |
| + } |
| + sealed := vars["TITO_S3_SECRET_KEY"] |
| + if sealed.Kind != "secret" || sealed.Envelope == "" { |
| + t.Fatalf("TITO_S3_SECRET_KEY = %+v, want a sealed secret", sealed) |
| + } |
| + plain, err := appconfig.Open(priv, sealed.Envelope) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(plain) != p.MintSecret { |
| + t.Errorf("delivered secret = %q, want the minted %q", plain, p.MintSecret) |
| + } |
| + |
| + // And NOT into the default bundle. An instance with no environment |
| + // binding must not be handed another environment's credentials. |
| + for name := range mustVars(t, s, "bab", "titogo") { |
| + if strings.HasPrefix(name, "TITO_S3_") { |
| + t.Errorf("default bundle carries %s — an environment store delivered into the app's own bundle", name) |
| + } |
| + } |
| + |
| + // A re-run is a no-op. This is the assertion that fails if the walk's |
| + // idempotence short-circuit reads the DEFAULT bundle: every name would |
| + // read as missing, and every walk would reap and mint a fresh key. |
| + keyID := rec.Provisioned.KeyID |
| + again, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if again.Provisioned.KeyID != keyID { |
| + t.Errorf("re-run minted a fresh key (%s -> %s)", keyID, again.Provisioned.KeyID) |
| + } |
| +} |
| + |
| +// TestEnvironmentStoreAndAppStoreAreSeparateEstates pins the isolation two |
| +// stores of one app must have: their own bucket, their own principal, their |
| +// own record, their own bundle. |
| +func TestEnvironmentStoreAndAppStoreAreSeparateEstates(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + d := testDeployment() |
| + |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + appRec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") |
| + envRec, err := GrantIn(ctx, s, p, d, pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if appRec.Provisioned.Bucket == envRec.Provisioned.Bucket { |
| + t.Errorf("both stores resolved to one bucket %q", appRec.Provisioned.Bucket) |
| + } |
| + if appRec.Provisioned.IAMUser == envRec.Provisioned.IAMUser { |
| + t.Errorf("both stores resolved to one IAM user %q", appRec.Provisioned.IAMUser) |
| + } |
| + if appRec.Provisioned.KeyID == envRec.Provisioned.KeyID { |
| + t.Error("both stores hold one access key") |
| + } |
| + if got := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"].Value; got != appRec.Provisioned.Bucket { |
| + t.Errorf("default bundle bucket = %q, want the app store's %q", got, appRec.Provisioned.Bucket) |
| + } |
| + if got := envVars(t, s, "bab", "titogo", "beta")["TITO_S3_BUCKET"].Value; got != envRec.Provisioned.Bucket { |
| + t.Errorf("beta bundle bucket = %q, want the environment store's %q", got, envRec.Provisioned.Bucket) |
| + } |
| +} |
| + |
| +// TestGetInRefusesARecordThatDisagreesWithItsKey: Environment is stamped from |
| +// the key, so a record naming another environment is hand-edited bucket data. |
| +// Guessing which half is right would deliver one environment's credentials |
| +// under another's name. |
| +func TestGetInRefusesARecordThatDisagreesWithItsKey(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + // Write a beta-stamped record at the APP store's key, past PutIn's |
| + // stamping, the way a hand edit would. |
| + body := []byte(`{"schema":1,"app":"titogo","environment":"beta"}` + "\n") |
| + if _, err := s.Put(ctx, Key("bab", "titogo"), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, err := Get(ctx, s, "bab", "titogo") |
| + if err == nil || !strings.Contains(err.Error(), "disagrees with its own key") { |
| + t.Fatalf("Get over a mis-stamped record = %v, want a refusal", err) |
| + } |
| +} |
| + |
| +// TestGrantInRefusesAnUnnameablePrincipal: the third name component can push |
| +// the IAM user name over AWS's ceiling. That must be refused in front of the |
| +// walk — a CreateUser failure would come after the grant block was written, |
| +// leaving a record naming an estate that was never created, which Teardown |
| +// then has to treat as live. |
| +// |
| +// The fixture needs a SHORT bucket prefix, and that is the whole shape of |
| +// this guard. Both names carry the same body: the bucket is |
| +// <prefix><body> against S3's 63, the principal is "carlos-store-"<body> |
| +// against IAM's 64. So the IAM ceiling is only reachable when the prefix is |
| +// shorter than the 13 characters of "carlos-store-" — under a longer one |
| +// (the flagship's "carlos-store-tito-") the bucket refusal always fires |
| +// first, and ValidUserName never speaks. Here: body 61, bucket exactly 63 |
| +// and legal, principal 74 and refused. |
| +func TestGrantInRefusesAnUnnameablePrincipal(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + d := Deployment{Backend: "aws", Region: "eu-west-1", BucketPrefix: "s-"} |
| + app := strings.Repeat("a", 24) // ValidApp caps at 32 |
| + env := strings.Repeat("e", 32) // ValidEnvironmentName caps at 32 |
| + declareIn(t, s, "bab", app, env, "TITO_S3") |
| + |
| + if got := len(MintedBucketIn(d, "bab", app, env)); got != 63 { |
| + t.Fatalf("fixture drift: bucket name is %d characters, want 63 (legal)", got) |
| + } |
| + _, err := GrantIn(ctx, s, p, d, pub, "bab", app, env, "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err == nil || !strings.Contains(err.Error(), "over AWS's 64") { |
| + t.Fatalf("GrantIn with an unnameable principal = %v, want the length refusal", err) |
| + } |
| + if len(p.Buckets) != 0 || len(p.Users) != 0 { |
| + t.Errorf("refused grant touched the backend: buckets %v users %v", p.Buckets, p.Users) |
| + } |
| + rec, _, err := GetIn(ctx, s, "bab", app, env) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Grant != nil { |
| + t.Error("refused grant wrote a grant block") |
| + } |
| +} |
| + |
| +// TestTeardownInLeavesTheAppStoreStanding is the blast-radius test. Tearing |
| +// an environment store down must not touch the app's own estate, its bundle, |
| +// or — the one this spec had to guard explicitly — its scan sidecar, which is |
| +// the app-scoped sweep cursor an environment store does not have. |
| +func TestTeardownInLeavesTheAppStoreStanding(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + d := testDeployment() |
| + |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + appRec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") |
| + envRec, err := GrantIn(ctx, s, p, d, pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The app's sweep cursor, which only the app store owns. |
| + if _, err := s.Put(ctx, ScanKey("bab", "titogo"), []byte(`{"schema":1}`+"\n"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := TeardownIn(ctx, s, p, nil, "bab", "titogo", "beta"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The environment's estate is gone. |
| + if _, ok := p.Buckets[envRec.Provisioned.Bucket]; ok { |
| + t.Error("environment bucket survived teardown") |
| + } |
| + if _, ok := p.Users[envRec.Provisioned.IAMUser]; ok { |
| + t.Error("environment IAM user survived teardown") |
| + } |
| + if gone, _, err := GetIn(ctx, s, "bab", "titogo", "beta"); err != nil || gone != nil { |
| + t.Errorf("environment record survived teardown: %+v, %v", gone, err) |
| + } |
| + for name := range envVars(t, s, "bab", "titogo", "beta") { |
| + if strings.HasPrefix(name, "TITO_S3_") { |
| + t.Errorf("beta bundle still carries %s", name) |
| + } |
| + } |
| + |
| + // The app's own estate is untouched. |
| + if _, ok := p.Buckets[appRec.Provisioned.Bucket]; !ok { |
| + t.Error("app bucket was destroyed by an environment teardown") |
| + } |
| + if _, ok := p.Users[appRec.Provisioned.IAMUser]; !ok { |
| + t.Error("app IAM user was destroyed by an environment teardown") |
| + } |
| + still, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || still == nil || still.Provisioned == nil { |
| + t.Fatalf("app store record = %+v, %v — want it standing", still, err) |
| + } |
| + if got := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"].Value; got != appRec.Provisioned.Bucket { |
| + t.Errorf("app bundle lost its coordinates: TITO_S3_BUCKET = %q", got) |
| + } |
| + if _, _, err := s.Get(ctx, ScanKey("bab", "titogo")); err != nil { |
| + t.Errorf("an environment teardown deleted the app's scan sidecar: %v", err) |
| + } |
| +} |
| + |
| +// TestPurgeGuardSeesAnEnvironmentStore extends the parse agreement to the |
| +// newer shape. An app whose ONLY live estate is an environment store must |
| +// still refuse the box-side auto-purge: the guard reads store.json and every |
| +// stores/<env>.json, because a swept record is a paid bucket and a live IAM |
| +// user that nothing remembers. |
| +func TestPurgeGuardSeesAnEnvironmentStore(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + relPub, _, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // No app store at all — only an environment one. |
| + declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") |
| + if _, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + backdated := time.Now().Add(-2 * release.TrashCooloff) |
| + if err := release.TrashApp(ctx, s, "bab", "titogo", "paul@keymail.dev", backdated); err != nil { |
| + t.Fatal(err) |
| + } |
| + err = release.PurgeApp(ctx, s, relPub, "bab", "titogo", time.Now()) |
| + if !errors.Is(err, release.ErrStoreProvisioned) { |
| + t.Fatalf("PurgeApp over a live environment store = %v, want release.ErrStoreProvisioned", err) |
| + } |
| + if _, _, err := s.Get(ctx, KeyIn("bab", "titogo", "beta")); err != nil { |
| + t.Fatalf("the refused purge destroyed the environment store record: %v", err) |
| + } |
| +} |
diff --git a/internal/release/trash.go b/internal/release/trash.go| index b802099de..bf4739902 100644 |
| --- a/internal/release/trash.go |
| +++ b/internal/release/trash.go |
| @@ -209,21 +209,45 @@ func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str |
| // Unreadable — transport error or bytes that will not parse — refuses too, |
| // the same direction as the production flag: "I could not ask" is not "no |
| // store". Absent is fine; most apps never declare one. |
| - if raw, _, err := s.Get(ctx, prefix+"store.json"); err == nil { |
| + // EVERY store of the app, not just store.json. Since 2026-09-19 a store |
| + // may belong to a named config environment, at |
| + // apps/<acct>/<app>/stores/<env>.json, and each one names backend estate |
| + // exactly as the app's own does. A guard that read store.json alone would |
| + // let a box sweep an app whose environment stores are live — this guard's |
| + // own failure, reached through the door the newer shape opened. |
| + // |
| + // The listing is part of the guard, so a listing that FAILS refuses the |
| + // purge, the same direction as an unreadable record: "I could not ask" is |
| + // not "no store". List returns nested keys under a prefix in both store |
| + // implementations (DirStore walks the root; S3Store paginates with no |
| + // delimiter), which is what makes one listing enough. |
| + storeKeys := []string{prefix + "store.json"} |
| + envStores, lerr := s.List(ctx, prefix+"stores/") |
| + if lerr != nil { |
| + return fmt.Errorf("release: %s/%s: cannot list %sstores/ — refusing to purge: %w: %v", |
| + acct, app, prefix, ErrStoreProvisioned, lerr) |
| + } |
| + storeKeys = append(storeKeys, envStores...) |
| + for _, storeKey := range storeKeys { |
| + raw, _, err := s.Get(ctx, storeKey) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue // absent is fine; most apps never declare one |
| + } |
| + return fmt.Errorf("release: %s/%s: cannot read %s — refusing to purge: %w: %v", |
| + acct, app, storeKey, ErrStoreProvisioned, err) |
| + } |
| var rec struct { |
| Grant *json.RawMessage `json:"grant"` |
| Provisioned *json.RawMessage `json:"provisioned"` |
| } |
| if jerr := json.Unmarshal(raw, &rec); jerr != nil { |
| - return fmt.Errorf("release: %s/%s: unparseable %sstore.json — refusing to purge: %w: %v", |
| - acct, app, prefix, ErrStoreProvisioned, jerr) |
| + return fmt.Errorf("release: %s/%s: unparseable %s — refusing to purge: %w: %v", |
| + acct, app, storeKey, ErrStoreProvisioned, jerr) |
| } |
| if rec.Provisioned != nil || rec.Grant != nil { |
| return fmt.Errorf("%s/%s: %w", acct, app, ErrStoreProvisioned) |
| } |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| - return fmt.Errorf("release: %s/%s: cannot read %sstore.json — refusing to purge: %w: %v", |
| - acct, app, prefix, ErrStoreProvisioned, err) |
| } |
| marker := AppTombstoneKey(acct, app) |
| keys, err := s.List(ctx, prefix) |