package console // The app-declared private-storage policy (issue #412). What these pin, in // the order they would hurt if lost: // // - the create door provisions the store BEFORE it writes the record, so // a failed walk leaves no instance at all — never an instance that wakes // onto local disk because its bundle had no coordinates; // - the environment is the host's first label, and the coordinates land in // THAT bundle, not the app's default one; // - an app without the policy is byte-for-byte unchanged; // - the ceiling holds on both doors, and re-using an existing store is not // capped; // - declaring the policy is a person's act, refused in the two states // where it could only ever be a trap. import ( "context" "encoding/json" "errors" "fmt" "net/http" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // privateStorageFixture is instanceFixture with the store plane armed: a // backend, a sealing key, and an approved account entitlement — the three // things the policy spends, and the standing approval #408 §6 says is the // only one needed. func privateStorageFixture(t *testing.T, code string) (*consoleFixture, *objstore.MemProvisioner, string, string) { t.Helper() fx, token := instanceFixture(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-"} approveStoreAccount(t, fx, "operator@keymail.dev") return fx, fake, token, priv } // privateManifest is titoManifest carrying the policy. The domain is the // app's own (not the platform namespace), which keeps the sibling-apex // branch out of these tests — it has its own coverage. func privateManifest() provision.Manifest { m := titoManifest() m.PrivateStorage = true return m } func createInstance(t *testing.T, fx *consoleFixture, token, body string) (int, string) { t.Helper() status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, body) return status, out } func envStore(t *testing.T, fx *consoleFixture, env string) *objstore.Record { t.Helper() rec, _, err := objstore.GetIn(context.Background(), fx.rel, release.OpsAccount, "titogo", env) if err != nil { t.Fatalf("read %s store: %v", env, err) } return rec } func envVars(t *testing.T, fx *consoleFixture, env string) map[string]appconfig.Var { t.Helper() m, err := appconfig.LoadEnvironment(context.Background(), fx.rel, release.OpsAccount, "titogo", env) if err != nil { t.Fatalf("read %s bundle: %v", env, err) } return m.Vars } // The whole chain, as a signup path drives it: one instance create, and the // tenant has a bucket of its own whose coordinates are already in the bundle // the record binds to. No `store create`, no `store provision`, no operator. func TestInstanceCreateProvisionsPrivateStore(t *testing.T) { fx, fake, token, priv := privateStorageFixture(t, "code-priv-chain") enableInstances(t, fx, "titogo", privateManifest()) status, out := createInstance(t, fx, token, `{"host":"acme.go.tito.io","region":"ie"}`) if status != http.StatusCreated { t.Fatalf("create = %d %s, want 201", status, out) } var echoed struct { Environment string `json:"environment"` PrivateStoreBucket string `json:"private_store_bucket"` } if err := json.Unmarshal([]byte(out), &echoed); err != nil { t.Fatalf("decode %s: %v", out, err) } // The environment is the host's FIRST LABEL, which is the one name // predictable from the host (spec §6). if echoed.Environment != "acme" { t.Fatalf("echoed environment = %q, want acme", echoed.Environment) } if echoed.PrivateStoreBucket != "carlos-bab-titogo-acme" { t.Fatalf("echoed bucket = %q, want carlos-bab-titogo-acme", echoed.PrivateStoreBucket) } // The record a box reconciles from carries the binding. stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") if err != nil { t.Fatal(err) } if stored.Environment != "acme" { t.Fatalf("stored environment = %q, want acme", stored.Environment) } // The store is real, provisioned, and its own. rec := envStore(t, fx, "acme") if rec == nil || rec.Provisioned == nil { t.Fatalf("acme store = %+v, want a provisioned one", rec) } if rec.Provisioned.Bucket != "carlos-bab-titogo-acme" || rec.Provisioned.IAMUser != "carlos-store-bab-titogo-acme" { t.Fatalf("provisioned = %+v, want the account/app/environment triple", rec.Provisioned) } // The grant's authority is the ACCOUNT entitlement, not an operator — // which is the whole reason no new power was granted to anyone. if rec.Grant == nil || rec.Grant.Source != objstore.GrantSourceAccountApproval { t.Fatalf("grant = %+v, want source %q", rec.Grant, objstore.GrantSourceAccountApproval) } if _, ok := fake.Buckets["carlos-bab-titogo-acme"]; !ok { t.Fatalf("backend buckets = %v, want the instance's own", fake.Buckets) } // Delivery goes to the INSTANCE's bundle. A credential in the app's // default bundle would be read by every other instance — the isolation // failing in exactly the direction that matters. vars := envVars(t, fx, "acme") if v, ok := vars["CARLOS_STORE_BUCKET"]; !ok || v.Value != "carlos-bab-titogo-acme" { t.Fatalf("acme bundle CARLOS_STORE_BUCKET = %+v, want the instance's bucket", v) } secret, ok := vars["CARLOS_STORE_SECRET_KEY"] if !ok || secret.Kind != "secret" { t.Fatalf("acme bundle CARLOS_STORE_SECRET_KEY = %+v, want a sealed secret", secret) } if openSecret(t, priv, secret) == "" { t.Fatal("the delivered secret did not open") } if got := appVars(t, fx, "titogo"); len(got) != 0 { t.Fatalf("the app's default bundle = %v, want nothing — a shared credential is the isolation failing", got) } } // An app without the policy is what it was before this branch: a record, no // store, no bundle, and no backend call. This is the test that fails if the // policy is ever read as on-by-default. func TestInstanceCreateWithoutPolicyTouchesNoStore(t *testing.T) { fx, fake, token, _ := privateStorageFixture(t, "code-priv-off") enableInstances(t, fx, "titogo", titoManifest()) status, out := createInstance(t, fx, token, `{"host":"acme.go.tito.io","region":"ie"}`) if status != http.StatusCreated { t.Fatalf("create = %d %s, want 201", status, out) } stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") if err != nil { t.Fatal(err) } if stored.Environment != "" { t.Fatalf("stored environment = %q, want empty — no policy, no derived binding", stored.Environment) } if rec := envStore(t, fx, "acme"); rec != nil { t.Fatalf("a store was created without the policy: %+v", rec) } untouched(t, fake, "a create with no private-storage policy") } // The policy fills a blank; it does not seize the axis. A caller who names // an environment gets a store for THAT one — which is still the invariant, // since what is promised is that the record's bundle carries the // coordinates, not that the bundle has any particular name. func TestInstanceCreateHonoursAnExplicitEnvironment(t *testing.T) { fx, _, token, _ := privateStorageFixture(t, "code-priv-explicit") enableInstances(t, fx, "titogo", privateManifest()) status, out := createInstance(t, fx, token, `{"host":"acme.go.tito.io","region":"ie","environment":"shared-eu"}`) if status != http.StatusCreated { t.Fatalf("create = %d %s, want 201", status, out) } stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") if err != nil { t.Fatal(err) } if stored.Environment != "shared-eu" { t.Fatalf("stored environment = %q, want the one the caller named", stored.Environment) } if rec := envStore(t, fx, "shared-eu"); rec == nil || rec.Provisioned == nil { t.Fatalf("shared-eu store = %+v, want a provisioned one", rec) } if rec := envStore(t, fx, "acme"); rec != nil { t.Fatalf("a store was also made for the derived name: %+v", rec) } } // A DNS label is not an environment name, and the create door is where that // has to be said: labels run to 63 characters, environment names to 32, and // "default" is a legal label that names the app's own bundle. Every refusal // leaves nothing behind. func TestInstanceCreateRefusesALabelThatCannotNameAStore(t *testing.T) { fx, fake, token, _ := privateStorageFixture(t, "code-priv-badlabel") enableInstances(t, fx, "titogo", privateManifest()) for _, label := range []string{ "default", "this-label-is-far-too-long-to-be-an-environment-name", } { host := label + ".go.tito.io" status, out := createInstance(t, fx, token, fmt.Sprintf(`{"host":%q,"region":"ie"}`, host)) if status != http.StatusBadRequest { t.Errorf("create at %s = %d %s, want 400", host, status, out) continue } if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host); !errors.Is(err, os.ErrNotExist) { t.Errorf("a refused create at %s left a record (err %v)", host, err) } } untouched(t, fake, "a create whose label cannot name a store") } // An EXPLICIT environment gets the same rails, and the same 400. Without // this the refusal comes from inside the walk instead — a 502 the caller // cannot act on, over a name they could have shortened. func TestInstanceCreateRefusesAnUnnameableExplicitEnvironment(t *testing.T) { fx, fake, token, _ := privateStorageFixture(t, "code-priv-badenv") enableInstances(t, fx, "titogo", privateManifest()) // 32 characters — a legal environment name, and an IAM principal of // "carlos-store-bab-titogo-" + 32 = 56, under the ceiling. So this is // the bucket half: "carlos-" + "bab-titogo-" + 32 = 50. Both fit, so // the case that must refuse is the one the config plane itself refuses. status, out := createInstance(t, fx, token, `{"host":"acme.go.tito.io","region":"ie","environment":"UPPER"}`) if status != http.StatusBadRequest { t.Fatalf("create with an unusable environment = %d %s, want 400", status, out) } if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) { t.Errorf("a refused create left a record (err %v)", err) } untouched(t, fake, "a create whose explicit environment cannot name a store") } // The IAM ceiling, and the arithmetic that makes it reachable at all. Both // names carry the same body: the bucket is against S3's 63, // the principal is "carlos-store-" + against IAM's 64. So under any // prefix longer than "carlos-store-" the BUCKET refusal always fires first, // and the IAM guard is only ever reached on a short prefix with a long app. // Encoded here so the guard cannot quietly become dead code. // // body = "bab" + "-" + app(25) + "-" + env(25) = 55. // bucket = "" + 55 = 55, legal. principal = 13 + 55 = 68, one refusal over. func TestInstanceCreateRefusesAnUnnameableIAMPrincipal(t *testing.T) { const app = "a-rather-long-application" // 25 label := strings.Repeat("b", 25) fx, token := instanceFixture(t, "code-priv-iamceiling") if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } pub, _, _ := 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"} approveStoreAccount(t, fx, "operator@keymail.dev") enableInstances(t, fx, app, provision.Manifest{ Schema: 1, App: app, Domain: "go.tito.io", Channel: "stable", PrivateStorage: true}) host := label + ".go.tito.io" status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", app), token, fmt.Sprintf(`{"host":%q}`, host)) if status != http.StatusBadRequest { t.Fatalf("create = %d %s, want 400", status, out) } if !strings.Contains(out, "64") { t.Errorf("refusal %q does not name AWS's ceiling", out) } if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", app, host); !errors.Is(err, os.ErrNotExist) { t.Errorf("a refused create left a record (err %v)", err) } untouched(t, fake, "a create whose principal cannot be named") } // A ceiling only the automatic path respected would be a ceiling anyone // could step over by hand. func TestStoreCreateRefusedAtTheCeiling(t *testing.T) { fx, token := storeFixture(t, "code-store-ceiling") fx.srv.cfg.StoreMaxPerApp = 1 if status, body := postRaw(t, storeEnvURL(fx, "titogo", "one"), map[string]any{}, token); status != http.StatusOK { t.Fatalf("declare one = %d %s", status, body) } status, body := postRaw(t, storeEnvURL(fx, "titogo", "two"), map[string]any{}, token) if status != http.StatusConflict { t.Fatalf("declare past the cap = %d %s, want 409", status, body) } // Re-declaring the one that exists is not a new footprint, so it stands. if status, body := postRaw(t, storeEnvURL(fx, "titogo", "one"), map[string]any{}, token); status != http.StatusOK { t.Fatalf("re-declare one = %d %s, want 200", status, body) } // And the app's OWN store is not one of the capped collection. if status, body := postRaw(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { t.Fatalf("declare the app's own store = %d %s, want 200", status, body) } } // An app that already declared a prefix reads those names in its code. An // instance store delivering CARLOS_STORE_BUCKET into the same app would be a // second vocabulary for one thing. func TestInstanceStoreInheritsTheAppsEnvPrefix(t *testing.T) { fx, _, token, _ := privateStorageFixture(t, "code-priv-prefix") enableInstances(t, fx, "titogo", privateManifest()) if status, out := postRaw(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "JELLY_S3"}, token); status != http.StatusOK { t.Fatalf("declare the app store = %d %v", status, out) } if status, out := createInstance(t, fx, token, `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated { t.Fatalf("create = %d %s", status, out) } vars := envVars(t, fx, "acme") if _, ok := vars["JELLY_S3_BUCKET"]; !ok { t.Fatalf("acme bundle = %v, want the app's own JELLY_S3 prefix", varNames(vars)) } if _, ok := vars["CARLOS_STORE_BUCKET"]; ok { t.Fatalf("acme bundle also carries the default prefix: %v", varNames(vars)) } } func varNames(m map[string]appconfig.Var) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out } // Declaring the policy is refused in the two states where it could only ever // be a trap — a deployment with no object storage, and an account nobody // approved. It fails once here, to the person, instead of once per signup in // a door whose caller is a machine. func TestEnableRefusesAPolicyItCannotHonour(t *testing.T) { t.Run("no backend", func(t *testing.T) { fx, _, token, _ := privateStorageFixture(t, "code-priv-enable-nobackend") fx.srv.cfg.AppsDomain = "oncarlos.test" fx.srv.cfg.StoreProvisioner = nil status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), token, `{"private_storage":true}`) if status != http.StatusServiceUnavailable { t.Fatalf("enable = %d %s, want 503", status, out) } if !strings.Contains(out, "CARLOS_STORE_BACKEND") { t.Errorf("refusal %q does not name the variable", out) } assertNoPolicy(t, fx) }) t.Run("unapproved account", func(t *testing.T) { fx, token := instanceFixture(t, "code-priv-enable-unapproved") fx.srv.cfg.AppsDomain = "oncarlos.test" fx.srv.cfg.StoreProvisioner = objstore.NewMemProvisioner("eu-west-1") status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), token, `{"private_storage":true}`) if status != http.StatusForbidden { t.Fatalf("enable = %d %s, want 403", status, out) } if !strings.Contains(out, "carlos store apply") { t.Errorf("refusal %q does not name the way out", out) } assertNoPolicy(t, fx) }) } func assertNoPolicy(t *testing.T, fx *consoleFixture) { t.Helper() m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo") if err == nil && m.PrivateStorage { t.Fatal("a refused enable wrote the policy anyway") } } // Turning it OFF is never refused, and the asymmetry is the point: an // account whose entitlement was later declined must be able to get its // create door working again without waiting on an operator. func TestEnableTurningPrivateStorageOffIsNeverRefused(t *testing.T) { fx, token := instanceFixture(t, "code-priv-enable-off") fx.srv.cfg.AppsDomain = "oncarlos.test" enableInstances(t, fx, "titogo", provision.Manifest{ Schema: 1, App: "titogo", Domain: "bab.oncarlos.test", PrivateStorage: true}) // No backend, no entitlement — the state the ON path refuses in. fx.srv.cfg.StoreProvisioner = nil status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), token, `{"private_storage":false}`) if status != http.StatusOK { t.Fatalf("enable off = %d %s, want 200", status, out) } m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo") if err != nil { t.Fatal(err) } if m.PrivateStorage { t.Fatal("the policy survived being turned off") } } // An enable that never mentions storage preserves the policy — the same // absent-vs-explicit split channel and seed already have. A `carlos // instances enable -channel beta` that silently unset a tenant's isolation // is the failure this pins. func TestEnablePreservesPrivateStorage(t *testing.T) { fx, _, token, _ := privateStorageFixture(t, "code-priv-enable-preserve") fx.srv.cfg.AppsDomain = "oncarlos.test" enableInstances(t, fx, "titogo", provision.Manifest{ Schema: 1, App: "titogo", Domain: "bab.oncarlos.test", PrivateStorage: true}) status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), token, `{"channel":"beta"}`) if status != http.StatusOK { t.Fatalf("enable = %d %s, want 200", status, out) } var res struct { Channel string `json:"channel"` PrivateStorage bool `json:"private_storage"` } if err := json.Unmarshal([]byte(out), &res); err != nil { t.Fatal(err) } if res.Channel != "beta" || !res.PrivateStorage { t.Fatalf("reply = %+v, want channel beta with the policy intact", res) } m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo") if err != nil { t.Fatal(err) } if !m.PrivateStorage { t.Fatal("a channel-only enable dropped the private-storage policy") } } // Declaring the policy stays a PERSON's act — the wall that makes this a // better answer than widening the instance role. An app token creates // instances all day and cannot write the manifest they are created under. func TestPrivateStoragePolicyStaysAHumanAct(t *testing.T) { fx, _, token, _ := privateStorageFixture(t, "code-priv-human") fx.srv.cfg.AppsDomain = "oncarlos.test" appToken := mintInstanceToken(t, fx, token, "titogo") status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), appToken, `{"private_storage":true}`) if status != http.StatusForbidden && status != http.StatusUnauthorized { t.Fatalf("app token enable = %d %s, want a refusal", status, out) } assertNoPolicy(t, fx) } // mintInstanceToken is `carlos instances token` — the service credential a // signup path holds. func mintInstanceToken(t *testing.T, fx *consoleFixture, personToken, app string) string { t.Helper() u := fx.ts.URL + "/api/cli/apps/bab/" + app + "/instance-token" status, _, out := rawReq(t, http.MethodPost, u, personToken, `{}`) if status != http.StatusCreated { t.Fatalf("mint instance token = %d %s", status, out) } var res struct { Token string `json:"token"` } if err := json.Unmarshal([]byte(out), &res); err != nil { t.Fatal(err) } return res.Token } // The ceiling in force, and the two ways a deployment names it. Zero in the // config is "not configured", never "no stores allowed" — an unset variable // turning an app into one that can hold no store at all is the wrong // direction for a default to fail in. // // The number itself is pinned because it is a JUDGEMENT about AWS's quota // rather than an arithmetic consequence of anything: IAM allows 5,000 users // per cloud account, so one app's share is a fifth of that. An earlier draft // said 100, which would have refused an ordinary managed product at its // hundredth customer while protecting nothing (James, 2026-09-23). A future // change to the constant should be a decision someone took, not a drift. func TestStoreCeilingDefault(t *testing.T) { s := &Server{} if got := s.storeMaxPerApp(); got != 1000 { t.Errorf("unconfigured ceiling = %d, want 1000", got) } s.cfg.StoreMaxPerApp = 25 if got := s.storeMaxPerApp(); got != 25 { t.Errorf("configured ceiling = %d, want 25", got) } }