carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The grant walk (§5): the provisioning sequence every other verb trusts.
2// Each numbered step is check-then-create, so the walk is re-runnable after a
3// failure at any point, and the secret's ONLY landing place is the app's
4// sealed config (§7) — it is delivered before it is recorded, never after.
5package objstore
6
7import (
8 "context"
9 "crypto/ecdh"
10 "errors"
11 "fmt"
12 "regexp"
13 "time"
14
15 "github.com/carlosframework/platform/internal/appconfig"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// ErrNotDeclared is a grant for an app whose member never asked for a store.
20// The operator's verb never invents the declaration: the env prefix is the
21// member's choice, and a store nobody declared has nowhere to be delivered.
22var ErrNotDeclared = errors.New("objstore: no store is declared for this app")
23
24// ErrEnvPrefixLocked is a re-declaration that tries to move the env prefix of
25// an already-provisioned store. Renaming the delivered set would leave the
26// old names in config.json holding live credentials while the app reads
27// nothing — so the prefix is fixed at first provision (the console door
28// raises this; the grant walk itself never re-reads a prefix choice).
29var ErrEnvPrefixLocked = errors.New("objstore: store already provisioned — env prefix is fixed")
30
31// ErrNotProvisioned is a rotation verb aimed at a store that was never
32// provisioned — declared-only, granted-but-half-walked, or absent entirely.
33// Rotation edits an existing credential estate; it never creates one, because
34// the coordinates it reseals (bucket, region, endpoint, IAM user) come from
35// the record, and there is no defensible guess for them. The caller's repair
36// for this state is Grant, not a rotate.
37var ErrNotProvisioned = errors.New("objstore: store is not provisioned")
38
39// ErrNoPendingFinish is a finish with nothing to finish. Finish is the one
40// destructive step in the rotation state machine — it kills a key that may
41// still be in use — so it refuses rather than succeeding vacuously: a member
42// who believes they just retired the old key, when in truth an earlier finish
43// already did, has a different picture of the estate than the estate does.
44var ErrNoPendingFinish = errors.New("objstore: no pending key to finish")
45
46// UserPath is the IAM path every store principal lives under, so an operator
47// (or a policy) can name the whole population in one place.
48const UserPath = "/carlos-store/"
49
50// Deployment is the per-deployment backend configuration the walks need: who
51// provisions (Backend), where (Region), an optional non-AWS Endpoint
52// delivered to the app, and the prefix minted bucket names carry.
53type Deployment struct {
54 Backend string
55 Region string
56 Endpoint string
57 BucketPrefix string
58}
59
60// MintedBucket is the name a store gets when the deployment mints one — the
61// adopt case (an existing bucket named in the grant) never comes through here.
62func MintedBucket(d Deployment, acct, app string) string {
63 return MintedBucketIn(d, acct, app, "")
64}
65
66// MintedBucketIn is MintedBucket for one store of an app. An environment
67// store carries the environment as a third component, so two stores of one
68// app can never resolve to one bucket.
69func MintedBucketIn(d Deployment, acct, app, env string) string {
70 name := d.BucketPrefix + acct + "-" + app
71 if env != "" {
72 name += "-" + env
73 }
74 return name
75}
76
77// UserName is the IAM principal that owns an app's store credentials. One
78// user per app: a key can be reaped or rotated without touching any other
79// app, and the user's inline policy names exactly one bucket.
80func UserName(acct, app string) string { return UserNameIn(acct, app, "") }
81
82// UserNameIn is UserName for one store of an app — one principal per STORE,
83// which is what keeps an environment store's key reapable and rotatable
84// without touching the app's own, and its inline policy naming exactly one
85// bucket.
86func UserNameIn(acct, app, env string) string {
87 name := "carlos-store-" + acct + "-" + app
88 if env != "" {
89 name += "-" + env
90 }
91 return name
92}
93
94// MaxIAMUserName is AWS's hard ceiling on an IAM user name. A name over it is
95// refused by CreateUser, deep in the walk and against live AWS — the worst
96// place to learn a store cannot be named, since the grant block is already
97// written by then and the caller is left with a record naming an estate that
98// was never created.
99const MaxIAMUserName = 64
100
101// ValidUserName gates the principal name the same way ValidBucketName gates
102// the bucket: in front of the walk, before anything is created or recorded.
103//
104// The bucket half needs no separate ceiling — bucketNameRe's own length bound
105// IS S3's 63 — but nothing bounded the IAM name before environment stores
106// added a third component to it.
107func ValidUserName(name string) bool { return name != "" && len(name) <= MaxIAMUserName }
108
109// EnvDelivered reports which of the delivered variables are NOT present in
110// the app's config manifest AS THIS PACKAGE WRITES THEM — an empty result
111// means fully delivered. _ENDPOINT counts only when wantEndpoint (a
112// deployment with no endpoint never delivers one). A manifest that does not
113// exist reads as every name missing, which is what an undelivered app is.
114//
115// Kind is part of "present": the coordinates must be plain "env" vars and
116// BOTH key halves must be "secret". A name occupied by the wrong kind counts
117// as missing, because that is what it is to the app — a key half stored as a
118// plaintext "env" var is a credential the app cannot use as one (and a
119// coordinate stored as a sealed "secret" renders from an envelope nobody
120// sealed for it). Overwriting drift like that is exactly what §7 promises a
121// grant or rotate does; reading it as healthy would leave the walk refusing
122// to repair the one state it exists to repair. Values are deliberately NOT
123// compared: this seam answers "is the set delivered", and the walk rewrites
124// every value it owns whenever it runs anyway.
125//
126// The grant walk's idempotence short-circuit and the console's missing-vars
127// report are the same question asked twice, so they ask it here: a member who
128// unsets or clobbers a delivered var must see the same "not delivered" the
129// walk acts on.
130func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix string, wantEndpoint bool) (missing []string, err error) {
131 return EnvDeliveredIn(ctx, s, acct, app, "", prefix, wantEndpoint)
132}
133
134// EnvDeliveredIn is EnvDelivered against the bundle a store actually delivers
135// into: the app's default bundle for the app's own store, and the named
136// environment's bundle for an environment store. Reading the default bundle
137// for an environment store would report every name missing on every walk —
138// the short-circuit would never hold and every re-run would mint a key.
139func EnvDeliveredIn(ctx context.Context, s release.Store, acct, app, env, prefix string, wantEndpoint bool) (missing []string, err error) {
140 var m *appconfig.Manifest
141 if env == "" {
142 m, err = appconfig.Get(ctx, s, acct, app)
143 } else {
144 m, err = appconfig.LoadEnvironment(ctx, s, acct, app, env)
145 }
146 if err != nil {
147 return nil, err
148 }
149 bucket, region, endpoint, accessKey, secretKey := EnvNames(prefix)
150 type wantVar struct{ name, kind string }
151 want := []wantVar{{bucket, "env"}, {region, "env"}}
152 if wantEndpoint {
153 want = append(want, wantVar{endpoint, "env"})
154 }
155 want = append(want, wantVar{accessKey, "secret"}, wantVar{secretKey, "secret"})
156 for _, w := range want {
157 if v, ok := m.Vars[w.name]; !ok || v.Kind != w.kind {
158 missing = append(missing, w.name)
159 }
160 }
161 return missing, nil
162}
163
164// bucketNameRe is the conservative intersection of S3 bucket naming rules:
165// 3-63 characters of lowercase letters, digits, dots and hyphens, starting
166// and ending alphanumeric. It deliberately refuses some names AWS would take
167// (uppercase and underscores are legal in some legacy us-east-1 buckets)
168// rather than admit any name that is not safely embeddable.
169var bucketNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`)
170
171// ValidBucketName gates every bucket name this package acts on — the minted
172// one and, more importantly, an operator-supplied adopt name, which is
173// interpolated straight into the IAM policy ARN the credential is scoped by.
174// An unchecked name there is a scope the operator did not mean to grant, so
175// the rail sits in front of the walk and in front of the console door (Task
176// 9 reuses this function, so the API's refusal and the walk's cannot drift).
177func ValidBucketName(name string) bool { return bucketNameRe.MatchString(name) }
178
179// Grant provisions the declared store and delivers its credentials, in spec
180// §5 order. It is idempotent: a healthy provisioned store makes a re-run a
181// no-op (no fresh key, no rewrite), and an unhealthy one — a crash anywhere
182// in the sequence — is repaired by walking it again. adoptBucket names an
183// existing bucket to adopt instead of minting one; it is honoured only on the
184// FIRST grant, since the recorded bucket is what the app's data is in.
185//
186// source is GrantSourceOperator or GrantSourceAccountApproval — why this grant
187// is authorised — and lands in the grant block beside actor. It is recorded
188// only on the FIRST grant, with the block itself: a re-run repairs an estate
189// whose authority was already established and must not restate it, exactly as
190// it must not re-choose the bucket.
191func claimRawGrant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
192 sealPub *ecdh.PublicKey, acct, app, actor, source, adoptBucket string, now time.Time) (*Record, error) {
193 return GrantIn(ctx, s, p, d, sealPub, acct, app, "", actor, source, adoptBucket, now)
194}
195
196// GrantIn is Grant for one store of an app: the app's own when env is "",
197// else the store belonging to that named config environment, whose bucket,
198// IAM principal and delivered bundle are all its own. Every property Grant's
199// doc comment states holds here unchanged — the environment is another
200// coordinate of WHICH estate, never a change to how the walk behaves.
201//
202// seed is the seed id this grant is for (GrantInFor), "" for every other
203// grant. Any grant of a record whose declaration carries another seed's
204// mark (Declared.Seed), a first grant or a re-run, clears the mark in the
205// walk's first write (with the grant block, on a first grant): the store
206// is then held by this grant, not the seed, and the seed's finish must
207// never tear it down (seeded-instances spec §4 Storage). An aborted
208// seed's bucket can leak that way, which is the safe direction. A mark
209// therefore survives only while its own seed is the only one granting.
210// A record with no mark is written exactly as before.
211func claimRawGrantIn(ctx context.Context, s release.Store, p Provisioner, d Deployment,
212 sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket, seed string, now time.Time) (*Record, error) {
213 // 1. resolve + write the grant block
214 rec, etag, err := GetIn(ctx, s, acct, app, env)
215 if err != nil {
216 return nil, err
217 }
218 if rec == nil || rec.Declared == nil {
219 return nil, ErrNotDeclared
220 }
221 // The key this walk started from, for the step-6 retry's guard below. ""
222 // is the ordinary case: a first grant, or a re-run of one that died before
223 // it recorded anything.
224 startKeyID := ""
225 if rec.Provisioned != nil {
226 startKeyID = rec.Provisioned.KeyID
227 }
228 bucket := adoptBucket
229 if rec.Grant != nil && rec.Grant.Bucket != "" {
230 bucket = rec.Grant.Bucket // a re-run never re-chooses the bucket
231 }
232 if bucket == "" {
233 bucket = MintedBucketIn(d, acct, app, env)
234 }
235 // Before anything is created or recorded: the name is about to become the
236 // IAM policy's resource ARN, so it is checked whether it was minted from a
237 // deployment prefix or handed over by an operator adopting a bucket.
238 if !ValidBucketName(bucket) {
239 return nil, fmt.Errorf("objstore: invalid bucket name %q", bucket)
240 }
241 // The principal's name is checked in the same breath, and for the same
242 // reason: an environment store's name carries a third component, so a
243 // long enough (account, app, environment) is unnameable to IAM. Learning
244 // that at CreateUser would mean a grant block already written over an
245 // estate that was never created — the shape Teardown has to treat as
246 // live. The sentence names the parts, since the member can shorten one.
247 user := UserNameIn(acct, app, env)
248 if !ValidUserName(user) {
249 return nil, fmt.Errorf("objstore: IAM user name %q is %d characters, over AWS's %d — shorten the account, app or environment name",
250 user, len(user), MaxIAMUserName)
251 }
252 // Both names must be this store's alone (#218), and that is settled here,
253 // before the grant block names them and before EnsureUser, EnsureBucket or
254 // the reap below act on them: either of those, run against a name another
255 // store holds, hands this caller that store's estate and kills its key.
256 // Every run re-checks, which is also how a store granted before claims
257 // existed gets its claims.
258 if err := claimStoreNames(ctx, s, acct, app, env, bucket, user, rec.Grant == nil, now); err != nil {
259 return nil, err
260 }
261 // A grant for any seed but the one that marked the record clears the
262 // mark, first grant or re-run, in this same write: whoever grants the
263 // store now holds it, and the seed's finish must not tear it down.
264 clearMark := rec.Declared.Seed != "" && rec.Declared.Seed != seed
265 if clearMark {
266 rec.Declared.Seed = ""
267 }
268 if rec.Grant == nil || clearMark {
269 if rec.Grant == nil {
270 rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, Source: source, At: now, By: actor}
271 }
272 if etag, err = PutIn(ctx, s, acct, app, env, rec, etag); err != nil {
273 return nil, err
274 }
275 }
276 // 2. the principal and its policy. EnsureUser runs UNCONDITIONALLY, before
277 // the idempotence short-circuit below — the same fix internal/mail's walk
278 // made as "C2", for the same reason.
279 //
280 // EnsureUser is the ONLY place this user's policy is written
281 // (PutUserPolicy overwrites wholesale), and the deny in it is recomputed
282 // from the CURRENT record on every call. Behind the short-circuit, turning
283 // enforcement on for an already-provisioned store — which is every store
284 // it would ever be turned on for — changed the record and nothing else,
285 // forever. There is no other convergence path: Rotate never writes a
286 // policy either.
287 //
288 // It needs only the bucket ARN string, resolved above, not an existing
289 // bucket, so EnsureBucket stays behind the short-circuit. EnsureUser is
290 // cheap to re-run (idempotent CreateUser, one PutUserPolicy); it is the
291 // reap-and-mint below that spends IAM's two-key ceiling, and THAT is what
292 // the short-circuit still has to gate.
293 var deny *DenyTag
294 if rec.Scanning != nil {
295 deny = rec.Scanning.Enforce
296 }
297 arn, err := p.EnsureUser(ctx, user, UserPath, "arn:aws:s3:::"+bucket, deny)
298 if err != nil {
299 return nil, fmt.Errorf("objstore: ensure user %s: %w", user, err)
300 }
301 // Idempotence short-circuit: "healthy" means the record says provisioned
302 // with THIS deployment's endpoint AND every delivered var is present, of
303 // the right kind, in config.json. Anything less and the walk runs, because
304 // a missing var means the app cannot reach its store — and an endpoint the
305 // deployment has since changed or dropped means it is reaching for the
306 // wrong one, which only a re-walk (which rewrites the set, and unsets
307 // _ENDPOINT when there is no longer an endpoint) can put right.
308 if rec.Provisioned != nil && rec.Provisioned.Endpoint == d.Endpoint {
309 missing, err := EnvDeliveredIn(ctx, s, acct, app, env, rec.Declared.EnvPrefix, d.Endpoint != "")
310 if err != nil {
311 return nil, err
312 }
313 if len(missing) == 0 {
314 return rec, nil
315 }
316 }
317 // 3. the bucket
318 region, err := p.EnsureBucket(ctx, bucket)
319 if err != nil {
320 return nil, fmt.Errorf("objstore: ensure bucket %s: %w", bucket, err)
321 }
322 // 4. key. Reap first: reaching here means delivery is missing, so any
323 // existing CURRENT key is either undelivered (its secret died with a
324 // failed config write — unrecoverable) or stranded by a crash between mint
325 // and record, which no record can name. Only list-and-delete finds both; a
326 // pending-finish key from an old rotation is kept for its explicit finish.
327 keep := []string(nil)
328 if rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "" {
329 keep = append(keep, rec.Provisioned.PrevKeyID)
330 }
331 if err := p.ReapKeys(ctx, user, keep); err != nil {
332 return nil, fmt.Errorf("objstore: reap keys for %s: %w", user, err)
333 }
334 id, secret, err := p.MintKey(ctx, user)
335 if err != nil {
336 return nil, fmt.Errorf("objstore: mint key for %s: %w", user, err)
337 }
338 // 5. deliver — the secret's ONLY landing place (§7)
339 if err := writeEnv(ctx, s, sealPub, acct, app, env, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil {
340 return nil, err
341 }
342 // 6. record success — after the secret is recorded, never before (§5).
343 // PrevKeyID carries over untouched: the reap above already removed the
344 // only stale current key, and a pending-finish key from an old rotation
345 // still deserves its explicit finish.
346 prev := ""
347 if rec.Provisioned != nil {
348 prev = rec.Provisioned.PrevKeyID
349 }
350 rec.Provisioned = &Provisioned{Bucket: bucket, Region: region, Endpoint: d.Endpoint,
351 IAMUser: user, UserARN: arn, KeyID: id, PrevKeyID: prev, At: now}
352 if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil {
353 if !errors.Is(err, release.ErrCASConflict) {
354 return nil, err
355 }
356 // Same window, same rule, same retry as Rotate's step five — see its
357 // doc comment for the full argument. A lost CAS here is the one shape
358 // of the gap that is NOT survivable: the secret has already been
359 // delivered (step 5), so the app is running on `id`, while the record
360 // would keep naming whatever it named before. The next rotation reaps
361 // every key the record does not name, and would delete the key in use.
362 //
363 // Before self-serve object storage (#381) this needed two operators
364 // running `grant` at once. Now a member's own `carlos store create`
365 // drives this walk, and the console's declare door writes this very
366 // object, so the window is ordinary rather than exotic.
367 //
368 // Re-read and try once more, carrying THEIR blocks and only our
369 // Provisioned one — but only if the record still names the key this
370 // walk started from. If it names another, a concurrent walk minted and
371 // DELIVERED a credential of its own; overwriting it would strand
372 // exactly what this retry protects against, so that conflict is the
373 // caller's to see.
374 fresh, freshEtag, gerr := GetIn(ctx, s, acct, app, env)
375 if gerr != nil {
376 return nil, err
377 }
378 freshKeyID := ""
379 if fresh != nil && fresh.Provisioned != nil {
380 freshKeyID = fresh.Provisioned.KeyID
381 }
382 if fresh == nil || freshKeyID != startKeyID {
383 return nil, err
384 }
385 fresh.Provisioned = rec.Provisioned
386 if _, perr := PutIn(ctx, s, acct, app, env, fresh, freshEtag); perr != nil {
387 return nil, perr
388 }
389 return fresh, nil
390 }
391 return rec, nil
392}
393
394// Rotate mints a fresh key, delivers it, and leaves the OLD key active (§8).
395// That overlap is the whole point: adoption swaps a symlink, it does not
396// restart processes, so at the instant the new credentials land there are
397// still hosts, in-flight requests and hibernating tenants holding the old
398// pair. Killing it here would break them; RotateFinish is the member's
399// explicit "nothing is using it any more".
400//
401// The overlap runs into AWS's hard ceiling of two access keys per user, so
402// the estate is a two-state machine — one live key, or one live plus one
403// pending — and every step below exists to keep it there:
404//
405// Step one deletes a pending key from an UNFINISHED earlier rotation.
406// Rotating twice without finishing is ordinary member behaviour, and the
407// second rotation is itself the statement that the oldest key is done.
408//
409// Step two reaps every key the record does not name. A crash between mint and
410// reseal (step four failing after step three) leaves a key no record can name;
411// without the reap the next mint wedges at the ceiling forever, and re-running
412// the walk — the platform's whole repair story — would stop working.
413//
414// Steps three and four mint, then reseal into the app's config. Coordinates
415// come from the record: a rotation changes credentials and nothing else, so
416// the bucket an app's data is in is never re-derived from a deployment that
417// may have moved on.
418//
419// Step five records, last, as in Grant. The invariant it buys is one-way: the
420// record never names a key whose secret was never delivered. A record that
421// claimed the new key after a FAILED delivery would point every reader at a
422// key whose secret died with the write, and the next run would reap the ONE
423// key still working. The reverse gap is survivable and deliberate — after a
424// successful delivery whose Put then fails, the record names the old key
425// while the app already holds the new one, and both are live, so the app
426// keeps working and a re-run converges.
427//
428// The one shape of that gap which is NOT survivable is a lost CAS: the record
429// would keep naming the old key permanently, and the next rotation's reap
430// would delete the delivered key the app is actually running on. So the final
431// Put retries once against a re-read record — but only when that re-read still
432// names the key this walk started from, i.e. no other rotation landed in
433// between. A blind retry would clobber a concurrent rotate and strand ITS
434// delivered key, which is the exact failure the retry exists to prevent.
435func claimRawRotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey,
436 acct, app string, now time.Time) (*Record, error) {
437 return RotateIn(ctx, s, p, sealPub, acct, app, "", now)
438}
439
440// RotateIn is Rotate for one store of an app. Coordinates come from that
441// store's own record, so a rotation of an environment store never reaches for
442// the app store's bucket, principal or bundle.
443func claimRawRotateIn(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey,
444 acct, app, env string, now time.Time) (*Record, error) {
445 rec, etag, err := GetIn(ctx, s, acct, app, env)
446 if err != nil {
447 return nil, err
448 }
449 if rec == nil || rec.Provisioned == nil {
450 return nil, ErrNotProvisioned
451 }
452 if rec.Declared == nil {
453 return nil, ErrNotDeclared // no prefix to deliver into
454 }
455 pr := rec.Provisioned
456 startKeyID := pr.KeyID // the key the app is holding, and the retry guard
457 // 1. finish the pending rotation late. The reap below would collect this
458 // key too, but doing it by name first keeps the intent legible and means
459 // a backend that refuses the delete stops the walk here, before a mint.
460 if pr.PrevKeyID != "" {
461 if err := p.DeleteKey(ctx, pr.IAMUser, pr.PrevKeyID); err != nil {
462 return nil, fmt.Errorf("objstore: delete pending key %s for %s: %w", pr.PrevKeyID, pr.IAMUser, err)
463 }
464 }
465 // 2. collect anything no record names — only list-and-delete finds it.
466 if err := p.ReapKeys(ctx, pr.IAMUser, []string{pr.KeyID}); err != nil {
467 return nil, fmt.Errorf("objstore: reap keys for %s: %w", pr.IAMUser, err)
468 }
469 // 3. mint into the now-guaranteed free slot
470 id, secret, err := p.MintKey(ctx, pr.IAMUser)
471 if err != nil {
472 return nil, fmt.Errorf("objstore: mint key for %s: %w", pr.IAMUser, err)
473 }
474 // 4. deliver — same names, same single CAS write, same validation of the
475 // credential halves before sealing (writeEnv owns that rail).
476 if err := writeEnv(ctx, s, sealPub, acct, app, env, rec.Declared.EnvPrefix,
477 pr.Bucket, pr.Region, pr.Endpoint, id, secret); err != nil {
478 return nil, err
479 }
480 // 5. record: the key just retired from live becomes the pending one.
481 pr.PrevKeyID, pr.KeyID, pr.At = pr.KeyID, id, now
482 if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil {
483 if !errors.Is(err, release.ErrCASConflict) {
484 return nil, err
485 }
486 // Someone else wrote this record between our Get and our Put — the
487 // console's declare/grant doors write the same object. Re-read and
488 // try once more, carrying THEIR blocks and only our Provisioned one,
489 // but only if the record still names the key we started from. If it
490 // names another key, a concurrent rotation delivered a credential of
491 // its own; overwriting it would strand exactly what we are protecting
492 // against, so the conflict is the caller's to see.
493 fresh, freshEtag, gerr := GetIn(ctx, s, acct, app, env)
494 if gerr != nil {
495 return nil, err
496 }
497 if fresh == nil || fresh.Provisioned == nil || fresh.Provisioned.KeyID != startKeyID {
498 return nil, err
499 }
500 fresh.Provisioned = pr
501 if _, perr := PutIn(ctx, s, acct, app, env, fresh, freshEtag); perr != nil {
502 return nil, perr
503 }
504 return fresh, nil
505 }
506 return rec, nil
507}
508
509// RotateFinish closes the two-key window: the pending key is deleted and the
510// record stops naming it. Nothing is delivered — the app has been reading the
511// current key since the rotation, so there is no config change to make, and
512// making one would restart nothing and risk everything.
513//
514// This is the only walk that destroys a credential an app might still be
515// using, which is why it is a separate verb a member asks for rather than a
516// timer: only the member knows every host, worker and hibernating tenant has
517// picked the new pair up.
518func claimRawRotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) {
519 return RotateFinishIn(ctx, s, p, acct, app, "")
520}
521
522// RotateFinishIn is RotateFinish for one store of an app — the pending key it
523// deletes is that store's own, named by that store's own record.
524func claimRawRotateFinishIn(ctx context.Context, s release.Store, p Provisioner, acct, app, env string) (*Record, error) {
525 rec, etag, err := GetIn(ctx, s, acct, app, env)
526 if err != nil {
527 return nil, err
528 }
529 if rec == nil || rec.Provisioned == nil {
530 return nil, ErrNotProvisioned
531 }
532 pr := rec.Provisioned
533 if pr.PrevKeyID == "" {
534 return nil, ErrNoPendingFinish
535 }
536 if err := p.DeleteKey(ctx, pr.IAMUser, pr.PrevKeyID); err != nil {
537 return nil, fmt.Errorf("objstore: delete pending key %s for %s: %w", pr.PrevKeyID, pr.IAMUser, err)
538 }
539 // Recorded after the delete: a record that forgot the key before the
540 // backend did would strand a live credential nothing names, which is the
541 // one thing the reap exists to clean up and the estate exists to avoid.
542 pr.PrevKeyID = ""
543 if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil {
544 return nil, err
545 }
546 return rec, nil
547}
548
549// Teardown dismantles a store: bucket contents, bucket, IAM principal (keys
550// and inline policy with it), the delivered config vars, and finally the
551// record. It is the shared engine under the console's "Delete now" purge and
552// its store-destroy door (§9), and it is idempotent at every step, so a
553// half-failed run is repaired by running it again.
554//
555// The ordering rule §9 states once lives here: the record is READ FIRST and
556// DELETED LAST. Everything this walk destroys is named only by the record —
557// the bucket an app's data is in, the principal holding its keys — so a
558// record deleted before the backend is a bucket nobody can find again, still
559// billing, with a live credential attached. Any failure therefore returns with
560// the record intact and provisioned; the caller (a purge) aborts on that error
561// rather than sweeping the prefix over an estate that is still standing.
562//
563// An absent record is nil, not an error: "no store" is the state teardown is
564// trying to reach, and both callers race other operators to it.
565//
566// What decides the path is the GRANT block, not the provisioned one. A record
567// carrying a grant but no `provisioned` is not "nothing to tear down": Grant
568// creates the bucket, the user and a key, and delivers the credentials, all
569// BEFORE its step-6 Put — so a lost CAS (or any crash) at that last write
570// leaves exactly this shape over a live, billing, credentialed estate. Reading
571// it as trivially deletable would erase the only record naming that estate,
572// which is precisely the failure §9's ordering rule exists to prevent. Only a
573// record with NO grant at all — declared, never acted on — dies trivially.
574func claimRawTeardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app string) error {
575 return TeardownIn(ctx, s, p, sc, acct, app, "")
576}
577
578// TeardownIn is Teardown for one store of an app. Tearing an environment
579// store down never touches the app's own store, or any other environment's:
580// the bucket, the principal and the bundle the vars come out of are all named
581// by this record alone.
582func claimRawTeardownIn(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app, env string) error {
583 rec, _, err := GetIn(ctx, s, acct, app, env)
584 if err != nil {
585 return err
586 }
587 if rec == nil {
588 return nil
589 }
590 // What this walk believes it is destroying — the guard on the final delete.
591 startKeyID, startBucket := "", ""
592 if rec.Provisioned != nil {
593 startKeyID = rec.Provisioned.KeyID
594 }
595 if rec.Grant != nil {
596 startBucket = rec.Grant.Bucket
597 }
598 if rec.Grant == nil && rec.Provisioned == nil {
599 // A grant that claimed its names and died before writing its block
600 // leaves both claims behind. The user's name is computable; the
601 // bucket it chose is recorded nowhere but in the claim itself, so
602 // the bucket claims are searched for this store's own.
603 bucket, err := claimedBucket(ctx, s, acct, app, env)
604 if err != nil {
605 return err
606 }
607 return deleteRecordIfUnchanged(ctx, s, acct, app, env, startKeyID, startBucket, bucket, UserNameIn(acct, app, env))
608 }
609 // Everything below spends backend authority, and a nil Provisioner is a
610 // deployment that has none — an ordinary shape (console Config.
611 // StoreProvisioner documents it), so this is a refusal, not an assertion.
612 // The console's destroy door already refuses an estate it cannot tear down,
613 // but it decides from a record it read a round trip earlier: a grant landing
614 // in between turns a declaration-only teardown into an estate teardown, and
615 // calling through a nil backend would panic the request rather than refuse
616 // it. The record is left exactly as found, which is what a refusal owes.
617 if p == nil {
618 return fmt.Errorf("objstore: %s/%s has backend estate and this deployment has no storage backend configured", acct, app)
619 }
620 // Coordinates: the provisioned block when there is one, since the bucket
621 // an app's data is in is whatever was recorded at provision time. Falling
622 // back to the grant covers the crashed-grant shape above — and the user
623 // name there is not a guess: EnsureUser is called with exactly
624 // UserName(acct, app), so a principal created by a grant that never
625 // recorded itself is still findable by name.
626 bucket, user := "", UserNameIn(acct, app, env)
627 switch {
628 case rec.Provisioned != nil:
629 bucket, user = rec.Provisioned.Bucket, rec.Provisioned.IAMUser
630 default:
631 bucket = rec.Grant.Bucket
632 }
633 // A bucket or user another store holds is left standing (#218, #191). Two
634 // shapes reach here: a pair whose joined names collided before claims
635 // existed, both granted; and a teardown re-run after its bucket was
636 // deleted, its name given to another store in between. Either way this
637 // record names an estate that is no longer — or never was — only its own,
638 // and deleting it would destroy the other store's data or credentials.
639 // The rest of the walk goes on: the vars and the record are this store's.
640 bucketShared, userShared, err := sharedNames(ctx, s, acct, app, env, bucket, user)
641 if err != nil {
642 return fmt.Errorf("objstore: check names for %s/%s: %w", acct, app, err)
643 }
644 estateBucket := bucket
645 if bucketShared {
646 bucket = ""
647 }
648 // The scanner goes BEFORE the bucket, while the record that names it still
649 // exists. A Malware Protection plan (and its managed EventBridge rule)
650 // outlives the bucket it protects, counting against a non-adjustable
651 // 25-buckets-per-region quota, and once the record is gone nothing names
652 // it any more — the read-first, delete-last failure this walk's ordering
653 // exists to prevent.
654 //
655 // A nil scanner over an enabled record REFUSES, for the same reason a nil
656 // provisioner does above: a teardown that cannot reach the engine must not
657 // go on to delete the only record of what it should have disabled.
658 if rec.Scanning != nil && rec.Scanning.Enabled != nil && bucket != "" {
659 if sc == nil {
660 return fmt.Errorf("objstore: %s/%s has scanning enabled and this deployment has no scanner configured (CARLOS_STORE_SCANNER)", acct, app)
661 }
662 if err := sc.EnsureDisabled(ctx, bucket); err != nil {
663 return fmt.Errorf("objstore: disable scanning on %s: %w", bucket, err)
664 }
665 }
666 // A grant block with no bucket cannot come from the walk (Grant resolves
667 // and validates a name before writing the block), so there is nothing
668 // nameable to destroy — but the deterministic user might exist, and the
669 // delivered vars might too, so only the bucket steps are skipped.
670 if bucket != "" {
671 // Empty before delete: a real backend refuses to delete a bucket with
672 // objects in it, and EmptyBucket is paginated and resumable for exactly
673 // the bucket too big to clear in one call.
674 if err := p.EmptyBucket(ctx, bucket); err != nil {
675 return fmt.Errorf("objstore: empty bucket %s: %w", bucket, err)
676 }
677 if err := p.DeleteBucket(ctx, bucket); err != nil {
678 return fmt.Errorf("objstore: delete bucket %s: %w", bucket, err)
679 }
680 }
681 // DeleteUser reaps the keys and the inline policy, so the credential dies
682 // with the estate it was scoped to — no orphan principal keeps a key that
683 // names a bucket that no longer exists.
684 //
685 // A user another store holds stays, but the keys THIS store minted on it
686 // do not: the tenant has seen them, and the shared user's policy covers
687 // the other store's bucket. Deleting a key that is already gone is
688 // success, so a re-run converges.
689 if userShared {
690 if rec.Provisioned != nil {
691 for _, id := range []string{rec.Provisioned.KeyID, rec.Provisioned.PrevKeyID} {
692 if id == "" {
693 continue
694 }
695 if err := p.DeleteKey(ctx, user, id); err != nil {
696 return fmt.Errorf("objstore: delete key %s for %s: %w", id, user, err)
697 }
698 }
699 }
700 } else if err := p.DeleteUser(ctx, user); err != nil {
701 return fmt.Errorf("objstore: delete user %s: %w", user, err)
702 }
703 // The delivered set goes even though the credentials behind it are already
704 // dead: leaving them would hand the app's next render a bucket name and a
705 // key that authenticate against nothing, which is harder to debug than an
706 // unset variable. The prefix is the member's, read back off the record;
707 // Declared should never be nil on a provisioned store (Grant refuses
708 // without it), but this is bucket data an operator can hand-edit, so the
709 // fallback is the default set rather than a panic or a stranded estate.
710 prefix := DefaultEnvPrefix
711 if rec.Declared != nil {
712 prefix = rec.Declared.EnvPrefix
713 }
714 bucketName, regionName, endpointName, accessKeyName, secretKeyName := EnvNames(prefix)
715 unsets := []string{bucketName, regionName, endpointName, accessKeyName, secretKeyName}
716 // _ENDPOINT is unset unconditionally: whether it was delivered depends on
717 // what the deployment looked like at grant time, and unsetting a var that
718 // is not there is a no-op.
719 //
720 // The guard is existence, not the unset itself: appconfig REFUSES an
721 // unset-only apply against a config object that does not exist (creating
722 // one would mint an empty manifest, which means "configured, then fully
723 // unset" to Sync, which converges every route's env file to empty as
724 // root). An app that was never configured has nothing of ours to remove
725 // and must not have that refusal strand its backend teardown — so if none
726 // of the names are present, this step is already converged.
727 if err := unsetDelivered(ctx, s, acct, app, env, unsets); err != nil {
728 return err
729 }
730 // The scan sidecar, before the record. It is the only other object under
731 // this app's prefix that this package writes, and an orphan left behind
732 // makes the app NAME unusable: the claim gate refuses a name whose prefix
733 // still holds any key, and the box-side auto-purge never reaches it
734 // because the tombstone goes last. Delete is idempotent on an absent key,
735 // so a store that was never swept costs one no-op.
736 //
737 // Only the app's own store has one. Scanning is (acct, app)-scoped
738 // throughout — Sweep's cursor, ScanResultKey, the queue — so an
739 // environment store has no sidecar to orphan, and deleting ScanKey here
740 // would destroy the APP store's sweep cursor while tearing an
741 // environment's estate down (2026-09-19 spec §9).
742 if env == "" {
743 if err := s.Delete(ctx, ScanKey(acct, app)); err != nil {
744 return fmt.Errorf("objstore: delete scan sidecar for %s/%s: %w", acct, app, err)
745 }
746 }
747 // Last (§9). Everything it named is gone.
748 return deleteRecordIfUnchanged(ctx, s, acct, app, env, startKeyID, startBucket, estateBucket, user)
749}
750
751// unsetDelivered removes the delivered set from the bundle a store delivers
752// INTO — the app's default bundle, or the named environment's.
753//
754// The presence check is why this is one helper rather than two call sites:
755// appconfig REFUSES an unset-only apply against a bundle that does not exist,
756// because creating one would mint an empty manifest, which means "configured,
757// then fully unset" to Sync — which converges every route's env file to
758// empty, as root. A store whose bundle was never configured has nothing of
759// ours to remove and must not have that refusal strand its backend teardown.
760// An environment store is the likelier shape to meet it: its bundle exists
761// only because delivery created it, so a crashed grant can leave an estate
762// with no bundle at all.
763func unsetDelivered(ctx context.Context, s release.Store, acct, app, env string, unsets []string) error {
764 var (
765 m *appconfig.Manifest
766 err error
767 )
768 if env == "" {
769 m, err = appconfig.Get(ctx, s, acct, app)
770 } else {
771 m, err = appconfig.LoadEnvironment(ctx, s, acct, app, env)
772 }
773 if err != nil {
774 return fmt.Errorf("objstore: read config for %s/%s: %w", acct, app, err)
775 }
776 present := false
777 for _, name := range unsets {
778 if _, ok := m.Vars[name]; ok {
779 present = true
780 break
781 }
782 }
783 if !present {
784 return nil
785 }
786 if env == "" {
787 if _, err := appconfig.Apply(ctx, s, acct, app, nil, unsets); err != nil {
788 return fmt.Errorf("objstore: remove store config for %s/%s: %w", acct, app, err)
789 }
790 return nil
791 }
792 if _, err := appconfig.ApplyEnvironment(ctx, s, acct, app, env, nil, unsets); err != nil {
793 return fmt.Errorf("objstore: remove store config for %s/%s environment %s: %w", acct, app, env, err)
794 }
795 return nil
796}
797
798// deleteRecordIfUnchanged is the final step of Teardown: delete the record,
799// but only if it still describes the estate the walk just destroyed.
800//
801// Store.Delete has no compare-and-swap, so this is the same shape Rotate's
802// retry uses — re-read, and act only on a record that still names what we
803// started from. The race it closes is a Grant landing mid-teardown: the
804// console's grant door writes this same object, and a grant that completes
805// after our unset has minted a NEW bucket, user and key and recorded them. A
806// blind delete there erases the record of a live estate — the §9 failure,
807// arrived at from the other end.
808//
809// The refusal wraps release.ErrCASConflict so a caller can tell it apart from
810// a backend failure: the estate the walk was aimed at IS gone, and the right
811// response is to re-run against the record as it now stands, not to retry the
812// same delete. A record that has vanished under us is success, not conflict —
813// another teardown got there first, which is the state we wanted.
814//
815// This narrows the window; it does not close it. A grant landing between the
816// re-read and the Delete still loses its record, and only a CAS delete in the
817// Store interface could fix that. The console door serialises these verbs per
818// app (internal/console/storelock.go), so the remaining window is a fraction
819// of one round trip.
820//
821// That serialisation was asserted here long before it existed — no lock of any
822// kind guarded these walks until #381 — so if this reference ever stops
823// resolving, the sentence above is a claim about nothing and this window is a
824// whole walk wide again.
825//
826// The store's name claims go in the same breath, after the re-read and before
827// the delete: given back any earlier, a grant landing mid-teardown would be
828// left holding names nothing claims; any later, a failed delete of the record
829// would leave its names claimed by a store that no longer exists.
830func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, env, startKeyID, startBucket, claimBucket, claimUser string) error {
831 fresh, _, err := GetIn(ctx, s, acct, app, env)
832 if err != nil {
833 return fmt.Errorf("objstore: re-read store record for %s/%s: %w", acct, app, err)
834 }
835 if fresh == nil {
836 return nil
837 }
838 keyID, bucket := "", ""
839 if fresh.Provisioned != nil {
840 keyID = fresh.Provisioned.KeyID
841 }
842 if fresh.Grant != nil {
843 bucket = fresh.Grant.Bucket
844 }
845 if keyID != startKeyID || bucket != startBucket {
846 return fmt.Errorf("objstore: store record for %s/%s changed during teardown — re-run: %w", acct, app, release.ErrCASConflict)
847 }
848 if err := releaseStoreNames(ctx, s, acct, app, env, claimBucket, claimUser); err != nil {
849 return err
850 }
851 if err := s.Delete(ctx, KeyIn(acct, app, env)); err != nil {
852 return fmt.Errorf("objstore: delete store record for %s/%s: %w", acct, app, err)
853 }
854 return nil
855}
856
857// writeEnv delivers the whole coordinate set in ONE appconfig.Apply — one CAS
858// write, so an app never reads a bucket name paired with the previous key.
859// Both key halves are sealed for the deployment's secrets key; the plaintext
860// secret exists nowhere else, in no other object and no log line.
861//
862// A deployment with no endpoint UNSETS the endpoint variable rather than
863// leaving whatever is there: dropping an endpoint override is a real change
864// (the app must go back to the backend's own address), and a stale
865// _ENDPOINT would keep pointing every host at an address the deployment no
866// longer uses. The set half is always non-empty, so this never trips
867// applyAt's refusal to create a bundle on an unset-only call.
868func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
869 acct, app, env, prefix, bucket, region, endpoint, keyID, secret string) error {
870 bucketName, regionName, endpointName, accessKeyName, secretKeyName := EnvNames(prefix)
871 sets := map[string]appconfig.Var{
872 bucketName: {Kind: "env", Value: bucket},
873 regionName: {Kind: "env", Value: region},
874 }
875 var unsets []string
876 if endpoint != "" {
877 sets[endpointName] = appconfig.Var{Kind: "env", Value: endpoint}
878 } else {
879 unsets = append(unsets, endpointName)
880 }
881 // Validate the credential halves BEFORE sealing. appconfig checks "env"
882 // values at set time for exactly this reason, but a sealed secret reaches
883 // it as an opaque envelope, so an unrenderable byte would sail through
884 // here and stall the app's WHOLE config render on every host, hours later
885 // and out of sight. Refusing now costs one wasted key, which the next
886 // walk's reap collects. The errors name the variable and NEVER the value:
887 // one half is a live secret, and these strings reach logs and HTTP bodies.
888 for _, half := range []struct{ name, plain string }{
889 {accessKeyName, keyID}, {secretKeyName, secret},
890 } {
891 if err := appconfig.ValidVarValue(half.plain); err != nil {
892 return fmt.Errorf("objstore: minted credential for %s is not EnvironmentFile-safe: %w", half.name, err)
893 }
894 env, err := appconfig.Seal(sealPub, appconfig.Binding{Account: acct, App: app}, []byte(half.plain))
895 if err != nil {
896 return fmt.Errorf("objstore: seal %s: %w", half.name, err)
897 }
898 sets[half.name] = appconfig.Var{Kind: "secret", Envelope: env}
899 }
900 // The bundle the store belongs to, which is the bundle the instances
901 // bound to it already read. ApplyEnvironment is the same one-write
902 // contract as Apply, touches the same per-app generation object, and
903 // applies the same refusal to create a bundle on an unset-only call —
904 // the set half here is always non-empty, so that refusal is never met.
905 if env == "" {
906 if _, err := appconfig.Apply(ctx, s, acct, app, sets, unsets); err != nil {
907 return fmt.Errorf("objstore: deliver store config for %s/%s: %w", acct, app, err)
908 }
909 return nil
910 }
911 if _, err := appconfig.ApplyEnvironment(ctx, s, acct, app, env, sets, unsets); err != nil {
912 return fmt.Errorf("objstore: deliver store config for %s/%s environment %s: %w", acct, app, env, err)
913 }
914 return nil
915}
916func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, sealPub *ecdh.PublicKey, acct, app, actor, source, adoptBucket string, now time.Time) (claimResult0 *Record, claimResult1 error) {
917 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.Grant", func(claimContext context.Context) error {
918 claimResult0, claimResult1 = claimRawGrant(claimContext, s, p, d, sealPub, acct, app, actor, source, adoptBucket, now)
919 return claimResult1
920 })
921 return
922}
923func GrantIn(ctx context.Context, s release.Store, p Provisioner, d Deployment, sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket string, now time.Time) (claimResult0 *Record, claimResult1 error) {
924 return GrantInFor(ctx, s, p, d, sealPub, acct, app, env, actor, source, adoptBucket, "", now)
925}
926
927// GrantInFor is GrantIn for a seeded create's store: seed is the seed id
928// whose mark the grant keeps. Every other grant is GrantIn, seed "".
929func GrantInFor(ctx context.Context, s release.Store, p Provisioner, d Deployment, sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket, seed string, now time.Time) (claimResult0 *Record, claimResult1 error) {
930 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.GrantIn", func(claimContext context.Context) error {
931 claimResult0, claimResult1 = claimRawGrantIn(claimContext, s, p, d, sealPub, acct, app, env, actor, source, adoptBucket, seed, now)
932 return claimResult1
933 })
934 return
935}
936func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, acct, app string, now time.Time) (claimResult0 *Record, claimResult1 error) {
937 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.Rotate", func(claimContext context.Context) error {
938 claimResult0, claimResult1 = claimRawRotate(claimContext, s, p, sealPub, acct, app, now)
939 return claimResult1
940 })
941 return
942}
943func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (claimResult0 *Record, claimResult1 error) {
944 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.RotateFinish", func(claimContext context.Context) error {
945 claimResult0, claimResult1 = claimRawRotateFinish(claimContext, s, p, acct, app)
946 return claimResult1
947 })
948 return
949}
950func RotateFinishIn(ctx context.Context, s release.Store, p Provisioner, acct, app, env string) (claimResult0 *Record, claimResult1 error) {
951 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.RotateFinishIn", func(claimContext context.Context) error {
952 claimResult0, claimResult1 = claimRawRotateFinishIn(claimContext, s, p, acct, app, env)
953 return claimResult1
954 })
955 return
956}
957func RotateIn(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, acct, app, env string, now time.Time) (claimResult0 *Record, claimResult1 error) {
958 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.RotateIn", func(claimContext context.Context) error {
959 claimResult0, claimResult1 = claimRawRotateIn(claimContext, s, p, sealPub, acct, app, env, now)
960 return claimResult1
961 })
962 return
963}
964func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app string) (claimResult0 error) {
965 claimResult0 = release.WithAppMutation(ctx, s, acct, app, "objstore.Teardown", func(claimContext context.Context) error {
966 claimResult0 = claimRawTeardown(claimContext, s, p, sc, acct, app)
967 return claimResult0
968 })
969 return
970}
971func TeardownIn(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app, env string) (claimResult0 error) {
972 claimResult0 = release.WithAppMutation(ctx, s, acct, app, "objstore.TeardownIn", func(claimContext context.Context) error {
973 claimResult0 = claimRawTeardownIn(claimContext, s, p, sc, acct, app, env)
974 return claimResult0
975 })
976 return
977}
978