carlos / platform Public

console: store grant behind operator allowlist, rotate + finish

The deployment's first operator-identity gate: POST .../store/grant runs the
member 404 first, then requires an address on CARLOS_STORE_OPERATORS, then
refuses to mint anything it cannot seal and deliver. Rotate and finish are
member verbs (spec §8) on the same gates minus the allowlist; finish needs no
sealing key and bumps no serial, because it delivers nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7dcd46b838436ecd4f7aaf597e1ec2b5cda29e90 parent bee27a5
3 files changed, +860 −20
  • internal/console/apistore.go +360 −16
  • internal/console/apistore_test.go +487 −0
  • internal/console/authcli.go +13 −4
diff --git a/internal/console/apistore.go b/internal/console/apistore.go
index 7df12beac..e7303d20f 100644
--- a/internal/console/apistore.go
+++ b/internal/console/apistore.go
@@ -1,33 +1,41 @@
-// The CLI's object-storage API, member half (issue #55): `carlos store
-// create` declares that an app wants a bucket, and `carlos store status`
-// answers what became of that request. Both behind the same apiAccountApp
-// gate as ship/promote/config — an unknown account, an unknown app and
-// someone else's account are one byte-identical 404.
+// The CLI's object-storage API (issue #55): `carlos store create` declares
+// that an app wants a bucket, `carlos store status` answers what became of
+// that request, and grant/rotate/finish drive the walks that make it real.
+// Every door here sits behind the same apiAccountApp gate as
+// ship/promote/config — an unknown account, an unknown app and someone else's
+// account are one byte-identical 404.
//
-// THE MEMBER HALF WRITES ONE BLOCK AND READS ALL THREE. A store record has
-// three authors (spec §4): the member DECLARES, a deployment operator GRANTS,
-// and the console's own walk records what it PROVISIONED. This file only ever
-// writes `declared` — nothing here creates a bucket, mints a key or spends a
-// cent, which is why declaring is a member action needing nothing but a
-// bearer token. The operator doors (Task 9) are a separate authority.
+// 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
+// (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).
//
-// NOTHING SECRET CROSSES THIS DOOR, in either direction. The record holds
+// NOTHING SECRET CROSSES THESE DOORS, in either direction. The record holds
// bucket names, an IAM user, and access key IDs; the secret half of a key
// exists only sealed inside the app's config bundle and is never written to
// the record at all (objstore.Record's doc comment). Key IDs are echoed
// deliberately — `status` prints them so a member can tell which credential
// their app is running on, and match it against a rotation.
//
-// NO SERIAL BUMP on the write, unlike promote/config/restart: a declaration
-// changes nothing a box reads. Delivery happens later, through the app's
-// config bundle, and THAT write does the announcing (objstore.Grant).
+// THE SERIAL BUMPS EXACTLY WHERE CONFIG MOVED. Declaring changes nothing a
+// box reads, and neither does finishing a rotation (it deletes a key in the
+// backend and forgets it in the record); both are silent. Grant and rotate
+// write the app's config bundle, so both announce, and the fleet picks the new
+// credentials up in seconds instead of at the hourly floor.
//
// Spec: docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md
-// §4, §7.
+// §3, §4, §5, §7, §8.
package console
import (
"context"
+ "crypto/ecdh"
"errors"
"fmt"
"log"
@@ -35,6 +43,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -318,3 +327,338 @@ func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, out)
}
+
+// ---- the walk doors: grant, rotate, finish -----------------------------------
+
+// storeGrantTimeout bounds the grant walk. Longer than storeTimeout because a
+// grant is not one round trip: it creates a bucket, an IAM principal with an
+// inline policy, and a key, then writes config and the record. Bucket creation
+// alone can take seconds on a cold region, and a walk cut off mid-sequence
+// costs the operator a re-run they shouldn't need.
+const storeGrantTimeout = 60 * time.Second
+
+// storeRotateTimeout bounds rotate and finish. Both act on an estate that
+// already exists — delete, reap, mint, deliver — so they are shorter than a
+// grant but still well past the 15s a single object write gets.
+const storeRotateTimeout = 30 * time.Second
+
+// storeOperator reports whether this address may drive the grant door.
+//
+// Case-insensitive because the list is typed by a human into a unit file and
+// addresses are not case-sensitive; an operator whose keymail address renders
+// as Alice@… on one screen and alice@… on another must not silently lose the
+// authority. Empty list is fail-closed — the loop simply never matches — which
+// is the right direction for a gate on spending money.
+func (s *Server) storeOperator(addr string) bool {
+ for _, op := range s.cfg.StoreOperators {
+ if strings.EqualFold(strings.TrimSpace(op), addr) {
+ return true
+ }
+ }
+ return false
+}
+
+// storeWalkGates are the gates every store WALK door shares, after the caller
+// has resolved the member (and, for grant, checked the operator allowlist):
+// the write budget, a bucket to record into, and a backend to act on.
+//
+// The provisioner check is here rather than inside the walks because a
+// deployment with no object storage is a configuration fact, not a failure of
+// this request: the member's declaration still stands and the sentence names
+// the variable that would wire it up, which is the only thing the person
+// reading it can do about it.
+func (s *Server) storeWalkGates(w http.ResponseWriter, addr string) bool {
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return false
+ }
+ if s.cfg.Releases == nil {
+ storeNoBucket(w)
+ return false
+ }
+ if s.cfg.StoreProvisioner == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "object storage is not configured for this deployment (CARLOS_STORE_BACKEND)")
+ return false
+ }
+ return true
+}
+
+// storeSealKey is the gate that says: no sealing key, no minting.
+//
+// A credential this console cannot seal is one it cannot DELIVER, and a key
+// minted without delivery is the worst state in the system — it exists in the
+// backend, it is billable, its secret died in this process, and only a reap
+// finds it again. So grant and rotate refuse before the first backend call
+// rather than after. Finish does not call this at all: it seals nothing.
+func (s *Server) storeSealKey(w http.ResponseWriter) (*ecdh.PublicKey, bool) {
+ if s.cfg.SecretsPub == "" {
+ jsonError(w, http.StatusServiceUnavailable,
+ "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver a store credential, so it will not mint one")
+ return nil, false
+ }
+ pub, err := appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY")
+ if err != nil {
+ // Logged with the parse detail, answered without it: the caller cannot
+ // fix this console's configuration and the sentence they get is the one
+ // that tells the operator where to look.
+ log.Printf("console: api store seal key: %v", err)
+ jsonError(w, http.StatusServiceUnavailable,
+ "this console's secrets key (CARLOS_SECRETS_PUBKEY) cannot be read — it cannot deliver a store credential, so it will not mint one")
+ return nil, false
+ }
+ return pub, true
+}
+
+// storeBucketName is the bucket a record names, for an audit line: what was
+// provisioned if the walk got that far, otherwise what the grant chose.
+func storeBucketName(rec *objstore.Record) string {
+ switch {
+ case rec == nil:
+ return ""
+ case rec.Provisioned != nil:
+ return rec.Provisioned.Bucket
+ case rec.Grant != nil:
+ return rec.Grant.Bucket
+ }
+ return ""
+}
+
+// storeWalkReply is the shared tail of all three walk doors: render the same
+// answer shape the member doors give, from the record the walk just wrote.
+// A walk that succeeded and a reply that could not be rendered are different
+// events, and the sentence says so — the thing was done, go and look at it —
+// because telling someone their grant failed when it didn't is how a second
+// grant gets run over a live estate.
+func (s *Server) storeWalkReply(ctx context.Context, w http.ResponseWriter, acct *Account, app string, rec *objstore.Record) {
+ out, err := s.storeReply(ctx, acct, app, rec)
+ if err != nil {
+ log.Printf("console: api store reply %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway,
+ "the store changed, but its status could not be read — run `carlos store status`")
+ return
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// handleAPIStoreGrant answers `carlos store grant`: provision the declared
+// store and deliver its credentials. THIS IS THE AUTHORITY DOOR — the one
+// request in the whole API that creates a bucket, mints an IAM key and starts
+// a bill in the deployment's own cloud account.
+//
+// The doors, in order, and why this order:
+//
+// token — cliUser's one 401, before any name is resolved.
+// account+app 404 — apiAccountApp, FIRST and before the allowlist. An
+// operator is not automatically a member: eve holding the
+// operator role must still get the same 404 for an account
+// she isn't in that a stranger gets, or this door becomes
+// the one place in the API that confirms an account or an
+// app exists. Spec §3 wants both — the operator allowlist
+// AND account membership.
+// allowlist — 403, naming CARLOS_STORE_OPERATORS. Safe to speak here:
+// the caller has already proved membership, so all they
+// learn is what authority THEY lack.
+// rate limit — the shared write budget, keyed on the ADDRESS.
+// Releases nil — 503; the record and the delivered config are both bucket
+// objects.
+// backend nil — 503, naming CARLOS_STORE_BACKEND: this deployment has no
+// object storage at all, which is the one thing the member
+// who declared could not see for themselves.
+// seal key — 503. Cannot seal → cannot deliver → MUST NOT MINT.
+// bucket name — 400 before any backend call. An adopt name is
+// interpolated into the IAM policy's resource ARN, so an
+// unchecked one is a scope nobody meant to grant. The walk
+// validates it too (objstore.ValidBucketName, the same
+// function) — this copy exists so the refusal is a clean
+// 400 with the name in it rather than a 502.
+//
+// IDEMPOTENT, because the walk is: re-running over a healthy store is a no-op
+// that mints nothing. A re-run over a DRIFTED one — a delivered variable
+// unset, a key gone from the backend — repairs it by reaping and re-minting,
+// which means the app's key CHANGES. That is the repair working as designed
+// (§5), and it is why the client sentence for a failed grant says "re-run to
+// resume" rather than promising nothing will move.
+func (s *Server) handleAPIStoreGrant(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.storeOperator(addr) {
+ jsonError(w, http.StatusForbidden,
+ "store grants need a deployment operator (CARLOS_STORE_OPERATORS)")
+ return
+ }
+ if !s.storeWalkGates(w, addr) {
+ return
+ }
+ pub, ok := s.storeSealKey(w)
+ if !ok {
+ return
+ }
+ // The body carries one optional field: a bucket to ADOPT instead of
+ // minting. `{}` is the ordinary request, as on the create door.
+ var req struct {
+ Bucket string `json:"bucket"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ bucket := strings.TrimSpace(req.Bucket)
+ if bucket != "" && !objstore.ValidBucketName(bucket) {
+ jsonError(w, http.StatusBadRequest,
+ fmt.Sprintf("invalid bucket name %q — want 3-63 characters of lowercase letters, digits, dots and hyphens, starting and ending alphanumeric", req.Bucket))
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
+ defer cancel()
+ rec, err := objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment,
+ pub, acct.Sqid, app, addr, bucket, time.Now())
+ switch {
+ case errors.Is(err, objstore.ErrNotDeclared):
+ // The operator's verb never invents the member's half: the env prefix
+ // is the member's choice and there is nothing to deliver into.
+ jsonError(w, http.StatusConflict,
+ "no store is declared for this app — run `carlos store create` first")
+ return
+ case err != nil:
+ // Logged, not returned: a backend error carries bucket names, ARNs,
+ // endpoints and request ids. The client sentence promises exactly what
+ // the walk's ordering guarantees — every resource this walk created is
+ // named by a record it wrote before creating it, so nothing is stranded
+ // where only a bill would find it — and the fix is to run the same verb
+ // again, which resumes from wherever it stopped.
+ log.Printf("console: api store grant %s/%s (bucket %q): %v", acct.Sqid, app, bucket, err)
+ jsonError(w, http.StatusBadGateway,
+ "could not provision the store — nothing unrecorded was left behind; re-run the grant to resume")
+ return
+ }
+ s.audit(acct, app, addr, "store-grant", "granted a store in bucket "+storeBucketName(rec)+" (cli)")
+ // The app's config bundle changed: boxes read config only when the serial
+ // moves, so without this the credentials sit in the bucket until the hourly
+ // pass and the app keeps failing to reach a store it has been given.
+ s.bumpSerial(ctx)
+ s.storeWalkReply(ctx, w, acct, app, rec)
+}
+
+// handleAPIStoreRotate answers `carlos store rotate`: mint a fresh key,
+// deliver it, and leave the OLD key live until an explicit finish.
+//
+// A MEMBER VERB, not an operator one (spec §8, and the route table in §10).
+// Rotation spends nothing new — it cycles a credential inside an estate an
+// operator already granted — and the person who needs it most is a member who
+// has just leaked a key at 2am. Making them find a deployment operator first
+// would make the safety verb the slow one. The gates are therefore create's,
+// plus the two 503s that any minting door owes: a backend to mint in and a key
+// to seal with.
+//
+// The overlap the walk leaves behind is the point: adoption swaps a symlink,
+// it does not restart processes, so at the instant new credentials land there
+// are still hosts and hibernating tenants holding the old pair. `pending_finish`
+// in the reply is how a member sees that window is open.
+func (s *Server) handleAPIStoreRotate(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.storeWalkGates(w, addr) {
+ return
+ }
+ pub, ok := s.storeSealKey(w)
+ if !ok {
+ return
+ }
+ // No body: a rotation has no arguments, and handleAPIRestart — the other
+ // verb whose whole request is "do the thing to this app" — reads none
+ // either, so a CLI can POST with nothing at all.
+ ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout)
+ defer cancel()
+ rec, err := objstore.Rotate(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, pub, acct.Sqid, app, time.Now())
+ switch {
+ case errors.Is(err, objstore.ErrNotProvisioned), errors.Is(err, objstore.ErrNotDeclared):
+ // Rotation edits an existing estate; it never creates one, because the
+ // coordinates it reseals come from the record and there is no
+ // defensible guess for them. The repair for this state is a grant.
+ jsonError(w, http.StatusConflict,
+ "no provisioned store to rotate — grant one first")
+ return
+ case errors.Is(err, release.ErrCASConflict):
+ // A concurrent write to the record. NOT a failed rotation: by the time
+ // the walk reaches its final Put it has already minted a key and
+ // delivered it into the app's config, so telling the caller nothing
+ // happened would send them looking for a key that is already live. The
+ // honest instruction is to run it again, which converges.
+ log.Printf("console: api store rotate %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusConflict,
+ "another rotation is in progress — re-run to converge")
+ return
+ case err != nil:
+ log.Printf("console: api store rotate %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway,
+ "could not rotate the store credential — nothing unrecorded was left behind; re-run the rotation to resume")
+ return
+ }
+ key := ""
+ if rec.Provisioned != nil {
+ key = rec.Provisioned.KeyID
+ }
+ // Key ids are not secrets — `status` prints them — and the audit row is
+ // where a member later matches "which credential did we cut over to".
+ s.audit(acct, app, addr, "store-rotate", "rotated the store credential to key "+key+" (cli)")
+ s.bumpSerial(ctx)
+ s.storeWalkReply(ctx, w, acct, app, rec)
+}
+
+// handleAPIStoreRotateFinish answers `carlos store rotate -finish`: delete the
+// previous key and stop naming it. The member's explicit "nothing is using it
+// any more" — the one destructive step in the rotation state machine, which is
+// why it is a verb someone asks for rather than a timer.
+//
+// TWO DIFFERENCES from its siblings, both deliberate:
+//
+// It needs NO SEALING KEY. Finishing delivers nothing — the app has been
+// reading the current key since the rotation — so a console that has lost its
+// CARLOS_SECRETS_PUBKEY can still close a two-key window. Requiring the key
+// uniformly would be tidier and would strand exactly the deployment that most
+// needs to retire a credential.
+//
+// It does NOT bump the serial. No object a box reads changed: the delivered
+// config is untouched, and the record is console-side. Waking the fleet to
+// tell it a key it never held is gone would be pure noise.
+func (s *Server) handleAPIStoreRotateFinish(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.storeWalkGates(w, addr) {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout)
+ defer cancel()
+ rec, err := objstore.RotateFinish(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app)
+ switch {
+ case errors.Is(err, objstore.ErrNotProvisioned):
+ jsonError(w, http.StatusConflict,
+ "no provisioned store to rotate — grant one first")
+ return
+ case errors.Is(err, objstore.ErrNoPendingFinish):
+ // Refused rather than succeeding vacuously: a member who believes they
+ // just retired the old key, when an earlier finish already did, has a
+ // different picture of the estate than the estate does.
+ jsonError(w, http.StatusConflict, "no rotation is pending finish")
+ return
+ case err != nil:
+ log.Printf("console: api store rotate finish %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway,
+ "could not finish the rotation — the previous key may still be live; re-run to resume")
+ return
+ }
+ key := ""
+ if rec.Provisioned != nil {
+ key = rec.Provisioned.KeyID
+ }
+ s.audit(acct, app, addr, "store-rotate-finish",
+ "finished the rotation — the store now runs on key "+key+" alone (cli)")
+ // No bumpSerial: see the doc comment.
+ s.storeWalkReply(ctx, w, acct, app, rec)
+}
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go
index 78df0c231..10a36d848 100644
--- a/internal/console/apistore_test.go
+++ b/internal/console/apistore_test.go
@@ -481,3 +481,490 @@ func TestAPIStoreWithoutABucketIs503(t *testing.T) {
t.Fatalf("status with no bucket = %d %v, want 503", status, out)
}
}
+
+// ---- grant, rotate, finish (the operator half) -------------------------------
+//
+// What these pin, in the order it would hurt if lost: nobody outside
+// CARLOS_STORE_OPERATORS can make this deployment spend money, and the member
+// gate still runs FIRST so the allowlist never becomes an oracle; a grant that
+// is refused for any reason leaves the backend untouched; a grant that
+// succeeds delivers the minted secret sealed into the app's own config and
+// announces itself to the fleet; and the rotation state machine keeps its
+// two-key discipline — rotate leaves both keys live and bumps, finish kills
+// the old one and bumps nothing.
+
+// storeArmed is storeFixture with a whole deployment behind it: an in-memory
+// backend, a deployment that mints carlos-<acct>-<app> buckets in eu-west-1, a
+// secrets key the console can seal with, and alice on the operator allowlist.
+//
+// It hands back the fake (assert on the estate it actually built), the
+// member's token, and the base64 PRIVATE key — the half this console never
+// holds — so a test can prove the secret delivered into config is the one the
+// backend minted, rather than merely that four variables exist.
+func storeArmed(t *testing.T, code string) (*consoleFixture, *objstore.MemProvisioner, string, string) {
+ t.Helper()
+ fx, token := storeFixture(t, code)
+ pub, priv, _ := sealedFor(t, "irrelevant")
+ fake := objstore.NewMemProvisioner("eu-west-1")
+ fx.srv.cfg.SecretsPub = pub
+ fx.srv.cfg.StoreProvisioner = fake
+ fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"}
+ // Spelled with different capitals on purpose: an operator list is typed by
+ // a human into a unit file, and addresses are case-insensitive.
+ fx.srv.cfg.StoreOperators = []string{"Alice@Keymail.dev"}
+ return fx, fake, token, priv
+}
+
+// declareStore is `carlos store create` — the member half every operator verb
+// below needs to exist before it has anything to act on.
+func declareStore(t *testing.T, fx *consoleFixture, token string) {
+ t.Helper()
+ if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("declare = %d %v", status, out)
+ }
+}
+
+// appVars reads the app's config manifest straight out of the bucket — the
+// only place a delivered store credential ever lands.
+func appVars(t *testing.T, fx *consoleFixture, app string) map[string]appconfig.Var {
+ t.Helper()
+ m, err := appconfig.Get(context.Background(), fx.rel, release.OpsAccount, app)
+ if err != nil {
+ t.Fatalf("read config: %v", err)
+ }
+ return m.Vars
+}
+
+// openSecret decrypts a delivered secret with the private key the console has
+// never seen.
+func openSecret(t *testing.T, priv string, v appconfig.Var) string {
+ t.Helper()
+ pk, err := appconfig.ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ plain, err := appconfig.Open(pk, v.Envelope)
+ if err != nil {
+ t.Fatalf("open envelope: %v", err)
+ }
+ return string(plain)
+}
+
+// untouched asserts the backend has nothing in it — the check every refusal
+// owes: a door that refuses AFTER creating a bucket has already spent money.
+func untouched(t *testing.T, fake *objstore.MemProvisioner, when string) {
+ t.Helper()
+ if len(fake.Buckets) != 0 || len(fake.Users) != 0 {
+ t.Fatalf("%s touched the backend: buckets %v, users %v", when, fake.Buckets, fake.Users)
+ }
+}
+
+// TestAPIStoreGrantNeedsADeploymentOperator: a member of the account — the
+// person who declared the store — cannot grant it. Provisioning spends real
+// money in the DEPLOYMENT's cloud account, so the authority belongs to
+// whoever runs the deployment; the refusal names the variable that carries
+// that list, because the only person who can fix it is the one who can read
+// it. An empty list is fail-closed: nobody may grant.
+func TestAPIStoreGrantNeedsADeploymentOperator(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-grant-operator")
+ declareStore(t, fx, token)
+ fx.srv.cfg.StoreOperators = []string{"paul@keymail.dev"} // alice is not on it
+
+ status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
+ if status != http.StatusForbidden {
+ t.Fatalf("grant by a non-operator member = %d %v, want 403", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_OPERATORS") {
+ t.Fatalf("error = %q, want the operator list named", msg)
+ }
+ untouched(t, fake, "a non-operator grant")
+ if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
+ t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant)
+ }
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial after a refused grant = %d, want 0", got)
+ }
+
+ // Unset is not "everybody" — the direction a spending gate must fail.
+ fx.srv.cfg.StoreOperators = nil
+ if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusForbidden {
+ t.Fatalf("grant with no operator list = %d %v, want 403", status, out)
+ }
+}
+
+// TestAPIStoreOperatorDoorsGateIsByteIdentical: the member gate runs BEFORE
+// the allowlist on all three doors — eve here IS a deployment operator, and
+// she still gets the same 404 as an unknown account and an app that isn't
+// there. If the allowlist ran first, a 403 would tell an operator which of a
+// deployment's accounts and apps exist; if it ran instead of the member gate,
+// it would let one in.
+func TestAPIStoreOperatorDoorsGateIsByteIdentical(t *testing.T) {
+ fx, _, token, _ := storeArmed(t, "code-store-op-gate")
+ fx.srv.cfg.StoreOperators = []string{"eve@keymail.dev", "alice@keymail.dev"}
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-store-op-gate-eve")
+
+ for _, door := range []string{"/grant", "/rotate", "/rotate/finish"} {
+ t.Run(door, func(t *testing.T) {
+ // (a) an operator who is a member of nothing, against a real app.
+ statusA, bodyA := postRaw(t, storeURL(fx, "titogo")+door, map[string]any{}, eve)
+ // (b) a member, against an account that does not exist.
+ statusB, bodyB := postRaw(t, fx.ts.URL+"/api/cli/apps/zzzz/titogo/store"+door, map[string]any{}, token)
+ // (c) a member of the account, against an app it never claimed.
+ statusC, bodyC := postRaw(t, storeURL(fx, "ghost-app")+door, map[string]any{}, token)
+ if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound {
+ t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", door, statusA, statusB, statusC)
+ }
+ if bodyA != bodyB || bodyB != bodyC {
+ t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", door, bodyA, bodyB, bodyC)
+ }
+ if bodyA != "404 page not found\n" {
+ t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", door, bodyA)
+ }
+ })
+ }
+ if status, _ := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, ""); status != http.StatusUnauthorized {
+ t.Fatalf("grant with no token = %d, want 401", status)
+ }
+}
+
+// TestAPIStoreGrantWithoutABackendIs503: a deployment with no object storage
+// wired up refuses the verbs that would create something, and names the
+// variable that would wire it. The member's declaration still stands — it is a
+// request an operator fulfils, and the operator is exactly who this sentence
+// is for.
+func TestAPIStoreGrantWithoutABackendIs503(t *testing.T) {
+ fx, _, token, _ := storeArmed(t, "code-store-grant-nobackend")
+ declareStore(t, fx, token)
+ fx.srv.cfg.StoreProvisioner = nil
+
+ for _, door := range []string{"/grant", "/rotate", "/rotate/finish"} {
+ status, out := postJSON(t, storeURL(fx, "titogo")+door, map[string]any{}, token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("%s with no backend = %d %v, want 503", door, status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_BACKEND") {
+ t.Fatalf("%s error = %q, want the backend variable named", door, msg)
+ }
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
+ t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant)
+ }
+}
+
+// TestAPIStoreGrantProvisionsAndDelivers: the whole authority path. One call
+// mints a bucket, an IAM principal and a key, seals both credential halves
+// into the app's own config, records what it built, and bumps the serial so
+// the fleet picks the config up in seconds rather than at the hourly floor.
+//
+// The secret is checked by DECRYPTING it with the key the console never holds:
+// "four variables are present" would pass just as happily on a delivery that
+// sealed the wrong bytes.
+func TestAPIStoreGrantProvisionsAndDelivers(t *testing.T) {
+ fx, fake, token, priv := storeArmed(t, "code-store-grant-happy")
+ declareStore(t, fx, token)
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial before the grant = %d, want 0", got)
+ }
+
+ status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("grant = %d %v, want 200", status, out)
+ }
+ prov := block(t, out, "provisioned")
+ const bucket = "carlos-bab-titogo"
+ const user = "carlos-store-bab-titogo"
+ if prov["bucket"] != bucket || prov["region"] != "eu-west-1" || prov["iam_user"] != user {
+ t.Fatalf("provisioned = %v, want the minted bucket, its region and the app's principal", prov)
+ }
+ keyID, _ := prov["key_id"].(string)
+ if keyID == "" {
+ t.Fatalf("provisioned.key_id = %v, want the minted key id", prov["key_id"])
+ }
+ if out["pending_finish"] != false {
+ t.Fatalf("pending_finish = %v, want false on a first grant", out["pending_finish"])
+ }
+ if got := strList(t, out, "missing_vars"); len(got) != 0 {
+ t.Fatalf("missing_vars = %v, want nothing missing right after a delivery", got)
+ }
+ if block(t, out, "grant")["by"] != "alice@keymail.dev" {
+ t.Fatalf("grant.by = %v, want the operator who asked", block(t, out, "grant")["by"])
+ }
+
+ // The estate the fake actually built: one bucket, one principal, one key.
+ if _, ok := fake.Buckets[bucket]; !ok {
+ t.Fatalf("buckets = %v, want %s", fake.Buckets, bucket)
+ }
+ u, ok := fake.Users[user]
+ if !ok || len(u.Keys) != 1 {
+ t.Fatalf("users = %v, want %s holding exactly one key", fake.Users, user)
+ }
+ secret, ok := u.Keys[keyID]
+ if !ok {
+ t.Fatalf("keys = %v, want the recorded key id %q among them", u.Keys, keyID)
+ }
+
+ // Delivery: coordinates in the clear, both credential halves sealed.
+ vars := appVars(t, fx, "titogo")
+ if v := vars["CARLOS_STORE_BUCKET"]; v.Kind != "env" || v.Value != bucket {
+ t.Fatalf("CARLOS_STORE_BUCKET = %+v, want the bucket as a plain env var", v)
+ }
+ if v := vars["CARLOS_STORE_REGION"]; v.Kind != "env" || v.Value != "eu-west-1" {
+ t.Fatalf("CARLOS_STORE_REGION = %+v", v)
+ }
+ if _, present := vars["CARLOS_STORE_ENDPOINT"]; present {
+ t.Fatalf("CARLOS_STORE_ENDPOINT was delivered by a deployment that has no endpoint")
+ }
+ access := vars["CARLOS_STORE_ACCESS_KEY"]
+ if access.Kind != "secret" || access.Value != "" {
+ t.Fatalf("CARLOS_STORE_ACCESS_KEY = %+v, want a sealed secret and no plaintext", access)
+ }
+ if got := openSecret(t, priv, access); got != keyID {
+ t.Fatalf("sealed access key = %q, want the minted key id %q", got, keyID)
+ }
+ sealedSecret := vars["CARLOS_STORE_SECRET_KEY"]
+ if sealedSecret.Kind != "secret" || sealedSecret.Value != "" {
+ t.Fatalf("CARLOS_STORE_SECRET_KEY = %+v, want a sealed secret and no plaintext", sealedSecret)
+ }
+ if got := openSecret(t, priv, sealedSecret); got != secret {
+ t.Fatalf("sealed secret half = %q, want the secret the backend minted", got)
+ }
+
+ // Recorded, announced, and audited.
+ rec := storedRecord(t, fx, "titogo")
+ if rec.Provisioned == nil || rec.Provisioned.KeyID != keyID || rec.Provisioned.Bucket != bucket {
+ t.Fatalf("record after a grant = %+v", rec.Provisioned)
+ }
+ if rec.Grant == nil || rec.Grant.Backend != "mem" {
+ t.Fatalf("grant block = %+v, want this deployment's backend", rec.Grant)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a grant = %d, want 1 — the app's config changed", got)
+ }
+ auditedBy(t, fx, "titogo", "store-grant", "alice@keymail.dev", bucket)
+
+ // Idempotent: the operator's verb is one people re-run, and a healthy
+ // store must not mint a second key for it.
+ if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("re-grant = %d %v, want 200", status, out)
+ }
+ if u := fake.Users[user]; len(u.Keys) != 1 || u.Keys[keyID] != secret {
+ t.Fatalf("keys after a re-grant = %v, want the same single key", u.Keys)
+ }
+}
+
+// TestAPIStoreGrantAdoptsANamedBucket: the operator can point a store at a
+// bucket that already exists — the migration path for data that predates the
+// platform — and the record says so, so nothing later re-derives a minted name
+// over the top of it.
+func TestAPIStoreGrantAdoptsANamedBucket(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-grant-adopt")
+ declareStore(t, fx, token)
+
+ status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{"bucket": "titogo-uploads"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("adopt grant = %d %v, want 200", status, out)
+ }
+ if got := block(t, out, "provisioned")["bucket"]; got != "titogo-uploads" {
+ t.Fatalf("provisioned.bucket = %v, want the adopted name", got)
+ }
+ if _, ok := fake.Buckets["carlos-bab-titogo"]; ok {
+ t.Fatalf("an adopting grant also minted the default bucket: %v", fake.Buckets)
+ }
+ rec := storedRecord(t, fx, "titogo")
+ if rec.Grant == nil || rec.Grant.Bucket != "titogo-uploads" {
+ t.Fatalf("grant block = %+v, want the adopted bucket recorded", rec.Grant)
+ }
+ auditedBy(t, fx, "titogo", "store-grant", "alice@keymail.dev", "titogo-uploads")
+}
+
+// TestAPIStoreGrantRefusesAnInvalidBucketName: an adopt name is interpolated
+// into the IAM policy ARN the credential is scoped by, so a bad one is refused
+// AT THE DOOR — before the record is read and before a single backend call, so
+// nothing exists to clean up and a corrected retry starts from where the
+// operator left off.
+func TestAPIStoreGrantRefusesAnInvalidBucketName(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-grant-badbucket")
+ declareStore(t, fx, token)
+
+ for _, bad := range []string{"Uploads", "up", "up_loads", "-uploads", "uploads-", strings.Repeat("u", 64), "uploads/*"} {
+ status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{"bucket": bad}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("grant with bucket %q = %d %v, want 400", bad, status, out)
+ }
+ untouched(t, fake, "an invalid bucket name")
+ if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
+ t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant)
+ }
+ }
+}
+
+// TestAPIStoreGrantWithoutADeclarationIs409: the operator's verb never invents
+// the member's half. The env prefix is the member's choice and a store nobody
+// asked for has nowhere to be delivered, so the refusal names the command that
+// fixes it — and nothing is created.
+func TestAPIStoreGrantWithoutADeclarationIs409(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-grant-undeclared")
+
+ status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("grant with no declaration = %d %v, want 409", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "store create") {
+ t.Fatalf("error = %q, want the member's command named", msg)
+ }
+ untouched(t, fake, "a grant with no declaration")
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial = %d, want 0 — nothing was delivered", got)
+ }
+}
+
+// TestAPIStoreGrantNeedsASealingKey: a console with no CARLOS_SECRETS_PUBKEY
+// cannot seal, therefore cannot DELIVER, therefore must not MINT — a key
+// created here would exist in the backend with its secret already lost. Same
+// for a rotation. Finish is the exception, and deliberately: it seals nothing,
+// it deletes a key and records that, and a console that lost its pubkey must
+// still be able to close a two-key window.
+func TestAPIStoreGrantNeedsASealingKey(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-grant-nokey")
+ declareStore(t, fx, token)
+ pub := fx.srv.cfg.SecretsPub
+ fx.srv.cfg.SecretsPub = ""
+
+ status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("grant with no secrets key = %d %v, want 503", status, out)
+ }
+ untouched(t, fake, "a grant with no sealing key")
+
+ // A key that is present but unreadable is the same refusal: what matters
+ // is whether this console can seal, not whether someone set the variable.
+ fx.srv.cfg.SecretsPub = "not-a-key"
+ if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusServiceUnavailable {
+ t.Fatalf("grant with an unreadable secrets key = %d %v, want 503", status, out)
+ }
+ untouched(t, fake, "a grant with an unreadable sealing key")
+
+ // Provision and rotate for real, then take the key away again.
+ fx.srv.cfg.SecretsPub = pub
+ if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("grant = %d %v", status, out)
+ }
+ if status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("rotate = %d %v", status, out)
+ }
+ fx.srv.cfg.SecretsPub = ""
+ if status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, token); status != http.StatusServiceUnavailable {
+ t.Fatalf("rotate with no secrets key = %d %v, want 503", status, out)
+ }
+ status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("finish with no secrets key = %d %v, want 200 — finishing seals nothing", status, out)
+ }
+ if out["pending_finish"] != false {
+ t.Fatalf("pending_finish = %v after a finish, want false", out["pending_finish"])
+ }
+}
+
+// TestAPIStoreRotateAndFinish: the two-key state machine, end to end, driven
+// by a MEMBER who is not a deployment operator — rotating touches only the
+// estate an operator already paid for, and a member must not have to find one
+// to cycle their own credential.
+//
+// Rotate leaves BOTH keys live and bumps the serial (the app's config
+// changed). Finish kills the old key and bumps NOTHING: no object a box reads
+// moved, and waking the fleet for it would be noise.
+func TestAPIStoreRotateAndFinish(t *testing.T) {
+ fx, fake, token, priv := storeArmed(t, "code-store-rotate")
+ declareStore(t, fx, token)
+ if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("grant = %d %v", status, out)
+ }
+ firstKey := storedRecord(t, fx, "titogo").Provisioned.KeyID
+ if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ bob := cliToken(t, fx, "bob@keymail.dev", "code-store-rotate-bob")
+
+ status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, bob)
+ if status != http.StatusOK {
+ t.Fatalf("rotate by a member = %d %v, want 200 — rotation is a member verb", status, out)
+ }
+ prov := block(t, out, "provisioned")
+ newKey, _ := prov["key_id"].(string)
+ if newKey == "" || newKey == firstKey {
+ t.Fatalf("key_id = %v, want a fresh key (was %q)", prov["key_id"], firstKey)
+ }
+ if prov["prev_key_id"] != firstKey {
+ t.Fatalf("prev_key_id = %v, want the key the app is still holding (%q)", prov["prev_key_id"], firstKey)
+ }
+ if out["pending_finish"] != true {
+ t.Fatalf("pending_finish = %v after a rotate, want true", out["pending_finish"])
+ }
+ const user = "carlos-store-bab-titogo"
+ if u := fake.Users[user]; len(u.Keys) != 2 {
+ t.Fatalf("keys after a rotate = %v, want both live until finish", u.Keys)
+ }
+ if got := openSecret(t, priv, appVars(t, fx, "titogo")["CARLOS_STORE_SECRET_KEY"]); got != fake.Users[user].Keys[newKey] {
+ t.Fatalf("delivered secret = %q, want the newly minted one", got)
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after grant+rotate = %d, want 2 — the app's config changed again", got)
+ }
+ auditedBy(t, fx, "titogo", "store-rotate", "bob@keymail.dev", newKey)
+
+ status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, bob)
+ if status != http.StatusOK {
+ t.Fatalf("finish = %d %v, want 200", status, out)
+ }
+ if out["pending_finish"] != false {
+ t.Fatalf("pending_finish = %v after a finish, want false", out["pending_finish"])
+ }
+ if block(t, out, "provisioned")["key_id"] != newKey {
+ t.Fatalf("finish moved the live key: %v", block(t, out, "provisioned"))
+ }
+ u := fake.Users[user]
+ if len(u.Keys) != 1 {
+ t.Fatalf("keys after a finish = %v, want only the live one", u.Keys)
+ }
+ if _, ok := u.Keys[firstKey]; ok {
+ t.Fatalf("finish left the previous key %q alive", firstKey)
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after a finish = %d, want it unmoved at 2 — nothing a box reads changed", got)
+ }
+ auditedBy(t, fx, "titogo", "store-rotate-finish", "bob@keymail.dev", newKey)
+
+ // Finishing twice refuses rather than succeeding vacuously: a member who
+ // believes they just retired a key, when an earlier call already did, has a
+ // different picture of the estate than the estate does.
+ status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, bob)
+ if status != http.StatusConflict {
+ t.Fatalf("second finish = %d %v, want 409", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "pending") {
+ t.Fatalf("error = %q, want it to say nothing is pending", msg)
+ }
+}
+
+// TestAPIStoreRotateWithoutAProvisionedStoreIs409: rotation edits an existing
+// credential estate — it never creates one, because the coordinates it reseals
+// come from the record and there is no defensible guess for them. Both verbs
+// point at the one that does create it.
+func TestAPIStoreRotateWithoutAProvisionedStoreIs409(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-store-rotate-unprovisioned")
+ declareStore(t, fx, token)
+
+ for _, door := range []string{"/rotate", "/rotate/finish"} {
+ status, out := postJSON(t, storeURL(fx, "titogo")+door, map[string]any{}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("%s on a declared-only store = %d %v, want 409", door, status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "grant") {
+ t.Fatalf("%s error = %q, want the grant named as the way forward", door, msg)
+ }
+ }
+ untouched(t, fake, "a rotate with nothing provisioned")
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial = %d, want 0", got)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 6dd477e03..a8b0d668a 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -280,12 +280,21 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/config", s.handleAPIConfigGet)
s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/config", s.handleAPIConfigPut)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/config/sync", s.handleAPIConfigSync)
- // The object-storage API, member half (apistore.go, issue #55) — declare
- // a store and read what became of it. Beside the config routes on
- // purpose: a store's credentials are DELIVERED as config, so these two
- // doors are two halves of the same story for whoever reads this list.
+ // The object-storage API (apistore.go, issue #55) — declare a store, read
+ // what became of it, and drive the walks that make it real. Beside the
+ // config routes on purpose: a store's credentials are DELIVERED as config,
+ // so these doors are halves of the same story for whoever reads this list.
+ //
+ // `grant` is the deployment's first operator-identity door: same member
+ // 404 as its siblings, then an address on CARLOS_STORE_OPERATORS. Rotate
+ // and finish are MEMBER verbs (spec §8) — they cycle a credential inside an
+ // estate a grant already paid for. All three are literal segments under
+ // .../store, never {wildcards}, so no app or host name can reach them.
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store", s.handleAPIStoreCreate)
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/store", s.handleAPIStoreGet)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store/grant", s.handleAPIStoreGrant)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store/rotate", s.handleAPIStoreRotate)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store/rotate/finish", s.handleAPIStoreRotateFinish)
}
// clientIP is who the rate limiter counts against.