objstore: the per-account storage entitlement, and why a grant exists
Two additions for self-serve object storage (#381), both inert until the console doors that read them land. Entitlement is store/entitlements/<sqid>.json: an owner applies, a deployment operator decides. Two blocks for two actors, the account-level twin of Record's three-for-three, with state derived from the blocks rather than stored beside them — the same reasoning that keeps Record honest. It is deliberately NOT accounts/<sqid>/store.json, which is where it obviously belongs. accounts/<sqid>/** is inside the fleet read ACL, so that placement would let every box credential of an account read the applicant's note, the deciding operator's address and the decline reason. A decline reason is an operator's commentary about a tenant and must not be readable by that tenant's boxes. store/ is in none of aclRead's subtrees and none of aclListScope's cases, so it is refused by default rather than by a rule someone must remember; the edge ACL matrix now pins that. GrantBlock gains Source, mirroring mail.GrantBlock.Source's vocabulary and its reason: an authority that may later be withdrawn has to say what it rested on. "operator" is a per-app grant, "account-approval" is a member's own declare in an approved account, and By names the approving operator either way. It is recorded with the block on the first grant only — a re-run repairs an estate whose authority was already settled, and must no more restate that than it re-chooses the bucket. Additive inside `grant`, so release.PurgeApp's json.RawMessage guard is untouched and TestPurgeGuardSeesOurRecord stays green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+408
−29
internal/console/apistore.go+1 −1internal/edge/storedoor_test.go+13 −0internal/objstore/entitlement.go+127 −0internal/objstore/entitlement_test.go+160 −0internal/objstore/objstore.go+21 −0internal/objstore/walk.go+8 −2internal/objstore/walk_test.go+78 −26
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| index a5c951b21..b218cb409 100644 |
| --- a/internal/console/apistore.go |
| +++ b/internal/console/apistore.go |
| @@ -538,7 +538,7 @@ func (s *Server) handleAPIStoreGrant(w http.ResponseWriter, r *http.Request) { |
| defer cancel() |
| defer s.storeWalks.lock(acct.Sqid, app)() |
| rec, err := objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, |
| - pub, acct.Sqid, app, addr, bucket, time.Now()) |
| + pub, acct.Sqid, app, addr, objstore.GrantSourceOperator, bucket, time.Now()) |
| switch { |
| case errors.Is(err, objstore.ErrNotDeclared): |
| // The operator's verb never invents the member's half: the env prefix |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index cfb98cf8f..14afdc9a0 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -150,6 +150,19 @@ func TestStoreDoorACLMatrix(t *testing.T) { |
| {"domains/app.example.com.json", opDelete, false, "nor delete them"}, |
| {"domains", opRead, false, "an object AT the bare name, not the claims prefix"}, |
| + // --- the self-serve storage entitlement is console-only (#381) --- |
| + // store/entitlements/<sqid>.json carries the applicant's note, the |
| + // deciding operator's address and the decline REASON — an operator's |
| + // commentary about a tenant. It lives outside accounts/ precisely so |
| + // that no fleet credential can read it; had it gone to the obvious |
| + // accounts/<sqid>/store.json, the account's own boxes could. |
| + {"store/entitlements/" + testSqid + ".json", opRead, false, "a box never reads its account's entitlement, own sqid or not"}, |
| + {"store/entitlements/zz99.json", opRead, false, "still less another account's"}, |
| + {"store/entitlements/" + testSqid + ".json", opWrite, false, "and never writes one"}, |
| + {"store/entitlements/" + testSqid + ".json", opDelete, false, "nor deletes one"}, |
| + {"store/", opRead, false, "no listing-shaped key reaches the prefix either"}, |
| + {"store", opRead, false, "nor an object at the bare name"}, |
| + |
| // --- shapes canonicalKey must kill before the ACL is consulted --- |
| {"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"}, |
| {"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"}, |
diff --git a/internal/objstore/entitlement.go b/internal/objstore/entitlement.go| new file mode 100644 |
| index 000000000..0ebddb878 |
| --- /dev/null |
| +++ b/internal/objstore/entitlement.go |
| @@ -0,0 +1,127 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// EntitlementSchema versions store/entitlements/<sqid>.json independently of |
| +// Schema: the per-app record and the per-account entitlement are written by |
| +// different doors, for different actors, and neither's shape should force a |
| +// version bump on the other. |
| +const EntitlementSchema = 1 |
| + |
| +// Entitlement is store/entitlements/<sqid>.json — the account's half of |
| +// self-serve object storage (#381): an owner ASKS for storage and a |
| +// deployment operator DECIDES. Two blocks for two actors, the account-level |
| +// twin of Record's three-for-three. |
| +// |
| +// NOT UNDER accounts/. The obvious home was accounts/<sqid>/store.json, beside |
| +// account.json, and it is the wrong one: accounts/<sqid>/** is inside the |
| +// fleet READ ACL (internal/edge/storedoor.go, aclRead), so every box |
| +// credential of that account could read the applicant's note, the deciding |
| +// operator's address, and the decline reason. A decline reason is an |
| +// operator's commentary ABOUT a tenant; it must not be readable BY that |
| +// tenant's boxes. A top-level prefix is in none of aclRead's three subtrees |
| +// and none of aclListScope's cases, so it is refused by default rather than by |
| +// a rule someone has to remember to keep — pinned by |
| +// TestEntitlementsAreNotReadableThroughTheEdgeDoor in internal/edge. |
| +// |
| +// STATE IS DERIVED, NEVER STORED. No object means never applied; Applied with |
| +// no Decision is pending; a Decision decides it, and Approved says which way. |
| +// Record's null-or-present tri-state, for the same reason: a `state string` |
| +// beside these blocks can disagree with them, and then two readers disagree |
| +// about the account. |
| +// |
| +// The secret-free rule carries over from Record: nothing here is a credential, |
| +// and nothing here names one. |
| +type Entitlement struct { |
| + Schema int `json:"schema"` |
| + Account string `json:"account"` |
| + Applied *Applied `json:"applied,omitempty"` |
| + Decision *Decision `json:"decision,omitempty"` |
| +} |
| + |
| +// Applied is the owner's half: the ask, and what it is for. |
| +type Applied struct { |
| + By string `json:"by"` |
| + At time.Time `json:"at"` |
| + Note string `json:"note,omitempty"` |
| +} |
| + |
| +// Decision is the operator's half. Reason is required on a decline and |
| +// meaningless on an approval — the door enforces that, not this struct. |
| +type Decision struct { |
| + Approved bool `json:"approved"` |
| + By string `json:"by"` |
| + At time.Time `json:"at"` |
| + Reason string `json:"reason,omitempty"` |
| +} |
| + |
| +// EntitlementKey is the bucket key for one account's entitlement. |
| +func EntitlementKey(acct string) string { return "store/entitlements/" + acct + ".json" } |
| + |
| +// Approved reports whether this account may provision stores without an |
| +// operator running grant for each one. A nil entitlement is not approved, |
| +// which is the fail-closed direction and the state every account starts in. |
| +func (e *Entitlement) Approved() bool { |
| + return e != nil && e.Decision != nil && e.Decision.Approved |
| +} |
| + |
| +// Pending reports an application waiting on an operator — applied, undecided. |
| +// This is the state the operator queue lists and the one a member is told they |
| +// are in. |
| +func (e *Entitlement) Pending() bool { |
| + return e != nil && e.Applied != nil && e.Decision == nil |
| +} |
| + |
| +// Declined reports a decision that said no. Distinct from Pending and from |
| +// never-applied because the member is owed a different sentence for each. |
| +func (e *Entitlement) Declined() bool { |
| + return e != nil && e.Decision != nil && !e.Decision.Approved |
| +} |
| + |
| +// GetEntitlement reads one account's entitlement. Absent is (nil, "", nil) — |
| +// Get's idiom (objstore.go), so a caller that knows one knows the other, and |
| +// "no entitlement" never arrives as an error a door has to special-case. |
| +func GetEntitlement(ctx context.Context, s release.Store, acct string) (*Entitlement, string, error) { |
| + if !release.ValidAccount(acct) { |
| + return nil, "", fmt.Errorf("objstore: invalid account %q", acct) |
| + } |
| + raw, etag, err := s.Get(ctx, EntitlementKey(acct)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + return nil, "", err |
| + } |
| + var e Entitlement |
| + if err := json.Unmarshal(raw, &e); err != nil { |
| + return nil, "", fmt.Errorf("objstore: %s: %w", EntitlementKey(acct), err) |
| + } |
| + return &e, etag, nil |
| +} |
| + |
| +// PutEntitlement CAS-writes one account's entitlement. Schema and Account are |
| +// stamped here rather than trusted from the caller, exactly as Put stamps |
| +// Record's — the key already says which account this is, and a body that |
| +// disagreed with its own key would be the corruption every reader after it |
| +// inherits. |
| +func PutEntitlement(ctx context.Context, s release.Store, acct string, e *Entitlement, etag string) (string, error) { |
| + if !release.ValidAccount(acct) { |
| + return "", fmt.Errorf("objstore: invalid account %q", acct) |
| + } |
| + e.Schema = EntitlementSchema |
| + e.Account = acct |
| + body, err := json.MarshalIndent(e, "", " ") |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, EntitlementKey(acct), append(body, '\n'), etag) |
| +} |
diff --git a/internal/objstore/entitlement_test.go b/internal/objstore/entitlement_test.go| new file mode 100644 |
| index 000000000..076eecf79 |
| --- /dev/null |
| +++ b/internal/objstore/entitlement_test.go |
| @@ -0,0 +1,160 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The four states a member is owed a different sentence for. Derived from the |
| +// blocks, never from a stored string, so this table is the whole definition. |
| +func TestEntitlementStates(t *testing.T) { |
| + now := time.Now().UTC() |
| + for _, tc := range []struct { |
| + name string |
| + e *Entitlement |
| + approved, pending, declined bool |
| + }{ |
| + {name: "never applied (nil)", e: nil}, |
| + { |
| + name: "pending", |
| + e: &Entitlement{Applied: &Applied{By: "owner@example.com", At: now}}, |
| + pending: true, |
| + }, |
| + { |
| + name: "approved", |
| + e: &Entitlement{ |
| + Applied: &Applied{By: "owner@example.com", At: now}, |
| + Decision: &Decision{Approved: true, By: "op@example.com", At: now}, |
| + }, |
| + approved: true, |
| + }, |
| + { |
| + name: "declined", |
| + e: &Entitlement{ |
| + Applied: &Applied{By: "owner@example.com", At: now}, |
| + Decision: &Decision{Approved: false, By: "op@example.com", At: now, Reason: "no"}, |
| + }, |
| + declined: true, |
| + }, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if got := tc.e.Approved(); got != tc.approved { |
| + t.Errorf("Approved() = %v, want %v", got, tc.approved) |
| + } |
| + if got := tc.e.Pending(); got != tc.pending { |
| + t.Errorf("Pending() = %v, want %v", got, tc.pending) |
| + } |
| + if got := tc.e.Declined(); got != tc.declined { |
| + t.Errorf("Declined() = %v, want %v", got, tc.declined) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A nil entitlement must be unapproved, not a nil dereference: every account |
| +// starts with no object at all, and the gate is consulted on every declare. |
| +func TestNilEntitlementIsFailClosed(t *testing.T) { |
| + var e *Entitlement |
| + if e.Approved() || e.Pending() || e.Declined() { |
| + t.Fatal("a nil entitlement claimed a state") |
| + } |
| +} |
| + |
| +func TestEntitlementRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Now().UTC().Truncate(time.Second) |
| + |
| + // Absent reads as (nil, "", nil) — Get's idiom, not an error. |
| + got, etag, err := GetEntitlement(ctx, s, "bab") |
| + if err != nil || got != nil || etag != "" { |
| + t.Fatalf("absent entitlement = %+v / %q / %v, want nil/\"\"/nil", got, etag, err) |
| + } |
| + |
| + want := &Entitlement{Applied: &Applied{By: "owner@example.com", At: now, Note: "uploads"}} |
| + if _, err := PutEntitlement(ctx, s, "bab", want, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, etag, err = GetEntitlement(ctx, s, "bab") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Schema != EntitlementSchema || got.Account != "bab" { |
| + t.Fatalf("schema/account stamped wrong: %+v", got) |
| + } |
| + if got.Applied == nil || got.Applied.By != "owner@example.com" || got.Applied.Note != "uploads" || |
| + !got.Applied.At.Equal(now) { |
| + t.Fatalf("applied = %+v", got.Applied) |
| + } |
| + if got.Decision != nil { |
| + t.Fatalf("decision = %+v, want nil for a pending application", got.Decision) |
| + } |
| + if !got.Pending() { |
| + t.Fatal("a written application did not read back as pending") |
| + } |
| + if etag == "" { |
| + t.Fatal("no etag on a written entitlement — CAS would be impossible") |
| + } |
| +} |
| + |
| +// The body must never be able to disagree with its own key. |
| +func TestPutEntitlementStampsAccountOverAnyClaim(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := PutEntitlement(ctx, s, "bab", &Entitlement{Account: "bde", Schema: 99}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _, err := GetEntitlement(ctx, s, "bab") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Account != "bab" || got.Schema != EntitlementSchema { |
| + t.Fatalf("entitlement = %+v, want the key's account and this schema", got) |
| + } |
| +} |
| + |
| +// Two owners applying at once: the loser sees the conflict rather than |
| +// silently erasing the winner's application. |
| +func TestPutEntitlementIsCAS(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := PutEntitlement(ctx, s, "bab", &Entitlement{}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, stale, err := GetEntitlement(ctx, s, "bab") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := PutEntitlement(ctx, s, "bab", &Entitlement{Applied: &Applied{By: "a@example.com"}}, stale); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := PutEntitlement(ctx, s, "bab", &Entitlement{Applied: &Applied{By: "b@example.com"}}, stale); err == nil { |
| + t.Fatal("a stale-etag write was accepted — concurrent applications would clobber each other") |
| + } |
| +} |
| + |
| +func TestEntitlementKeyIsOutsideTheAccountsTree(t *testing.T) { |
| + k := EntitlementKey("bab") |
| + if strings.HasPrefix(k, "accounts/") || strings.HasPrefix(k, "apps/") || strings.HasPrefix(k, "fleets/") { |
| + t.Fatalf("EntitlementKey = %q, which is inside a fleet-readable subtree "+ |
| + "(internal/edge/storedoor.go aclRead) — decline reasons would be tenant-readable", k) |
| + } |
| + if k != "store/entitlements/bab.json" { |
| + t.Fatalf("EntitlementKey = %q", k) |
| + } |
| +} |
| + |
| +func TestGetEntitlementRefusesAnInvalidAccount(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := GetEntitlement(ctx, s, "../../etc"); err == nil { |
| + t.Fatal("an invalid account was accepted") |
| + } |
| + if _, err := PutEntitlement(ctx, s, "../../etc", &Entitlement{}, ""); err == nil { |
| + t.Fatal("an invalid account was accepted for write") |
| + } |
| +} |
diff --git a/internal/objstore/objstore.go b/internal/objstore/objstore.go| index 71d913ea8..254ea2a3a 100644 |
| --- a/internal/objstore/objstore.go |
| +++ b/internal/objstore/objstore.go |
| @@ -29,9 +29,30 @@ type Declared struct { |
| By string `json:"by"` |
| } |
| +// Grant sources (#381). Source records WHY a grant exists, mirroring |
| +// mail.GrantBlock.Source's vocabulary and its reason: an authority that can be |
| +// recomputed later must say what it rested on, or recomputing it silently |
| +// discards a deliberate decision. |
| +// |
| +// Not omitempty, matching mail's. A record written before this field existed |
| +// decodes "" — which reads correctly as "granted by the operator path, before |
| +// we recorded why" — and an empty string in a fresh write is a bug worth |
| +// seeing rather than a field worth hiding. |
| +const ( |
| + // GrantSourceOperator: a deployment operator ran `carlos store grant` for |
| + // this one app. The original and still the override path. |
| + GrantSourceOperator = "operator" |
| + // GrantSourceAccountApproval: the app's ACCOUNT is approved for object |
| + // storage, so the member's own declare provisioned it. By names the |
| + // operator who approved the account — a real person who really did |
| + // authorise this spend, one level up. |
| + GrantSourceAccountApproval = "account-approval" |
| +) |
| + |
| type GrantBlock struct { |
| Backend string `json:"backend"` |
| Bucket string `json:"bucket,omitempty"` // the resolved bucket (minted or adopted; the record does not distinguish) |
| + Source string `json:"source"` |
| At time.Time `json:"at"` |
| By string `json:"by"` |
| } |
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go| index 38b9db52a..23bea3afc 100644 |
| --- a/internal/objstore/walk.go |
| +++ b/internal/objstore/walk.go |
| @@ -130,8 +130,14 @@ func ValidBucketName(name string) bool { return bucketNameRe.MatchString(name) } |
| // 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. |
| +// |
| +// source is GrantSourceOperator or GrantSourceAccountApproval — why this grant |
| +// is authorised — and lands in the grant block beside actor. It is recorded |
| +// only on the FIRST grant, with the block itself: a re-run repairs an estate |
| +// whose authority was already established and must not restate it, exactly as |
| +// it must not re-choose the bucket. |
| 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) { |
| + sealPub *ecdh.PublicKey, acct, app, actor, source, adoptBucket string, now time.Time) (*Record, error) { |
| // 1. resolve + write the grant block |
| rec, etag, err := Get(ctx, s, acct, app) |
| if err != nil { |
| @@ -161,7 +167,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| return nil, fmt.Errorf("objstore: invalid bucket name %q", bucket) |
| } |
| if rec.Grant == nil { |
| - rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, At: now, By: actor} |
| + rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, Source: source, At: now, By: actor} |
| if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil { |
| return nil, err |
| } |
diff --git a/internal/objstore/walk_test.go b/internal/objstore/walk_test.go| index a11b459c8..81180e765 100644 |
| --- a/internal/objstore/walk_test.go |
| +++ b/internal/objstore/walk_test.go |
| @@ -76,7 +76,7 @@ func TestGrantUndeclaredRefused(t *testing.T) { |
| 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) { |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", 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 { |
| @@ -92,7 +92,7 @@ func TestGrantMintsAndDelivers(t *testing.T) { |
| 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) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op@example.com", GrantSourceOperator, "", now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -164,7 +164,7 @@ func TestGrantDeliversEndpointWhenSet(t *testing.T) { |
| d := testDeployment() |
| d.Endpoint = "https://s3.example.net" |
| - rec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + rec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -184,7 +184,7 @@ func TestGrantAdoptsExistingBucket(t *testing.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()) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "titogo-uploads", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -209,7 +209,7 @@ func TestGrantResumesAfterEachStepFailure(t *testing.T) { |
| 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 { |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", 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 |
| @@ -219,7 +219,7 @@ func TestGrantResumesAfterEachStepFailure(t *testing.T) { |
| 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()) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatalf("resume after %s failure: %v", method, err) |
| } |
| @@ -251,7 +251,7 @@ func TestGrantResumesAfterEachStepFailure(t *testing.T) { |
| 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 { |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()); err == nil { |
| t.Fatal("delivery failure: Grant returned no error") |
| } |
| // The record must NOT claim provisioned: the secret never landed. |
| @@ -259,7 +259,7 @@ func TestGrantResumesAfterEachStepFailure(t *testing.T) { |
| t.Fatalf("record after failed delivery = %+v, %v", rec, err) |
| } |
| - rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatalf("resume after delivery failure: %v", err) |
| } |
| @@ -289,14 +289,14 @@ func TestGrantRerunKeepsBucketChoice(t *testing.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 { |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "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()) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -318,13 +318,13 @@ func TestGrantIdempotentWhenHealthy(t *testing.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()) |
| + first, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", 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)) |
| + second, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC().Add(time.Hour)) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -355,7 +355,7 @@ func TestGrantRepairsWrongKindVar(t *testing.T) { |
| 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 { |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| t.Fatal(err) |
| } |
| // Clobber the secret half with a plaintext env var of the same name. |
| @@ -380,7 +380,7 @@ func TestGrantRepairsWrongKindVar(t *testing.T) { |
| t.Fatalf("wrong-kind coordinate: missing = %v, %v", missing, err) |
| } |
| - rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -440,7 +440,7 @@ func TestGrantRefusesInvalidBucketName(t *testing.T) { |
| p := NewMemProvisioner("eu-west-1") |
| declare(t, s, "bab", "titogo", "TITO_S3") |
| - _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "Other-Bucket/*", time.Now().UTC()) |
| + _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "Other-Bucket/*", time.Now().UTC()) |
| if err == nil || !strings.Contains(err.Error(), "invalid bucket name") { |
| t.Fatalf("adopt of an invalid name: %v", err) |
| } |
| @@ -454,7 +454,7 @@ func TestGrantRefusesInvalidBucketName(t *testing.T) { |
| // A deployment whose prefix mints an illegal name is refused the same way. |
| d := testDeployment() |
| d.BucketPrefix = "Carlos_Store-" |
| - if _, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", "", time.Now().UTC()); err == nil || |
| + if _, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()); err == nil || |
| !strings.Contains(err.Error(), "invalid bucket name") { |
| t.Fatalf("minted invalid name: %v", err) |
| } |
| @@ -471,7 +471,7 @@ func TestGrantRefusesUnrenderableSecret(t *testing.T) { |
| p.MintSecret = "bad\"secret\nwith\\newline" |
| declare(t, s, "bab", "titogo", "TITO_S3") |
| - _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err == nil { |
| t.Fatal("unrenderable secret was delivered") |
| } |
| @@ -489,7 +489,7 @@ func TestGrantRefusesUnrenderableSecret(t *testing.T) { |
| } |
| // The wasted key is collected by the next walk's reap. |
| p.MintSecret = "" |
| - rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -509,7 +509,7 @@ func TestGrantUnsetsStaleEndpoint(t *testing.T) { |
| withEndpoint := testDeployment() |
| withEndpoint.Endpoint = "https://s3.example.net" |
| - if _, err := Grant(ctx, s, p, withEndpoint, pub, "bab", "titogo", "op", "", time.Now().UTC()); err != nil { |
| + if _, err := Grant(ctx, s, p, withEndpoint, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| t.Fatal(err) |
| } |
| if _, ok := mustVars(t, s, "bab", "titogo")["TITO_S3_ENDPOINT"]; !ok { |
| @@ -517,7 +517,7 @@ func TestGrantUnsetsStaleEndpoint(t *testing.T) { |
| } |
| // Same app, deployment no longer has an endpoint: the record's endpoint no |
| // longer matches, so the walk must run rather than short-circuit. |
| - rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -548,7 +548,7 @@ func TestEnvDeliveredReportsMissing(t *testing.T) { |
| } |
| declare(t, s, "bab", "titogo", "TITO_S3") |
| - if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); err != nil { |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| t.Fatal(err) |
| } |
| if missing, err = EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", false); err != nil || len(missing) != 0 { |
| @@ -573,7 +573,7 @@ func TestEnvDeliveredReportsMissing(t *testing.T) { |
| func rotateFixture(t *testing.T, s release.Store, pub *ecdh.PublicKey, p *MemProvisioner) *Record { |
| t.Helper() |
| declare(t, s, "bab", "titogo", "TITO_S3") |
| - rec, err := Grant(context.Background(), s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + rec, err := Grant(context.Background(), s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -862,7 +862,7 @@ func TestRotateKeepsRecordedCoordinates(t *testing.T) { |
| d := testDeployment() |
| d.Endpoint = "https://s3.example.net" |
| - granted, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + granted, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1001,7 +1001,7 @@ func TestRotateRefusesRetryAgainstAnotherRotation(t *testing.T) { |
| func teardownFixture(t *testing.T, s release.Store, pub *ecdh.PublicKey, p *MemProvisioner) *Record { |
| t.Helper() |
| declare(t, s, "bab", "titogo", "TITO_S3") |
| - rec, err := Grant(context.Background(), s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + rec, err := Grant(context.Background(), s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1409,6 +1409,58 @@ func TestPurgeGuardSeesOurRecord(t *testing.T) { |
| }) |
| } |
| +// Source records WHY a grant is authorised (#381), so that an approval which |
| +// is later withdrawn can be told apart from an operator's deliberate per-app |
| +// grant — mail.GrantBlock.Source's reasoning, applied to storage. |
| +func TestGrantRecordsItsSource(t *testing.T) { |
| + ctx := context.Background() |
| + _, pub := testSealKeypair(t) |
| + s := release.NewDirStore(t.TempDir()) |
| + p := NewMemProvisioner("eu-west-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", |
| + "op@example.com", GrantSourceAccountApproval, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Grant.Source != GrantSourceAccountApproval { |
| + t.Fatalf("grant source = %q, want %q", rec.Grant.Source, GrantSourceAccountApproval) |
| + } |
| + if rec.Grant.By != "op@example.com" { |
| + t.Fatalf("grant by = %q — an account-approved grant still names the approving operator", rec.Grant.By) |
| + } |
| + stored, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored.Grant.Source != GrantSourceAccountApproval { |
| + t.Fatalf("stored grant = %+v, %v", stored.Grant, err) |
| + } |
| +} |
| + |
| +// A re-run repairs an estate whose authority was already established. Restating |
| +// the source would let a repair rewrite the record's account of who authorised |
| +// the spend — the same reason a re-run never re-chooses the bucket. |
| +func TestGrantDoesNotRestateSourceOnRerun(t *testing.T) { |
| + ctx := context.Background() |
| + _, pub := testSealKeypair(t) |
| + s := release.NewDirStore(t.TempDir()) |
| + p := NewMemProvisioner("eu-west-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", |
| + "op@example.com", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Re-run claiming a different authority; the first one must stand. |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", |
| + "someone@example.com", GrantSourceAccountApproval, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Grant.Source != GrantSourceOperator || rec.Grant.By != "op@example.com" { |
| + t.Fatalf("a re-run rewrote the grant's authority: %+v", rec.Grant) |
| + } |
| +} |
| + |
| // 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 |
| @@ -1462,7 +1514,7 @@ func TestGrantRetriesLostCAS(t *testing.T) { |
| } |
| } |
| - rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| if err != nil { |
| t.Fatalf("grant lost its CAS and gave up: %v", err) |
| } |
| @@ -1512,7 +1564,7 @@ func TestGrantRefusesRetryAgainstAnotherProvision(t *testing.T) { |
| } |
| } |
| - if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); !errors.Is(err, release.ErrCASConflict) { |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", 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") |