carlos / platform Public

objstore: grant walk — provision, mint, deliver, record

The §5 sequence every other store verb trusts: resolve the bucket and
pin it in the grant block, ensure bucket and user, reap-then-mint the
key, deliver the sealed coordinates in one appconfig.Apply, and only
then record success. Each step is check-then-create, so a crash
anywhere is repaired by re-running; a healthy provisioned store
short-circuits to a no-op via EnvDelivered, which Task 8 reuses for
its missing-vars report.

Reap before mint is the crash repair: a key minted before a failed
delivery is recorded nowhere, so only list-and-delete can find it. A
pending-finish key from an old rotation is kept for its explicit
finish.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 675e3eac3d9a345b8724cffe816084f2d85554e8 parent 6b537ae
2 files changed, +572 −0
  • internal/objstore/walk.go +195 −0
  • internal/objstore/walk_test.go +377 −0
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go
new file mode 100644
index 000000000..b8d85859b
--- /dev/null
+++ b/internal/objstore/walk.go
@@ -0,0 +1,195 @@
+// The grant walk (§5): the provisioning sequence every other verb trusts.
+// Each numbered step is check-then-create, so the walk is re-runnable after a
+// failure at any point, and the secret's ONLY landing place is the app's
+// sealed config (§7) — it is delivered before it is recorded, never after.
+package objstore
+
+import (
+ "context"
+ "crypto/ecdh"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ErrNotDeclared is a grant for an app whose member never asked for a store.
+// The operator's verb never invents the declaration: the env prefix is the
+// member's choice, and a store nobody declared has nowhere to be delivered.
+var ErrNotDeclared = errors.New("objstore: no store is declared for this app")
+
+// ErrEnvPrefixLocked is a re-declaration that tries to move the env prefix of
+// an already-provisioned store. Renaming the delivered set would leave the
+// old names in config.json holding live credentials while the app reads
+// nothing — so the prefix is fixed at first provision (the console door
+// raises this; the grant walk itself never re-reads a prefix choice).
+var ErrEnvPrefixLocked = errors.New("objstore: store already provisioned — env prefix is fixed")
+
+// UserPath is the IAM path every store principal lives under, so an operator
+// (or a policy) can name the whole population in one place.
+const UserPath = "/carlos-store/"
+
+// Deployment is the per-deployment backend configuration the walks need: who
+// provisions (Backend), where (Region), an optional non-AWS Endpoint
+// delivered to the app, and the prefix minted bucket names carry.
+type Deployment struct {
+ Backend string
+ Region string
+ Endpoint string
+ BucketPrefix string
+}
+
+// MintedBucket is the name a store gets when the deployment mints one — the
+// adopt case (an existing bucket named in the grant) never comes through here.
+func MintedBucket(d Deployment, acct, app string) string {
+ return d.BucketPrefix + acct + "-" + app
+}
+
+// UserName is the IAM principal that owns an app's store credentials. One
+// user per app: a key can be reaped or rotated without touching any other
+// app, and the user's inline policy names exactly one bucket.
+func UserName(acct, app string) string { return "carlos-store-" + acct + "-" + app }
+
+// EnvDelivered reports which of the delivered variable names are NOT present
+// in the app's config manifest — an empty result means fully delivered.
+// _ENDPOINT counts only when wantEndpoint (a deployment with no endpoint
+// never delivers one). A manifest that does not exist reads as every name
+// missing, which is what an undelivered app is.
+//
+// The grant walk's idempotence short-circuit and the console's missing-vars
+// report are the same question asked twice, so they ask it here: a member who
+// unsets a delivered var must see the same "not delivered" the walk acts on.
+func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix string, wantEndpoint bool) (missing []string, err error) {
+ m, err := appconfig.Get(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ bucket, region, endpoint, accessKey, secretKey := EnvNames(prefix)
+ want := []string{bucket, region}
+ if wantEndpoint {
+ want = append(want, endpoint)
+ }
+ want = append(want, accessKey, secretKey)
+ for _, name := range want {
+ if _, ok := m.Vars[name]; !ok {
+ missing = append(missing, name)
+ }
+ }
+ return missing, nil
+}
+
+// Grant provisions the declared store and delivers its credentials, in spec
+// §5 order. It is idempotent: a healthy provisioned store makes a re-run a
+// no-op (no fresh key, no rewrite), and an unhealthy one — a crash anywhere
+// in the sequence — is repaired by walking it again. adoptBucket names an
+// existing bucket to adopt instead of minting one; it is honoured only on the
+// FIRST grant, since the recorded bucket is what the app's data is in.
+func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
+ sealPub *ecdh.PublicKey, acct, app, actor, adoptBucket string, now time.Time) (*Record, error) {
+ // 1. resolve + write the grant block
+ rec, etag, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil || rec.Declared == nil {
+ return nil, ErrNotDeclared
+ }
+ bucket := adoptBucket
+ if rec.Grant != nil && rec.Grant.Bucket != "" {
+ bucket = rec.Grant.Bucket // a re-run never re-chooses the bucket
+ }
+ if bucket == "" {
+ bucket = MintedBucket(d, acct, app)
+ }
+ if rec.Grant == nil {
+ rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, At: now, By: actor}
+ if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil {
+ return nil, err
+ }
+ }
+ // Idempotence short-circuit: "healthy" means the record says provisioned
+ // AND every delivered var is present in config.json. Anything less and the
+ // walk runs, because a missing var means the app cannot reach its store.
+ if rec.Provisioned != nil {
+ missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, rec.Provisioned.Endpoint != "")
+ if err != nil {
+ return nil, err
+ }
+ if len(missing) == 0 {
+ return rec, nil
+ }
+ }
+ // 2–3. backend resources
+ region, err := p.EnsureBucket(ctx, bucket)
+ if err != nil {
+ return nil, fmt.Errorf("objstore: ensure bucket %s: %w", bucket, err)
+ }
+ user := UserName(acct, app)
+ arn, err := p.EnsureUser(ctx, user, UserPath, "arn:aws:s3:::"+bucket)
+ if err != nil {
+ return nil, fmt.Errorf("objstore: ensure user %s: %w", user, err)
+ }
+ // 4. key. Reap first: reaching here means delivery is missing, so any
+ // existing CURRENT key is either undelivered (its secret died with a
+ // failed config write — unrecoverable) or stranded by a crash between mint
+ // and record, which no record can name. Only list-and-delete finds both; a
+ // pending-finish key from an old rotation is kept for its explicit finish.
+ keep := []string(nil)
+ if rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "" {
+ keep = append(keep, rec.Provisioned.PrevKeyID)
+ }
+ if err := p.ReapKeys(ctx, user, keep); err != nil {
+ return nil, fmt.Errorf("objstore: reap keys for %s: %w", user, err)
+ }
+ id, secret, err := p.MintKey(ctx, user)
+ if err != nil {
+ return nil, fmt.Errorf("objstore: mint key for %s: %w", user, err)
+ }
+ // 5. deliver — the secret's ONLY landing place (§7)
+ if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil {
+ return nil, err
+ }
+ // 6. record success — after the secret is recorded, never before (§5).
+ // PrevKeyID carries over untouched: the reap above already removed the
+ // only stale current key, and a pending-finish key from an old rotation
+ // still deserves its explicit finish.
+ prev := ""
+ if rec.Provisioned != nil {
+ prev = rec.Provisioned.PrevKeyID
+ }
+ rec.Provisioned = &Provisioned{Bucket: bucket, Region: region, Endpoint: d.Endpoint,
+ IAMUser: user, UserARN: arn, KeyID: id, PrevKeyID: prev, At: now}
+ if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
+ return nil, err
+ }
+ return rec, nil
+}
+
+// writeEnv delivers the whole coordinate set in ONE appconfig.Apply — one CAS
+// write, so an app never reads a bucket name paired with the previous key.
+// Both key halves are sealed for the deployment's secrets key; the plaintext
+// secret exists nowhere else, in no other object and no log line.
+func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
+ acct, app, prefix, bucket, region, endpoint, keyID, secret string) error {
+ bucketName, regionName, endpointName, accessKeyName, secretKeyName := EnvNames(prefix)
+ sets := map[string]appconfig.Var{
+ bucketName: {Kind: "env", Value: bucket},
+ regionName: {Kind: "env", Value: region},
+ }
+ if endpoint != "" {
+ sets[endpointName] = appconfig.Var{Kind: "env", Value: endpoint}
+ }
+ for name, plain := range map[string]string{accessKeyName: keyID, secretKeyName: secret} {
+ env, err := appconfig.Seal(sealPub, []byte(plain))
+ if err != nil {
+ return fmt.Errorf("objstore: seal %s: %w", name, err)
+ }
+ sets[name] = appconfig.Var{Kind: "secret", Envelope: env}
+ }
+ if _, err := appconfig.Apply(ctx, s, acct, app, sets, nil); err != nil {
+ return fmt.Errorf("objstore: deliver store config for %s/%s: %w", acct, app, err)
+ }
+ return nil
+}
diff --git a/internal/objstore/walk_test.go b/internal/objstore/walk_test.go
new file mode 100644
index 000000000..3d329253a
--- /dev/null
+++ b/internal/objstore/walk_test.go
@@ -0,0 +1,377 @@
+package objstore
+
+import (
+ "context"
+ "crypto/ecdh"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// testSealKeypair mints the secrets keypair the walk seals with and the test
+// opens with — the console holds the public half, a host the private one.
+func testSealKeypair(t *testing.T) (*ecdh.PrivateKey, *ecdh.PublicKey) {
+ t.Helper()
+ privB64, pubB64, err := appconfig.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := appconfig.ParsePrivateKey(privB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := appconfig.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return priv, pub
+}
+
+// failingStore fails the FIRST Put to failKeyOnce, then behaves normally —
+// the crash between mint and delivery the walk has to repair.
+type failingStore struct {
+ release.Store
+ failKeyOnce string
+}
+
+func (f *failingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key != "" && key == f.failKeyOnce {
+ f.failKeyOnce = ""
+ return "", errors.New("injected: put failed")
+ }
+ return f.Store.Put(ctx, key, body, ifMatch)
+}
+
+func testDeployment() Deployment {
+ return Deployment{Backend: "aws", Region: "eu-west-1", BucketPrefix: "carlos-store-tito-"}
+}
+
+// declare writes the member half of the record: a store is asked for.
+func declare(t *testing.T, s release.Store, acct, app, prefix string) {
+ t.Helper()
+ rec := &Record{Declared: &Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: "member@example.com"}}
+ if _, err := Put(context.Background(), s, acct, app, rec, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func mustVars(t *testing.T, s release.Store, acct, app string) map[string]appconfig.Var {
+ t.Helper()
+ m, err := appconfig.Get(context.Background(), s, acct, app)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m.Vars
+}
+
+func TestGrantUndeclaredRefused(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); !errors.Is(err, ErrNotDeclared) {
+ t.Fatalf("Grant on undeclared app: %v, want ErrNotDeclared", err)
+ }
+ if len(p.Buckets) != 0 || len(p.Users) != 0 {
+ t.Fatalf("refused grant touched the backend: %v %v", p.Buckets, p.Users)
+ }
+}
+
+func TestGrantMintsAndDelivers(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ priv, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ now := time.Now().UTC()
+ rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op@example.com", "", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ const wantBucket = "carlos-store-tito-bab-titogo"
+ const wantUser = "carlos-store-bab-titogo"
+ if _, ok := p.Buckets[wantBucket]; !ok {
+ t.Fatalf("bucket %q not created: %v", wantBucket, p.Buckets)
+ }
+ u, ok := p.Users[wantUser]
+ if !ok {
+ t.Fatalf("user %q not created: %v", wantUser, p.Users)
+ }
+ if rec.Provisioned == nil {
+ t.Fatal("record has no provisioned block")
+ }
+ if rec.Provisioned.Bucket != wantBucket || rec.Provisioned.IAMUser != wantUser ||
+ rec.Provisioned.Region != "eu-west-1" || rec.Provisioned.UserARN != u.ARN {
+ t.Fatalf("provisioned = %+v", rec.Provisioned)
+ }
+ if rec.Grant == nil || rec.Grant.Backend != "aws" || rec.Grant.By != "op@example.com" {
+ t.Fatalf("grant block = %+v", rec.Grant)
+ }
+ secret, ok := u.Keys[rec.Provisioned.KeyID]
+ if !ok {
+ t.Fatalf("recorded key %q is not in the fake: %v", rec.Provisioned.KeyID, u.Keys)
+ }
+
+ // the record is durable, not just the returned value
+ stored, _, err := Get(ctx, s, "bab", "titogo")
+ if err != nil || stored == nil || stored.Provisioned == nil ||
+ stored.Provisioned.KeyID != rec.Provisioned.KeyID {
+ t.Fatalf("stored record = %+v, %v", stored, err)
+ }
+
+ vars := mustVars(t, s, "bab", "titogo")
+ if v := vars["TITO_S3_BUCKET"]; v.Kind != "env" || v.Value != wantBucket {
+ t.Fatalf("TITO_S3_BUCKET = %+v", v)
+ }
+ if v := vars["TITO_S3_REGION"]; v.Kind != "env" || v.Value != "eu-west-1" {
+ t.Fatalf("TITO_S3_REGION = %+v", v)
+ }
+ if v := vars["TITO_S3_ACCESS_KEY"]; v.Kind != "secret" || v.Envelope == "" {
+ t.Fatalf("TITO_S3_ACCESS_KEY = %+v", v)
+ }
+ gotID, err := appconfig.Open(priv, vars["TITO_S3_ACCESS_KEY"].Envelope)
+ if err != nil || string(gotID) != rec.Provisioned.KeyID {
+ t.Fatalf("delivered access key = %q, %v; want %q", gotID, err, rec.Provisioned.KeyID)
+ }
+ sk := vars["TITO_S3_SECRET_KEY"]
+ if sk.Kind != "secret" || sk.Value != "" {
+ t.Fatalf("TITO_S3_SECRET_KEY = %+v (secret must never be plaintext)", sk)
+ }
+ gotSecret, err := appconfig.Open(priv, sk.Envelope)
+ if err != nil || string(gotSecret) != secret {
+ t.Fatalf("delivered secret = %q, %v; want %q", gotSecret, err, secret)
+ }
+ if _, ok := vars["TITO_S3_ENDPOINT"]; ok {
+ t.Fatalf("endpoint delivered for an empty endpoint: %+v", vars)
+ }
+}
+
+func TestGrantDeliversEndpointWhenSet(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-central-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ d := testDeployment()
+ d.Endpoint = "https://s3.example.net"
+ rec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", "", time.Now().UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Provisioned.Endpoint != d.Endpoint {
+ t.Fatalf("provisioned endpoint = %q", rec.Provisioned.Endpoint)
+ }
+ vars := mustVars(t, s, "bab", "titogo")
+ if v := vars["TITO_S3_ENDPOINT"]; v.Kind != "env" || v.Value != d.Endpoint {
+ t.Fatalf("TITO_S3_ENDPOINT = %+v", v)
+ }
+}
+
+func TestGrantAdoptsExistingBucket(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "titogo-uploads", time.Now().UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Provisioned.Bucket != "titogo-uploads" || rec.Grant.Bucket != "titogo-uploads" {
+ t.Fatalf("adopt: grant=%+v provisioned=%+v", rec.Grant, rec.Provisioned)
+ }
+ if _, ok := p.Buckets["carlos-store-tito-bab-titogo"]; ok {
+ t.Fatalf("minted bucket created despite adoption: %v", p.Buckets)
+ }
+ if v := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"]; v.Value != "titogo-uploads" {
+ t.Fatalf("delivered bucket = %+v", v)
+ }
+}
+
+func TestGrantResumesAfterEachStepFailure(t *testing.T) {
+ for _, method := range []string{"EnsureBucket", "EnsureUser", "MintKey"} {
+ t.Run(method, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ priv, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ p.FailNext[method] = errors.New("injected: " + method + " failed")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); err == nil {
+ t.Fatalf("%s failure: Grant returned no error", method)
+ }
+ // The grant block is written before any backend call, so a
+ // half-failed walk leaves the bucket choice pinned, not provisioned.
+ half, _, err := Get(ctx, s, "bab", "titogo")
+ if err != nil || half == nil || half.Grant == nil || half.Provisioned != nil {
+ t.Fatalf("record after %s failure = %+v, %v", method, half, err)
+ }
+
+ rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC())
+ if err != nil {
+ t.Fatalf("resume after %s failure: %v", method, err)
+ }
+ if len(p.Buckets) != 1 {
+ t.Fatalf("buckets after resume = %v", p.Buckets)
+ }
+ if len(p.Users) != 1 {
+ t.Fatalf("users after resume = %v", p.Users)
+ }
+ u := p.Users["carlos-store-bab-titogo"]
+ if len(u.Keys) != 1 {
+ t.Fatalf("keys after resume = %v", u.Keys)
+ }
+ if _, ok := u.Keys[rec.Provisioned.KeyID]; !ok {
+ t.Fatalf("recorded key %q not live: %v", rec.Provisioned.KeyID, u.Keys)
+ }
+ env := mustVars(t, s, "bab", "titogo")["TITO_S3_SECRET_KEY"].Envelope
+ got, err := appconfig.Open(priv, env)
+ if err != nil || string(got) != u.Keys[rec.Provisioned.KeyID] {
+ t.Fatalf("delivered secret = %q, %v; want %q", got, err, u.Keys[rec.Provisioned.KeyID])
+ }
+ })
+ }
+
+ t.Run("delivery", func(t *testing.T) {
+ ctx := context.Background()
+ priv, pub := testSealKeypair(t)
+ s := &failingStore{Store: release.NewDirStore(t.TempDir()), failKeyOnce: appconfig.Key("bab", "titogo")}
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); err == nil {
+ t.Fatal("delivery failure: Grant returned no error")
+ }
+ // The record must NOT claim provisioned: the secret never landed.
+ if rec, _, err := Get(ctx, s, "bab", "titogo"); err != nil || rec == nil || rec.Provisioned != nil {
+ t.Fatalf("record after failed delivery = %+v, %v", rec, err)
+ }
+
+ rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC())
+ if err != nil {
+ t.Fatalf("resume after delivery failure: %v", err)
+ }
+ u := p.Users["carlos-store-bab-titogo"]
+ if len(u.Keys) != 1 {
+ t.Fatalf("undelivered key not reaped: %v", u.Keys)
+ }
+ if rec.Provisioned.KeyID != "MEMKEY2" {
+ t.Fatalf("recorded key = %q, want the SECOND mint MEMKEY2", rec.Provisioned.KeyID)
+ }
+ vars := mustVars(t, s, "bab", "titogo")
+ gotID, err := appconfig.Open(priv, vars["TITO_S3_ACCESS_KEY"].Envelope)
+ if err != nil || string(gotID) != "MEMKEY2" {
+ t.Fatalf("delivered access key = %q, %v; want MEMKEY2", gotID, err)
+ }
+ gotSecret, err := appconfig.Open(priv, vars["TITO_S3_SECRET_KEY"].Envelope)
+ if err != nil || string(gotSecret) != u.Keys["MEMKEY2"] {
+ t.Fatalf("delivered secret = %q, %v; want %q", gotSecret, err, u.Keys["MEMKEY2"])
+ }
+ })
+}
+
+func TestGrantRerunKeepsBucketChoice(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "titogo-uploads", time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ // Wipe delivery so the re-run walks the whole sequence again.
+ if _, err := appconfig.Apply(ctx, s, "bab", "titogo", nil, []string{"TITO_S3_BUCKET"}); err != nil {
+ t.Fatal(err)
+ }
+ rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Provisioned.Bucket != "titogo-uploads" || rec.Grant.Bucket != "titogo-uploads" {
+ t.Fatalf("re-run re-chose the bucket: grant=%+v provisioned=%+v", rec.Grant, rec.Provisioned)
+ }
+ if len(p.Buckets) != 1 {
+ t.Fatalf("buckets = %v", p.Buckets)
+ }
+ if v := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"]; v.Value != "titogo-uploads" {
+ t.Fatalf("re-delivered bucket = %+v", v)
+ }
+}
+
+func TestGrantIdempotentWhenHealthy(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ first, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ before := mustVars(t, s, "bab", "titogo")
+
+ second, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC().Add(time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.Provisioned.KeyID != first.Provisioned.KeyID {
+ t.Fatalf("re-run minted a fresh key: %q then %q", first.Provisioned.KeyID, second.Provisioned.KeyID)
+ }
+ if u := p.Users["carlos-store-bab-titogo"]; len(u.Keys) != 1 {
+ t.Fatalf("keys after healthy re-run = %v", u.Keys)
+ }
+ after := mustVars(t, s, "bab", "titogo")
+ if len(before) != len(after) {
+ t.Fatalf("var set changed: %v then %v", before, after)
+ }
+ for k, v := range before {
+ if after[k] != v {
+ t.Fatalf("var %s rewritten: %+v then %+v", k, v, after[k])
+ }
+ }
+}
+
+func TestEnvDeliveredReportsMissing(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+
+ missing, err := EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(missing) != 4 {
+ t.Fatalf("undelivered app: missing = %v, want the 4 non-endpoint names", missing)
+ }
+
+ declare(t, s, "bab", "titogo", "TITO_S3")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ if missing, err = EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", false); err != nil || len(missing) != 0 {
+ t.Fatalf("after grant: missing = %v, %v", missing, err)
+ }
+ // endpoint wanted but never delivered (deployment has no endpoint)
+ if missing, err = EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", true); err != nil ||
+ len(missing) != 1 || missing[0] != "TITO_S3_ENDPOINT" {
+ t.Fatalf("endpoint wanted: missing = %v, %v", missing, err)
+ }
+ if _, err := appconfig.Apply(ctx, s, "bab", "titogo", nil, []string{"TITO_S3_SECRET_KEY"}); err != nil {
+ t.Fatal(err)
+ }
+ if missing, err = EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", false); err != nil ||
+ len(missing) != 1 || missing[0] != "TITO_S3_SECRET_KEY" {
+ t.Fatalf("after unset: missing = %v, %v", missing, err)
+ }
+}