| 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. |
| 5 | package objstore |
| 6 | |
| 7 | import ( |
| 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. |
| 22 | var 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). |
| 29 | var 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. |
| 37 | var 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. |
| 44 | var 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. |
| 48 | const 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. |
| 53 | type 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. |
| 62 | func 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. |
| 69 | func 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. |
| 80 | func 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. |
| 86 | func 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. |
| 99 | const 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. |
| 107 | func 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. |
| 130 | func 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. |
| 139 | func 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. |
| 169 | var 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). |
| 177 | func 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. |
| 191 | func 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. |
| 211 | func 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. |
| 435 | func 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. |
| 443 | func 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. |
| 518 | func 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. |
| 524 | func 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. |
| 574 | func 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. |
| 582 | func 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. |
| 763 | func 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. |
| 830 | func 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. |
| 868 | func 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 | } |
| 916 | func 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 | } |
| 923 | 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) (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 "". |
| 929 | func 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 | } |
| 936 | func 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 | } |
| 943 | func 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 | } |
| 950 | func 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 | } |
| 957 | func 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 | } |
| 964 | func 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 | } |
| 971 | func 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 | |