| 1 | // Name claims: one store per bucket name and per IAM user name (#218). |
| 2 | // |
| 3 | // MintedBucketIn and UserNameIn join account, app and environment with "-", |
| 4 | // and app and environment names may both contain "-" — so app "shop" with |
| 5 | // environment "eu" and app "shop-eu" resolve to ONE bucket and ONE user. |
| 6 | // Nothing downstream notices: EnsureBucket takes BucketAlreadyOwnedByYou as |
| 7 | // success, EnsureUser adopts an existing user, the grant walk's reap deletes |
| 8 | // the other store's live key, and a later Teardown empties and deletes the |
| 9 | // other store's bucket. An operator adopting a bucket by name reaches the |
| 10 | // same place by a typo. |
| 11 | // |
| 12 | // The names themselves cannot change — production buckets and users carry |
| 13 | // them — so the fix is to make a name belong to one store. Same discipline as |
| 14 | // internal/mail/domainclaim.go: a create-only bucket key per name, GLOBAL |
| 15 | // rather than per account (an adopted bucket name is not scoped to the |
| 16 | // account that adopts it), whose write is the uniqueness check. Two walks |
| 17 | // racing for one name cannot both win, in one console or in two, whichever |
| 18 | // store was declared first. |
| 19 | // |
| 20 | // Stores granted before claims existed hold none. The walk backfills a claim |
| 21 | // on its next run, and until then two checks stand in for it: the stores in |
| 22 | // the same account whose names collide by construction are few and |
| 23 | // computable (collidingStores), so they are read directly; and every first |
| 24 | // grant scans every store record, which is the only way to find a name an |
| 25 | // unclaimed record holds in some other account (an operator's adopt can be |
| 26 | // shaped exactly like another account's minted name). |
| 27 | package objstore |
| 28 | |
| 29 | import ( |
| 30 | "context" |
| 31 | "encoding/json" |
| 32 | "errors" |
| 33 | "fmt" |
| 34 | "os" |
| 35 | "path" |
| 36 | "strings" |
| 37 | "time" |
| 38 | |
| 39 | "github.com/carlosframework/platform/internal/appconfig" |
| 40 | "github.com/carlosframework/platform/internal/release" |
| 41 | ) |
| 42 | |
| 43 | // ErrStoreNameTaken refuses a grant whose bucket or IAM user name already |
| 44 | // belongs to another store. It is raised before anything is created, |
| 45 | // recorded or reaped. |
| 46 | var ErrStoreNameTaken = errors.New("objstore: bucket or IAM user name belongs to another store") |
| 47 | |
| 48 | const nameClaimsPrefix = "objstore/name-claims/" |
| 49 | |
| 50 | func bucketClaimKey(bucket string) string { return nameClaimsPrefix + "buckets/" + bucket + ".json" } |
| 51 | func userClaimKey(user string) string { return nameClaimsPrefix + "users/" + user + ".json" } |
| 52 | |
| 53 | // NameClaim is the wire shape at a claim key: which store holds the name. |
| 54 | type NameClaim struct { |
| 55 | Schema int `json:"schema"` // 1 |
| 56 | Name string `json:"name"` |
| 57 | Account string `json:"account"` |
| 58 | App string `json:"app"` |
| 59 | Environment string `json:"environment,omitempty"` |
| 60 | CreatedAt time.Time `json:"created_at"` |
| 61 | } |
| 62 | |
| 63 | func (c *NameClaim) heldBy(acct, app, env string) bool { |
| 64 | return c.Account == acct && c.App == app && c.Environment == env |
| 65 | } |
| 66 | |
| 67 | // getNameClaim reads a claim; absent is (nil, nil). |
| 68 | func getNameClaim(ctx context.Context, s release.Store, key string) (*NameClaim, error) { |
| 69 | raw, _, err := s.Get(ctx, key) |
| 70 | if errors.Is(err, os.ErrNotExist) { |
| 71 | return nil, nil |
| 72 | } |
| 73 | if err != nil { |
| 74 | return nil, err |
| 75 | } |
| 76 | var c NameClaim |
| 77 | if err := json.Unmarshal(raw, &c); err != nil { |
| 78 | return nil, fmt.Errorf("objstore: name claim %s: %w", key, err) |
| 79 | } |
| 80 | if c.Schema > 1 { |
| 81 | return nil, fmt.Errorf("objstore: name claim %s: schema %d is newer than this binary understands — upgrade carlos", key, c.Schema) |
| 82 | } |
| 83 | return &c, nil |
| 84 | } |
| 85 | |
| 86 | // claimName takes key for the store, or confirms the store already holds it. |
| 87 | // The create-only Put is the check; a lost create race re-reads and judges |
| 88 | // the winner, as claimDomain does. |
| 89 | func claimName(ctx context.Context, s release.Store, key, name, acct, app, env string, now time.Time) error { |
| 90 | body, err := json.Marshal(NameClaim{Schema: 1, Name: name, Account: acct, App: app, Environment: env, CreatedAt: now}) |
| 91 | if err != nil { |
| 92 | return err |
| 93 | } |
| 94 | for attempt := 0; ; attempt++ { |
| 95 | cur, err := getNameClaim(ctx, s, key) |
| 96 | if err != nil { |
| 97 | return err |
| 98 | } |
| 99 | if cur != nil { |
| 100 | if !cur.heldBy(acct, app, env) { |
| 101 | return fmt.Errorf("%w: %s", ErrStoreNameTaken, name) |
| 102 | } |
| 103 | return nil |
| 104 | } |
| 105 | _, err = s.Put(ctx, key, body, "") |
| 106 | if err == nil { |
| 107 | return nil |
| 108 | } |
| 109 | if !errors.Is(err, release.ErrCASConflict) || attempt > 0 { |
| 110 | return err |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // releaseName deletes key only when this store holds it. A claim held by |
| 116 | // another store is theirs, and an absent one is already released. |
| 117 | func releaseName(ctx context.Context, s release.Store, key, acct, app, env string) error { |
| 118 | cur, err := getNameClaim(ctx, s, key) |
| 119 | if err != nil { |
| 120 | return err |
| 121 | } |
| 122 | if cur == nil || !cur.heldBy(acct, app, env) { |
| 123 | return nil |
| 124 | } |
| 125 | return s.Delete(ctx, key) |
| 126 | } |
| 127 | |
| 128 | type storeRef struct{ app, env string } |
| 129 | |
| 130 | // collidingStores lists every OTHER store in the account whose minted bucket |
| 131 | // and IAM user names are the same as this one's: every way of splitting |
| 132 | // app[-env] at a hyphen into a valid app and an optional valid environment. |
| 133 | func collidingStores(app, env string) []storeRef { |
| 134 | joined := app |
| 135 | if env != "" { |
| 136 | joined += "-" + env |
| 137 | } |
| 138 | var out []storeRef |
| 139 | for i := 0; i <= len(joined); i++ { |
| 140 | var ref storeRef |
| 141 | switch { |
| 142 | case i == len(joined): |
| 143 | ref = storeRef{joined, ""} |
| 144 | case joined[i] == '-': |
| 145 | ref = storeRef{joined[:i], joined[i+1:]} |
| 146 | default: |
| 147 | continue |
| 148 | } |
| 149 | if ref.app == app && ref.env == env { |
| 150 | continue |
| 151 | } |
| 152 | if !release.ValidApp(ref.app) || (ref.env != "" && !appconfig.ValidEnvironmentName(ref.env)) { |
| 153 | continue |
| 154 | } |
| 155 | out = append(out, ref) |
| 156 | } |
| 157 | return out |
| 158 | } |
| 159 | |
| 160 | // grantedColliders reads the records of collidingStores and returns those a |
| 161 | // grant has acted on — the ones holding the shared names, claimed or not. |
| 162 | func grantedColliders(ctx context.Context, s release.Store, acct, app, env string) ([]*Record, error) { |
| 163 | var out []*Record |
| 164 | for _, ref := range collidingStores(app, env) { |
| 165 | rec, _, err := GetIn(ctx, s, acct, ref.app, ref.env) |
| 166 | if err != nil { |
| 167 | return nil, err |
| 168 | } |
| 169 | if rec != nil && (rec.Grant != nil || rec.Provisioned != nil) { |
| 170 | rec.App, rec.Environment = ref.app, ref.env // named by the key read, not the body |
| 171 | out = append(out, rec) |
| 172 | } |
| 173 | } |
| 174 | return out, nil |
| 175 | } |
| 176 | |
| 177 | // recordNames is the bucket and user a record's estate is named by, read the |
| 178 | // way Teardown reads them. |
| 179 | func recordNames(acct string, rec *Record) (bucket, user string) { |
| 180 | user = UserNameIn(acct, rec.App, rec.Environment) |
| 181 | if rec.Grant != nil { |
| 182 | bucket = rec.Grant.Bucket |
| 183 | } |
| 184 | if rec.Provisioned != nil { |
| 185 | bucket, user = rec.Provisioned.Bucket, rec.Provisioned.IAMUser |
| 186 | } |
| 187 | return bucket, user |
| 188 | } |
| 189 | |
| 190 | // storeNamingBucket scans every store record in the deployment for one other |
| 191 | // than (acct, app, env) that names bucket. Every FIRST grant pays for it, not |
| 192 | // only an operator's adopt: an adopt made before claims existed can hold a |
| 193 | // name shaped exactly like another account's minted one (#191), and nothing |
| 194 | // but the record says so. It walks with ListDirs, as mail.SweepLegacyRecords |
| 195 | // does, so it never lists a release artifact; a re-run skips it, because by |
| 196 | // then the walk holds the claim. |
| 197 | func storeNamingBucket(ctx context.Context, s release.Store, bucket, acct, app, env string) (bool, error) { |
| 198 | accts, err := s.ListDirs(ctx, "apps/") |
| 199 | if err != nil { |
| 200 | return false, err |
| 201 | } |
| 202 | for _, acctPrefix := range accts { |
| 203 | a := path.Base(strings.TrimSuffix(acctPrefix, "/")) |
| 204 | if !release.ValidAccount(a) { |
| 205 | continue |
| 206 | } |
| 207 | apps, err := s.ListDirs(ctx, acctPrefix) |
| 208 | if err != nil { |
| 209 | return false, err |
| 210 | } |
| 211 | for _, appPrefix := range apps { |
| 212 | p := path.Base(strings.TrimSuffix(appPrefix, "/")) |
| 213 | if !release.ValidApp(p) { |
| 214 | continue |
| 215 | } |
| 216 | envs, err := StoreEnvironments(ctx, s, a, p) |
| 217 | if err != nil { |
| 218 | return false, err |
| 219 | } |
| 220 | for _, e := range append([]string{""}, envs...) { |
| 221 | if a == acct && p == app && e == env { |
| 222 | continue |
| 223 | } |
| 224 | rec, _, err := GetIn(ctx, s, a, p, e) |
| 225 | if err != nil { |
| 226 | return false, err |
| 227 | } |
| 228 | if rec == nil { |
| 229 | continue |
| 230 | } |
| 231 | if b, _ := recordNames(a, rec); b == bucket { |
| 232 | return true, nil |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | return false, nil |
| 238 | } |
| 239 | |
| 240 | // claimStoreNames is the grant walk's gate, run before the grant block is |
| 241 | // written and before any backend call. firstGrant is a grant with no block |
| 242 | // yet — the only run that can be choosing a name another record holds. |
| 243 | func claimStoreNames(ctx context.Context, s release.Store, acct, app, env, bucket, user string, firstGrant bool, now time.Time) error { |
| 244 | others, err := grantedColliders(ctx, s, acct, app, env) |
| 245 | if err != nil { |
| 246 | return err |
| 247 | } |
| 248 | if len(others) > 0 { |
| 249 | return fmt.Errorf("%w: %s is already the IAM user of %s/%s environment %q", |
| 250 | ErrStoreNameTaken, user, acct, others[0].App, others[0].Environment) |
| 251 | } |
| 252 | if firstGrant { |
| 253 | taken, err := storeNamingBucket(ctx, s, bucket, acct, app, env) |
| 254 | if err != nil { |
| 255 | return err |
| 256 | } |
| 257 | if taken { |
| 258 | return fmt.Errorf("%w: %s", ErrStoreNameTaken, bucket) |
| 259 | } |
| 260 | } |
| 261 | // Both are read before either is written, so a refusal on the second |
| 262 | // does not leave the first behind. |
| 263 | for _, key := range []string{userClaimKey(user), bucketClaimKey(bucket)} { |
| 264 | cur, err := getNameClaim(ctx, s, key) |
| 265 | if err != nil { |
| 266 | return err |
| 267 | } |
| 268 | if cur != nil && !cur.heldBy(acct, app, env) { |
| 269 | return fmt.Errorf("%w: %s", ErrStoreNameTaken, cur.Name) |
| 270 | } |
| 271 | } |
| 272 | if err := claimName(ctx, s, userClaimKey(user), user, acct, app, env, now); err != nil { |
| 273 | return err |
| 274 | } |
| 275 | return claimName(ctx, s, bucketClaimKey(bucket), bucket, acct, app, env, now) |
| 276 | } |
| 277 | |
| 278 | // sharedNames reports, for Teardown, whether the bucket or the user it is |
| 279 | // about to delete belongs to another store: by that store's claim, or — for |
| 280 | // a pair granted before claims existed — by a colliding record that names it. |
| 281 | // Either way the other store's estate is not this teardown's to destroy. |
| 282 | func sharedNames(ctx context.Context, s release.Store, acct, app, env, bucket, user string) (bucketShared, userShared bool, err error) { |
| 283 | if bucket != "" { |
| 284 | c, err := getNameClaim(ctx, s, bucketClaimKey(bucket)) |
| 285 | if err != nil { |
| 286 | return false, false, err |
| 287 | } |
| 288 | bucketShared = c != nil && !c.heldBy(acct, app, env) |
| 289 | } |
| 290 | c, err := getNameClaim(ctx, s, userClaimKey(user)) |
| 291 | if err != nil { |
| 292 | return false, false, err |
| 293 | } |
| 294 | userShared = c != nil && !c.heldBy(acct, app, env) |
| 295 | others, err := grantedColliders(ctx, s, acct, app, env) |
| 296 | if err != nil { |
| 297 | return false, false, err |
| 298 | } |
| 299 | for _, o := range others { |
| 300 | b, u := recordNames(acct, o) |
| 301 | bucketShared = bucketShared || (bucket != "" && b == bucket) |
| 302 | userShared = userShared || u == user |
| 303 | } |
| 304 | return bucketShared, userShared, nil |
| 305 | } |
| 306 | |
| 307 | // claimedBucket finds the bucket claim this store holds, for the one teardown |
| 308 | // that has no record of the name: a grant that took its claims and died |
| 309 | // before writing its block. Only a declared-only teardown pays for the |
| 310 | // listing. "" means none. |
| 311 | func claimedBucket(ctx context.Context, s release.Store, acct, app, env string) (string, error) { |
| 312 | keys, err := s.List(ctx, nameClaimsPrefix+"buckets/") |
| 313 | if err != nil { |
| 314 | return "", err |
| 315 | } |
| 316 | for _, key := range keys { |
| 317 | c, err := getNameClaim(ctx, s, key) |
| 318 | if err != nil { |
| 319 | return "", err |
| 320 | } |
| 321 | if c != nil && c.heldBy(acct, app, env) { |
| 322 | return c.Name, nil |
| 323 | } |
| 324 | } |
| 325 | return "", nil |
| 326 | } |
| 327 | |
| 328 | // releaseStoreNames gives back whatever claims this store holds on bucket and |
| 329 | // user. Owner-gated, so a name another store holds is never touched. |
| 330 | func releaseStoreNames(ctx context.Context, s release.Store, acct, app, env, bucket, user string) error { |
| 331 | keys := []string{userClaimKey(user)} |
| 332 | if bucket != "" { |
| 333 | keys = append(keys, bucketClaimKey(bucket)) |
| 334 | } |
| 335 | for _, key := range keys { |
| 336 | if err := releaseName(ctx, s, key, acct, app, env); err != nil { |
| 337 | return fmt.Errorf("objstore: release name claim %s: %w", key, err) |
| 338 | } |
| 339 | } |
| 340 | return nil |
| 341 | } |
| 342 | |