carlos / platform Public

store: an approved account gets its bucket from create alone

Stage 5 of #381, and the stage where behaviour changes. Everything before it
was dormant.

carlos store create becomes two doors rather than one. It declares, as it
always did; then, if the account is approved and the deployment has a
backend, it provisions in the same breath and the member never learns the
word "grant". An unapproved account gets exactly what it got before — a
declaration, recorded and waiting — but is now told that applying is a thing
it can do, rather than being left to discover four days later that a person
had to act.

The grant block records WHY it exists: an account-approval grant carries the
approving operator as its actor and account-approval as its source, so the
record answers "who decided this" for both roads into it. The per-app
operator grant verb is untouched, for unapproved accounts, for --bucket
adoption, and for repair.

The provision path rereads the entitlement after taking the per-app walk
mutex, so an approval revoked while a provision queued behind another one
denies rather than proceeding on a decision that no longer holds. A grant
written but not yet provisioned still runs to completion — that was the
first blocker of the 2026-09-03 review, and the regression test for it is
here.

Built by codex (gpt-6-astra) from the brief committed at 7c93be2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfUNFHm89j2mj5q7vBHsfH
Paul Campbell pushed by paul@keymail.dev 12f90442a0e9ec81333642977ef27e4434ed5596 parent e09833c
8 files changed, +724 −44
  • apimode.go +39 −8
  • internal/console/apistore.go +29 −24
  • internal/console/apistore_provision.go +76 −0
  • internal/console/apistore_provision_test.go +454 −0
  • internal/console/apistore_test.go +7 −8
  • internal/console/authcli.go +1 −0
  • store.go +21 −4
  • store_test.go +97 −0
