| index 518a968c6..a11b459c8 100644 |
| --- a/internal/objstore/walk_test.go |
| +++ b/internal/objstore/walk_test.go |
| @@ -1408,3 +1408,115 @@ func TestPurgeGuardSeesOurRecord(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// grantBlockOnlyFixture leaves the record exactly as a walk that died mid-flight |
| +// leaves it: the grant block written (Grant's step 1) and no provisioned block. |
| +// A re-run then skips step 1's Put, so the next write it makes to the record is |
| +// step 6's — the one these two tests race. |
| +func grantBlockOnlyFixture(t *testing.T, s release.Store) { |
| + t.Helper() |
| + ctx := context.Background() |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + rec, etag, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.Grant = &GrantBlock{ |
| + Backend: testDeployment().Backend, |
| + Bucket: MintedBucket(testDeployment(), "bab", "titogo"), |
| + At: time.Now().UTC(), |
| + By: "op", |
| + } |
| + if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Grant's final Put has the same window Rotate's does — the console's declare |
| +// door writes this very object, and under self-serve storage (#381) a member's |
| +// own `carlos store create` drives the walk, so the window is ordinary rather |
| +// than rare. Losing that CAS outright leaves the record naming no key (or an |
| +// older one) while the app already holds the delivered credential, and the next |
| +// rotate's reap then deletes the key actually in use — the outcome Rotate's own |
| +// doc comment calls NOT survivable. So Grant retries against a re-read record, |
| +// and a competitor's change to blocks it does not own must survive. |
| +func TestGrantRetriesLostCAS(t *testing.T) { |
| + ctx := context.Background() |
| + priv, pub := testSealKeypair(t) |
| + inner := release.NewDirStore(t.TempDir()) |
| + s := &racingStore{Store: inner} |
| + p := NewMemProvisioner("eu-west-1") |
| + grantBlockOnlyFixture(t, s) |
| + |
| + // A competing writer touches a block this walk does not own — exactly what |
| + // a re-declare through the console's create door does. |
| + s.raceKeyOnce = Key("bab", "titogo") |
| + s.race = func(in release.Store) { |
| + other, etag, err := Get(ctx, in, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + other.Declared.By = "someone-else@example.com" |
| + if _, err := Put(ctx, in, "bab", "titogo", other, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatalf("grant lost its CAS and gave up: %v", err) |
| + } |
| + if rec.Provisioned == nil || rec.Provisioned.KeyID == "" { |
| + t.Fatalf("provisioned = %+v", rec.Provisioned) |
| + } |
| + if rec.Declared.By != "someone-else@example.com" { |
| + t.Fatalf("retry clobbered the competing write: Declared.By = %q", rec.Declared.By) |
| + } |
| + stored, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored.Declared.By != "someone-else@example.com" || |
| + stored.Provisioned == nil || stored.Provisioned.KeyID != rec.Provisioned.KeyID { |
| + t.Fatalf("stored record = %+v / %+v, %v", stored.Declared, stored.Provisioned, err) |
| + } |
| + // The record and the app agree on the live key — the whole point. |
| + if got := openVar(t, priv, s, "bab", "titogo", "TITO_S3_ACCESS_KEY"); got != stored.Provisioned.KeyID { |
| + t.Fatalf("delivered access key = %q, record says %q", got, stored.Provisioned.KeyID) |
| + } |
| +} |
| + |
| +// Grant-vs-grant is the case the retry must NOT paper over: a competing walk |
| +// minted and DELIVERED a key of its own, so overwriting its record would strand |
| +// that key exactly as a lost CAS would strand ours. The conflict is surfaced. |
| +func TestGrantRefusesRetryAgainstAnotherProvision(t *testing.T) { |
| + ctx := context.Background() |
| + _, pub := testSealKeypair(t) |
| + inner := release.NewDirStore(t.TempDir()) |
| + s := &racingStore{Store: inner} |
| + p := NewMemProvisioner("eu-west-1") |
| + grantBlockOnlyFixture(t, s) |
| + |
| + s.raceKeyOnce = Key("bab", "titogo") |
| + s.race = func(in release.Store) { |
| + other, etag, err := Get(ctx, in, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + other.Provisioned = &Provisioned{ |
| + Bucket: other.Grant.Bucket, |
| + Region: "eu-west-1", |
| + IAMUser: UserName("bab", "titogo"), |
| + KeyID: "MEMKEY99", // another grant landed first, and delivered |
| + At: time.Now().UTC(), |
| + } |
| + if _, err := Put(ctx, in, "bab", "titogo", other, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("grant over a concurrent grant: %v, want ErrCASConflict", err) |
| + } |
| + stored, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored.Provisioned == nil || stored.Provisioned.KeyID != "MEMKEY99" { |
| + t.Fatalf("the competing grant was clobbered: %+v, %v", stored.Provisioned, err) |
| + } |
| +} |