carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 0294fa3dd74774df671cfc486cd8fe213c7f35d6 parent 8e3482f
7 files changed, +408 −29
  • internal/console/apistore.go +1 −1
  • internal/edge/storedoor_test.go +13 −0
  • internal/objstore/entitlement.go +127 −0
  • internal/objstore/entitlement_test.go +160 −0
  • internal/objstore/objstore.go +21 −0
  • internal/objstore/walk.go +8 −2
  • internal/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")