diff --git a/apimode.go b/apimode.go
index 59556c81b..e346114ac 100644
--- a/apimode.go
+++ b/apimode.go
@@ -36,6 +36,7 @@ import (
"github.com/carlosframework/platform/internal/applog"
"github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/ledger"
+ "github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/schedule"
)
@@ -1532,6 +1533,7 @@ type apiStoreGrantBlock struct {
Bucket string `json:"bucket,omitempty"` // the resolved bucket (minted or adopted; the record does not distinguish)
At time.Time `json:"at"`
By string `json:"by"`
+ Source string `json:"source"`
}
type apiStoreProvisioned struct {
@@ -1546,16 +1548,19 @@ type apiStoreProvisioned struct {
}
type apiStoreReply struct {
- App string `json:"app"`
- Backend string `json:"backend"`
- Declared *apiStoreDeclared `json:"declared"`
- Grant *apiStoreGrantBlock `json:"grant"`
- Provisioned *apiStoreProvisioned `json:"provisioned"`
- EnvNames []string `json:"env_names"`
- PendingFinish bool `json:"pending_finish"`
- MissingVars []string `json:"missing_vars"`
+ App string `json:"app"`
+ Backend string `json:"backend"`
+ Declared *apiStoreDeclared `json:"declared"`
+ Grant *apiStoreGrantBlock `json:"grant"`
+ Provisioned *apiStoreProvisioned `json:"provisioned"`
+ EnvNames []string `json:"env_names"`
+ PendingFinish bool `json:"pending_finish"`
+ MissingVars []string `json:"missing_vars"`
+ Entitlement *objstore.Entitlement `json:"entitlement"`
}
+const storeProvisionHTTPTimeout = 75 * time.Second
+
// storeCreate asks the console to declare (or re-declare) a store for app,
// under envPrefix ("" takes the console's own default — objstore.
// DefaultEnvPrefix). Mirrors restart's shape (apimode.go:438): postJSON, a
@@ -1583,6 +1588,32 @@ func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, envPrefix
return out, nil
}
+// storeProvision runs the account-approved provisioning walk. The server may
+// spend its full 60-second storeGrantTimeout on backend work, so this one JSON
+// door gets a scoped client with a slightly longer deadline; mutating the
+// session's ordinary 30-second client would silently widen every later call.
+func (s *apiSession) storeProvision(ctx context.Context, a apiAcct, app string) (apiStoreReply, error) {
+ clone := *s
+ client := *s.c
+ httpc := *s.c.httpc
+ httpc.Timeout = storeProvisionHTTPTimeout
+ client.httpc = &httpc
+ clone.c = &client
+
+ status, body, err := clone.postJSON(ctx, apiPath(a, app, "/store/provision"), struct{}{})
+ if err != nil {
+ return apiStoreReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiStoreReply{}, apiErrorFromBody(status, body)
+ }
+ var out apiStoreReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiStoreReply{}, fmt.Errorf("store create: unreadable provision response: %w", err)
+ }
+ return out, nil
+}
+
// storeGet asks the console for app's store record (`carlos store status`).
// A 404 here is a real diagnosis, not the byte-identical membership 404
// apiAccountApp gives — handleAPIStoreGet answers it with an actual
diff --git a/internal/console/apistore.go b/internal/console/apistore.go
index b218cb409..eb222bf10 100644
--- a/internal/console/apistore.go
+++ b/internal/console/apistore.go
@@ -8,10 +8,9 @@
// THREE AUTHORS, TWO AUTHORITIES. A store record has three blocks for three
// actors (spec §4): the member DECLARES, a deployment operator GRANTS, and
// the console's own walk records what it PROVISIONED. Declaring and rotating
-// are MEMBER verbs — they ask for, or cycle, an estate that costs the
-// deployment nothing new — and they need a bearer token and nothing else.
-// Granting is the one verb that spends money in the DEPLOYMENT's own cloud
-// account, so it needs an address on CARLOS_STORE_OPERATORS as well
+// are member verbs. Provisioning also admits members once a deployment
+// operator has approved their account (apistore_provision.go).
+// Per-app granting needs an address on CARLOS_STORE_OPERATORS as well
// (Config.StoreOperators). That check runs AFTER the member gate on purpose:
// a 403 handed to a stranger would confirm which accounts and apps exist.
// This is the deployment's first explicit operator-identity gate (spec §3).
@@ -111,6 +110,10 @@ func storeEffectivePrefix(rec *objstore.Record) string {
// apps domain: a deployment-wide fact about the platform, carrying nothing
// about any tenant, and every member of it can already see the consequences.
func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) {
+ entitlement, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid)
+ if err != nil {
+ return nil, err
+ }
prefix := storeEffectivePrefix(rec)
wantEndpoint := rec.Provisioned != nil && rec.Provisioned.Endpoint != ""
bucket, region, endpoint, accessKey, secretKey := objstore.EnvNames(prefix)
@@ -129,6 +132,7 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec
}
return map[string]any{
"app": app,
+ "entitlement": entitlement,
"backend": s.cfg.StoreDeployment.Backend,
"declared": rec.Declared,
"grant": rec.Grant,
@@ -173,10 +177,9 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec
// request here — not a zero-length body, which decodes as malformed like
// anywhere else.
//
-// IDEMPOTENT by design: re-declaring the same prefix refreshes `at`/`by` and
-// answers 200. `carlos store create` is a thing people re-run — after a failed
-// grant, from a second terminal, from a deploy script — and turning the second
-// run into an error would make the safe verb the scary one.
+// Re-declaring the same prefix preserves the author, timestamp and etag.
+// A no-op must not make an in-flight provision lose its record write. Members
+// re-run create after a failed walk, from second terminals and deploy scripts.
func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) {
addr, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
@@ -254,24 +257,26 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) {
"store already provisioned — the env prefix is fixed; destroy and re-create to change it")
return
}
- rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: addr}
- if _, err := objstore.Put(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
- if errors.Is(err, release.ErrCASConflict) {
- // Someone else wrote this record between our read and our write —
- // plausibly the operator's grant landing. Re-read and re-ask; this
- // door deliberately does not retry, because the thing it would be
- // overwriting is the half it doesn't own.
- jsonError(w, http.StatusConflict, "the store record changed underneath you — try again")
+ if rec.Declared == nil || storeEffectivePrefix(rec) != prefix {
+ rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: addr}
+ if _, err := objstore.Put(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ // Someone else wrote this record between our read and our write —
+ // plausibly the operator's grant landing. Re-read and re-ask; this
+ // door deliberately does not retry, because the thing it would be
+ // overwriting is the half it doesn't own.
+ jsonError(w, http.StatusConflict, "the store record changed underneath you — try again")
+ return
+ }
+ log.Printf("console: api store create %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not record the store — nothing was declared; try again")
return
}
- log.Printf("console: api store create %s/%s: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusBadGateway, "could not record the store — nothing was declared; try again")
- return
+ // Audited, unlike a read: this is the request an operator will act on by
+ // spending money in the deployment's own cloud account, so who asked and
+ // under what prefix has to be visible to anyone reading rows.
+ s.audit(acct, app, addr, "store-create", "declared a store with env prefix "+prefix+" (cli)")
}
- // Audited, unlike a read: this is the request an operator will act on by
- // spending money in the deployment's own cloud account, so who asked and
- // under what prefix has to be visible to anyone reading rows.
- s.audit(acct, app, addr, "store-create", "declared a store with env prefix "+prefix+" (cli)")
// No bumpSerial: nothing a box reads has changed. See the file comment.
out, err := s.storeReply(ctx, acct, app, rec)
if err != nil {
@@ -323,7 +328,7 @@ func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) {
out, err := s.storeReply(ctx, acct, app, rec)
if err != nil {
log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusBadGateway, "could not read the store's config delivery — try again")
+ jsonError(w, http.StatusBadGateway, "could not read the store's status — try again")
return
}
writeJSON(w, http.StatusOK, out)
diff --git a/internal/console/apistore_provision.go b/internal/console/apistore_provision.go
new file mode 100644
index 000000000..f444b2055
--- /dev/null
+++ b/internal/console/apistore_provision.go
@@ -0,0 +1,76 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+)
+
+// Provision spends the account approval on one app. Grant owns retry and
+// repair: a grant block without provisioning must still run the walk.
+func (s *Server) handleAPIStoreProvision(w http.ResponseWriter, r *http.Request) {
+ actor, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if s.cfg.Releases == nil {
+ storeNoBucket(w)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
+ defer cancel()
+ if _, ok := s.storeProvisionApproval(ctx, w, acct); !ok {
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.storeWalkGates(w, p) {
+ return
+ }
+ pub, ok := s.storeSealKey(w)
+ if !ok {
+ return
+ }
+ var req struct{}
+ if !readJSON(w, r, &req) {
+ return
+ }
+ defer s.storeWalks.lock(acct.Sqid, app)()
+ // A queued request may have waited while an operator revoked approval.
+ // An already-running walk completes; decline does not tear down estate.
+ e, ok := s.storeProvisionApproval(ctx, w, acct)
+ if !ok {
+ return
+ }
+ rec, err := objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment,
+ pub, acct.Sqid, app, e.Decision.By, objstore.GrantSourceAccountApproval, "", time.Now())
+ switch {
+ case errors.Is(err, objstore.ErrNotDeclared):
+ jsonError(w, http.StatusConflict, "no store is declared for this app — run `carlos store create` first")
+ return
+ case err != nil:
+ log.Printf("console: api store provision %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not provision the store — re-run `carlos store create` to resume; after destroying a store, its bucket name may take time to become available again")
+ return
+ }
+ s.audit(acct, app, actor, "store-provision", "provisioned a store in bucket "+storeBucketName(rec)+" (cli)")
+ s.bumpSerial(ctx)
+ s.storeWalkReply(ctx, w, acct, app, rec)
+}
+
+func (s *Server) storeProvisionApproval(ctx context.Context, w http.ResponseWriter, acct *Account) (*objstore.Entitlement, bool) {
+ e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid)
+ if err != nil {
+ log.Printf("console: api store provision %s: read entitlement: %v", acct.Sqid, err)
+ jsonError(w, http.StatusBadGateway, "could not read the storage application — try again")
+ return nil, false
+ }
+ if !e.Approved() {
+ jsonError(w, http.StatusForbidden, "this account is not approved for object storage — `carlos store apply`")
+ return nil, false
+ }
+ return e, true
+}
diff --git a/internal/console/apistore_provision_test.go b/internal/console/apistore_provision_test.go
new file mode 100644
index 000000000..42b80ddce
--- /dev/null
+++ b/internal/console/apistore_provision_test.go
@@ -0,0 +1,454 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func storeProvisionURL(fx *consoleFixture, app string) string {
+ return storeURL(fx, app) + "/provision"
+}
+
+func approveStoreAccount(t *testing.T, fx *consoleFixture, by string) {
+ t.Helper()
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
+ Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Minute)},
+ Decision: &objstore.Decision{Approved: true, By: by, At: time.Now()},
+ })
+}
+
+type pausedBucketProvisioner struct {
+ objstore.Provisioner
+ entered chan struct{}
+ release chan struct{}
+ once sync.Once
+}
+
+func (p *pausedBucketProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) {
+ p.once.Do(func() {
+ close(p.entered)
+ select {
+ case <-p.release:
+ case <-ctx.Done():
+ }
+ })
+ return p.Provisioner.EnsureBucket(ctx, name)
+}
+
+type provisionResult struct {
+ status int
+ out map[string]any
+}
+
+func provisionAsync(t *testing.T, fx *consoleFixture, token string) <-chan provisionResult {
+ t.Helper()
+ ch := make(chan provisionResult, 1)
+ go func() {
+ defer close(ch)
+ status, raw := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token)
+ out := map[string]any{}
+ if err := json.Unmarshal([]byte(raw), &out); err != nil {
+ out["raw"] = raw
+ }
+ ch <- provisionResult{status: status, out: out}
+ }()
+ return ch
+}
+
+func waitPaused(t *testing.T, p *pausedBucketProvisioner) {
+ t.Helper()
+ select {
+ case <-p.entered:
+ case <-time.After(3 * time.Second):
+ t.Fatal("provision did not reach the paused backend")
+ }
+}
+
+func TestAPIStoreProvisionNeedsAnApprovedEntitlement(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-unapproved")
+ declareStore(t, fx, token)
+
+ status, _ := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token)
+ if status < 400 {
+ t.Fatalf("provision without approval = %d, want a refusal", status)
+ }
+ untouched(t, fake, "an unapproved provision")
+ if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
+ t.Fatalf("an unapproved provision wrote a grant: %+v", rec.Grant)
+ }
+}
+
+func TestAPIStoreProvisionWithoutABackendDoesNotGrant(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-nobackend")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ fx.srv.cfg.StoreProvisioner = nil
+
+ status, _ := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("provision without a backend = %d, want 503", status)
+ }
+ untouched(t, fake, "a backend-less provision")
+ if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
+ t.Fatalf("a backend-less provision wrote a grant: %+v", rec.Grant)
+ }
+}
+
+func TestAPIStoreProvisionAttributesAccountApproval(t *testing.T) {
+ fx, fake, token, priv := storeArmed(t, "code-store-provision-happy")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ fx.srv.cfg.StoreOperators = nil
+
+ status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("provision = %d %v, want 200", status, out)
+ }
+ rec := storedRecord(t, fx, "titogo")
+ if rec.Provisioned == nil || rec.Grant == nil {
+ t.Fatalf("provisioned record = %+v", rec)
+ }
+ if rec.Grant.Source != objstore.GrantSourceAccountApproval || rec.Grant.By != "operator@keymail.dev" {
+ t.Fatalf("grant = %+v, want the account approval source and deciding operator", rec.Grant)
+ }
+ u := fake.Users[rec.Provisioned.IAMUser]
+ secret, ok := u.Keys[rec.Provisioned.KeyID]
+ if !ok {
+ t.Fatalf("recorded key %q is absent from backend keys %v", rec.Provisioned.KeyID, u.Keys)
+ }
+ if got := openSecret(t, priv, appVars(t, fx, "titogo")["CARLOS_STORE_SECRET_KEY"]); got != secret {
+ t.Fatalf("delivered secret does not match the recorded backend key")
+ }
+ if entitlementBlock(t, out)["decision"] == nil {
+ t.Fatalf("provision reply omitted the entitlement: %v", out)
+ }
+}
+
+func TestAPIStoreProvisionCompletedStoreIsANoOp(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-noop")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("first provision = %d %v", status, out)
+ }
+ before := storedRecord(t, fx, "titogo").Provisioned.KeyID
+ if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("second provision = %d %v", status, out)
+ }
+ after := storedRecord(t, fx, "titogo").Provisioned.KeyID
+ if after != before || len(fake.Users["carlos-store-bab-titogo"].Keys) != 1 {
+ t.Fatalf("no-op changed key %q to %q or minted extras: %v", before, after, fake.Users)
+ }
+}
+
+func TestAPIStoreProvisionRetriesAFailedGrantWithoutChangingBucket(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-retry")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ fake.FailNext["EnsureBucket"] = errors.New("backend detail must stay private")
+
+ status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusBadGateway {
+ t.Fatalf("failed provision = %d %v, want 502", status, out)
+ }
+ if strings.Contains(str(t, out, "error"), "backend detail") {
+ t.Fatalf("provision response leaked the backend failure: %v", out)
+ }
+ failed := storedRecord(t, fx, "titogo")
+ if failed.Grant == nil || failed.Provisioned != nil {
+ t.Fatalf("failed provision record = %+v, want grant-only", failed)
+ }
+ bucket := failed.Grant.Bucket
+ if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("retry provision = %d %v, want 200", status, out)
+ }
+ got := storedRecord(t, fx, "titogo")
+ if got.Provisioned == nil || got.Provisioned.Bucket != bucket || got.Grant.Bucket != bucket {
+ t.Fatalf("retry changed or failed to provision bucket %q: %+v", bucket, got)
+ }
+}
+
+func TestAPIStoreProvisionSerializesAndRereadsEntitlement(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-revoke")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})}
+ fx.srv.cfg.StoreProvisioner = paused
+ defer func() {
+ select {
+ case <-paused.release:
+ default:
+ close(paused.release)
+ }
+ }()
+
+ first := provisionAsync(t, fx, token)
+ waitPaused(t, paused)
+ second := provisionAsync(t, fx, token)
+ waitStoreProvisionQueued(t, fx)
+ e, etag, err := objstore.GetEntitlement(context.Background(), fx.rel, fx.sqid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ e.Decision = &objstore.Decision{Approved: false, By: "operator@keymail.dev", At: time.Now(), Reason: "capacity review"}
+ if _, err := objstore.PutEntitlement(context.Background(), fx.rel, fx.sqid, e, etag); err != nil {
+ t.Fatal(err)
+ }
+ close(paused.release)
+ if got := <-first; got.status != http.StatusOK {
+ t.Fatalf("first provision = %d %v, want 200", got.status, got.out)
+ }
+ if got := <-second; got.status < 400 {
+ t.Fatalf("queued provision after revocation = %d %v, want a refusal", got.status, got.out)
+ }
+ status, out := getJSON(t, storeURL(fx, "titogo"), token)
+ if status != http.StatusOK || block(t, entitlementBlock(t, out), "decision")["approved"] != false {
+ t.Fatalf("declined status = %d %v", status, out)
+ }
+ rec := storedRecord(t, fx, "titogo")
+ if rec.Provisioned == nil || len(fake.Users[rec.Provisioned.IAMUser].Keys) != 1 {
+ t.Fatalf("serialized provisions left the wrong estate: record=%+v users=%v", rec, fake.Users)
+ }
+}
+
+func TestAPIStoreDeclareSamePrefixDuringProvisionIsNoOp(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-declare-race")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})}
+ fx.srv.cfg.StoreProvisioner = paused
+ defer func() {
+ select {
+ case <-paused.release:
+ default:
+ close(paused.release)
+ }
+ }()
+ result := provisionAsync(t, fx, token)
+ waitPaused(t, paused)
+
+ before, beforeETag, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("same-prefix declare = %d %v, want 200", status, out)
+ }
+ after, afterETag, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if afterETag != beforeETag || after.Declared.By != before.Declared.By || !after.Declared.At.Equal(before.Declared.At) {
+ t.Fatalf("same-prefix declare changed record: etag %q -> %q, declared %+v -> %+v", beforeETag, afterETag, before.Declared, after.Declared)
+ }
+ close(paused.release)
+ if got := <-result; got.status != http.StatusOK {
+ t.Fatalf("provision after no-op declare = %d %v, want 200", got.status, got.out)
+ }
+}
+
+func TestAPIStoreAdminServiceDeclarationKeepsServiceAttribution(t *testing.T) {
+ fx, _, _, _ := storeArmed(t, "code-store-provision-service")
+ admin := serviceToken(t, fx.st, OpsAccountID, "storebot", "", svcrole.Admin)
+
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, admin)
+ if status != http.StatusOK {
+ t.Fatalf("admin service declare = %d %v, want 200", status, out)
+ }
+ if got := storedRecord(t, fx, "titogo").Declared.By; got != "service:bab/storebot" {
+ t.Fatalf("declared.by = %q, want service attribution", got)
+ }
+}
+
+func waitStoreProvisionQueued(t *testing.T, fx *consoleFixture) {
+ t.Helper()
+ deadline := time.Now().Add(3 * time.Second)
+ for {
+ fx.srv.storeWalks.mu.Lock()
+ e := fx.srv.storeWalks.m[fx.sqid+"/titogo"]
+ refs := 0
+ if e != nil {
+ refs = e.refs
+ }
+ fx.srv.storeWalks.mu.Unlock()
+ if refs == 2 {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("second provision never queued: refs=%d", refs)
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
+
+func TestAPIStoreConcurrentProvisionsMintOneDeliveredKey(t *testing.T) {
+ fx, fake, token, priv := storeArmed(t, "code-store-provision-two")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})}
+ fx.srv.cfg.StoreProvisioner = paused
+ defer func() {
+ select {
+ case <-paused.release:
+ default:
+ close(paused.release)
+ }
+ }()
+ first := provisionAsync(t, fx, token)
+ waitPaused(t, paused)
+ second := provisionAsync(t, fx, token)
+ waitStoreProvisionQueued(t, fx)
+ close(paused.release)
+ for _, result := range []<-chan provisionResult{first, second} {
+ select {
+ case got := <-result:
+ if got.status != http.StatusOK {
+ t.Fatalf("provision = %d %v", got.status, got.out)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("provision did not finish")
+ }
+ }
+ rec := storedRecord(t, fx, "titogo")
+ if rec.Provisioned == nil {
+ t.Fatal("store was not provisioned")
+ }
+ keys := fake.Users[rec.Provisioned.IAMUser].Keys
+ if rec.Provisioned.KeyID != "MEMKEY1" || len(keys) != 1 {
+ t.Fatalf("wanted exactly one mint: record=%+v keys=%v", rec.Provisioned, keys)
+ }
+ vars := appVars(t, fx, "titogo")
+ if openSecret(t, priv, vars["CARLOS_STORE_ACCESS_KEY"]) != rec.Provisioned.KeyID {
+ t.Fatal("delivered key differs from record")
+ }
+ if openSecret(t, priv, vars["CARLOS_STORE_SECRET_KEY"]) != keys[rec.Provisioned.KeyID] {
+ t.Fatal("delivered secret differs from backend")
+ }
+}
+
+func TestAPIStoreDeclareReportsEntitlementWithoutProvisioning(t *testing.T) {
+ for _, approved := range []bool{false, true} {
+ for _, backend := range []bool{false, true} {
+ t.Run(fmt.Sprintf("approved=%t/backend=%t", approved, backend), func(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-declare-entitlement")
+ if approved {
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ }
+ if !backend {
+ fx.srv.cfg.StoreProvisioner = nil
+ fx.srv.cfg.StoreDeployment = objstore.Deployment{}
+ }
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("declare = %d %v", status, out)
+ }
+ if _, present := out["entitlement"]; !present {
+ t.Fatal("reply omitted entitlement")
+ }
+ if approved {
+ if !block(t, entitlementBlock(t, out), "decision")["approved"].(bool) {
+ t.Fatal("approval missing")
+ }
+ } else if out["entitlement"] != nil {
+ t.Fatalf("unexpected entitlement: %v", out)
+ }
+ if (out["backend"] != "") != backend {
+ t.Fatalf("backend mismatch: %v", out)
+ }
+ if out["grant"] != nil || out["provisioned"] != nil {
+ t.Fatalf("declare minted estate: %v", out)
+ }
+ untouched(t, fake, "declare")
+ })
+ }
+ }
+}
+
+func TestAPIStoreProvisionMemberAndServiceGates(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-gates")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ stranger := cliToken(t, fx, "stranger@keymail.dev", "code-store-provision-stranger")
+ status, body := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, stranger)
+ if status != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("stranger = %d %q", status, body)
+ }
+ for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance, svcrole.Operate} {
+ svc := serviceToken(t, fx.st, OpsAccountID, "denied-"+string(role), "", role)
+ status, _ := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, svc)
+ if status != http.StatusForbidden {
+ t.Fatalf("service %s = %d", role, status)
+ }
+ }
+ untouched(t, fake, "refused provision")
+ admin := serviceToken(t, fx.st, OpsAccountID, "store-admin", "titogo", svcrole.Admin)
+ status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, admin)
+ if status != http.StatusOK {
+ t.Fatalf("admin provision = %d %v", status, out)
+ }
+ if storedRecord(t, fx, "titogo").Grant.By != "operator@keymail.dev" {
+ t.Fatal("service replaced approving actor")
+ }
+ auditedBy(t, fx, "titogo", "store-provision", "service:bab/store-admin", "provisioned")
+}
+
+func TestAPIStoreProvisionRequiresDeclarationAndSealKey(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-preconditions")
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("undeclared = %d %v", status, out)
+ }
+ declareStore(t, fx, token)
+ fx.srv.cfg.SecretsPub = ""
+ status, out = postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("unsealable = %d %v", status, out)
+ }
+ untouched(t, fake, "unsealable provision")
+}
+
+func TestAPIStoreProvisionCannotAdoptABucket(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-no-adoption")
+ declareStore(t, fx, token)
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{"bucket": "someone-elses-data"}, token)
+ if status != http.StatusOK && status != http.StatusBadRequest {
+ t.Fatalf("provision with adoption input = %d %v", status, out)
+ }
+ if _, exists := fake.Buckets["someone-elses-data"]; exists {
+ t.Fatal("member provision adopted caller bucket")
+ }
+ rec := storedRecord(t, fx, "titogo")
+ if rec.Grant != nil && rec.Grant.Bucket != objstore.MintedBucket(fx.srv.cfg.StoreDeployment, fx.sqid, "titogo") {
+ t.Fatalf("grant chose caller bucket: %+v", rec.Grant)
+ }
+}
+
+func TestAPIStoreProvisionFailsClosedOnUnreadableApproval(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-provision-unreadable")
+ declareStore(t, fx, token)
+ if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), []byte("private backend details"), ""); err != nil {
+ t.Fatal(err)
+ }
+ status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusBadGateway {
+ t.Fatalf("unreadable approval = %d %v", status, out)
+ }
+ if strings.Contains(str(t, out, "error"), "private") {
+ t.Fatal("approval error leaked backend details")
+ }
+ untouched(t, fake, "unreadable approval")
+}
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go
index bcca27b2f..02c128e6e 100644
--- a/internal/console/apistore_test.go
+++ b/internal/console/apistore_test.go
@@ -157,9 +157,8 @@ func TestAPIStoreDeclareWritesTheRecord(t *testing.T) {
}
// TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent: a chosen prefix renames
-// every delivered variable, and re-declaring the SAME prefix is a refresh, not
-// a conflict — `carlos store create` has to be safe to run twice, including by
-// a second member of the same account.
+// every delivered variable, and re-declaring the same prefix preserves the
+// original declaration. A second member can safely run create again.
func TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent(t *testing.T) {
fx, token := storeFixture(t, "code-store-prefix")
@@ -184,9 +183,9 @@ func TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent(t *testing.T) {
if rec == nil || rec.Declared == nil || rec.Declared.EnvPrefix != "BLOBS" {
t.Fatalf("record after re-declare = %+v", rec)
}
- // Refreshed, not merely tolerated: the trail says who asked most recently.
- if rec.Declared.By != "bob@keymail.dev" {
- t.Fatalf("declared.by = %q, want the second caller", rec.Declared.By)
+ // A retry preserves the original author and does not race a grant write.
+ if rec.Declared.By != "alice@keymail.dev" {
+ t.Fatalf("declared.by = %q, want the original caller", rec.Declared.By)
}
}
@@ -263,7 +262,7 @@ func TestAPIStorePrefixIsFixedOnceGranted(t *testing.T) {
if rec := storedRecord(t, fx, "titogo"); rec.Declared.EnvPrefix != "BLOBS" {
t.Fatalf("a refused re-declare changed the prefix to %q", rec.Declared.EnvPrefix)
}
- // The same prefix still refreshes: a re-run of `carlos store create` must
+ // The same prefix remains a no-op: a re-run of `carlos store create` must
// not turn into an error the moment an operator picks the request up.
if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token); status != http.StatusOK {
t.Fatalf("same-prefix re-declare mid-grant = %d %v, want 200", status, out)
@@ -301,7 +300,7 @@ func TestAPIStorePrefixIsFixedOnceProvisioned(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("same-prefix re-declare on a provisioned store = %d %v, want 200", status, out)
}
- // The provisioning survives the refresh — the reply echoes the whole
+ // The provisioning survives the no-op — the reply echoes the whole
// record, not just the half this door writes.
if got := block(t, out, "provisioned")["bucket"]; got != "carlos-bab-titogo" {
t.Fatalf("provisioned.bucket = %v after a re-declare", got)
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 03af570e0..7f95c1338 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -699,6 +699,7 @@ func (s *Server) registerCLIRoutes() {
// .../store, never {wildcards}, so no app or host name can reach them.
s.cliRoute("POST /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreCreate)
s.cliRoute("GET /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGet)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/store/provision", svcrole.Allow(svcrole.Admin), s.handleAPIStoreProvision)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/grant", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGrant)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotate)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate/finish", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotateFinish)
diff --git a/store.go b/store.go
index 8a7dfe5a0..ef141f7f9 100644
--- a/store.go
+++ b/store.go
@@ -147,11 +147,26 @@ func runStoreCreate(args []string) {
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
name = resolvedApp(sess, acct, name, usage)
- res, err := sess.storeCreate(context.Background(), acct, name, strings.TrimSpace(*envPrefix))
- if err != nil {
+ if err := storeCreateAndProvision(context.Background(), sess, acct, name, strings.TrimSpace(*envPrefix), stdout); err != nil {
log.Fatalf("store create: %v", err)
}
- printStoreCreate(stdout, res)
+}
+
+func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct, app, envPrefix string, out io.Writer) error {
+ res, err := sess.storeCreate(ctx, acct, app, envPrefix)
+ if err != nil {
+ return err
+ }
+ printStoreCreate(out, res)
+ if res.Backend == "" || !res.Entitlement.Approved() {
+ return nil
+ }
+ res, err = sess.storeProvision(ctx, acct, app)
+ if err != nil {
+ return err
+ }
+ printStoreGrant(out, res)
+ return nil
}
func runStoreStatus(args []string) {
@@ -258,7 +273,9 @@ func printStoreCreate(w io.Writer, res apiStoreReply) {
}
fmt.Fprintf(w, "%s %s under prefix %s — delivers %s\n",
c.ok("declared a store for"), res.App, prefix, strings.Join(res.EnvNames, ", "))
- mutedf(w, "nothing is provisioned until a deployment operator grants it (carlos store grant --app %s)", res.App)
+ if !res.Entitlement.Approved() {
+ mutedf(w, "nothing is provisioned until a deployment operator grants it (carlos store grant --app %s)", res.App)
+ }
if res.Backend == "" {
warnf(w, "this deployment has no object storage backend configured yet (the operator sets "+
"CARLOS_STORE_BACKEND) — the declaration will simply wait until one is")
diff --git a/store_test.go b/store_test.go
index b7b2decb0..05c21a354 100644
--- a/store_test.go
+++ b/store_test.go
@@ -16,8 +16,21 @@ import (
"os/exec"
"strings"
"testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
)
+func approvedEntitlement() *objstore.Entitlement {
+ return &objstore.Entitlement{
+ Schema: objstore.EntitlementSchema,
+ Account: "bac",
+ Applied: &objstore.Applied{By: "owner@example.com", At: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)},
+ Decision: &objstore.Decision{Approved: true, By: "operator@example.com",
+ At: time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)},
+ }
+}
+
// ---- apiSession.store* wire shape -------------------------------------------
// TestAPISessionStoreCreateWireShape pins handleAPIStoreCreate's contract: a
@@ -98,6 +111,90 @@ func TestAPISessionStoreCreateSendsChosenPrefix(t *testing.T) {
}
}
+func TestStoreCreateAndProvisionPrintsDeclarationBeforeProvisionFailure(t *testing.T) {
+ var paths []string
+ var out bytes.Buffer
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ paths = append(paths, r.URL.Path)
+ switch r.URL.Path {
+ case "/api/cli/apps/bac/keymail/store":
+ json.NewEncoder(w).Encode(apiStoreReply{
+ App: "keymail", Backend: "aws", Entitlement: approvedEntitlement(),
+ Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE", By: "owner@example.com"},
+ EnvNames: []string{"CARLOS_STORE_BUCKET"},
+ })
+ case "/api/cli/apps/bac/keymail/store/provision":
+ if !strings.Contains(out.String(), "declared a store for keymail") {
+ t.Errorf("provision began before declaration was printed: %q", out.String())
+ }
+ w.WriteHeader(http.StatusBadGateway)
+ json.NewEncoder(w).Encode(map[string]string{"error": "object storage provisioning failed — retry store create; a recently destroyed bucket name may still be unavailable"})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer ts.Close()
+
+ err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", &out)
+ if err == nil || !strings.Contains(err.Error(), "provisioning failed") {
+ t.Fatalf("error = %v, want provision failure", err)
+ }
+ if got := strings.Join(paths, ","); got != "/api/cli/apps/bac/keymail/store,/api/cli/apps/bac/keymail/store/provision" {
+ t.Fatalf("paths = %q, want declare then provision", got)
+ }
+}
+
+func TestStoreCreateAndProvisionBranchesOnReply(t *testing.T) {
+ tests := []struct {
+ name string
+ backend string
+ entitlement *objstore.Entitlement
+ wantCalls int
+ }{
+ {name: "approved with backend", backend: "aws", entitlement: approvedEntitlement(), wantCalls: 2},
+ {name: "approved without backend", entitlement: approvedEntitlement(), wantCalls: 1},
+ {name: "not applied", backend: "aws", wantCalls: 1},
+ {name: "pending", backend: "aws", entitlement: &objstore.Entitlement{Applied: &objstore.Applied{}}, wantCalls: 1},
+ {name: "declined", backend: "aws", entitlement: &objstore.Entitlement{Applied: &objstore.Applied{}, Decision: &objstore.Decision{Approved: false}}, wantCalls: 1},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ calls := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ if strings.HasSuffix(r.URL.Path, "/provision") {
+ json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Provisioned: &apiStoreProvisioned{Bucket: "b", Region: "eu-west-1", IAMUser: "u"}})
+ return
+ }
+ json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}})
+ }))
+ defer ts.Close()
+ if err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", new(bytes.Buffer)); err != nil {
+ t.Fatal(err)
+ }
+ if calls != tt.wantCalls {
+ t.Fatalf("calls = %d, want %d", calls, tt.wantCalls)
+ }
+ })
+ }
+}
+
+func TestStoreProvisionUsesLongWalkClient(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(40 * time.Millisecond)
+ json.NewEncoder(w).Encode(apiStoreReply{App: "keymail"})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ s.c.httpc.Timeout = time.Millisecond
+ if _, err := s.storeProvision(context.Background(), apiAcct{Seg: "bac"}, "keymail"); err != nil {
+ t.Fatalf("storeProvision inherited ordinary JSON timeout: %v", err)
+ }
+ if s.c.httpc.Timeout != time.Millisecond {
+ t.Fatalf("ordinary client timeout changed to %s", s.c.httpc.Timeout)
+ }
+}
+
// TestAPISessionStoreGetWireShape pins handleAPIStoreGet's contract: a GET
// to the same .../store path, decoding the full reply including drift and
// pending-finish.