carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The CLI's object-storage API (issue #55): `carlos store create` declares
2// that an app wants a bucket, `carlos store status` answers what became of
3// that request, and grant/rotate/finish drive the walks that make it real.
4// Every door here sits behind the same apiAccountApp gate as
5// ship/promote/config — an unknown account, an unknown app and someone else's
6// account are one byte-identical 404.
7//
8// THREE AUTHORS, TWO AUTHORITIES. A store record has three blocks for three
9// actors (spec §4): the member DECLARES, a deployment operator GRANTS, and
10// the console's own walk records what it PROVISIONED. Declaring and rotating
11// are member verbs. Provisioning also admits members once a deployment
12// operator has approved their account (apistore_provision.go).
13// Per-app granting needs an address on CARLOS_STORE_OPERATORS as well
14// (Config.StoreOperators). That check runs AFTER the member gate on purpose:
15// a 403 handed to a stranger would confirm which accounts and apps exist.
16// This is the deployment's first explicit operator-identity gate (spec §3).
17//
18// NOTHING SECRET CROSSES THESE DOORS, in either direction. The record holds
19// bucket names, an IAM user, and access key IDs; the secret half of a key
20// exists only sealed inside the app's config bundle and is never written to
21// the record at all (objstore.Record's doc comment). Key IDs are echoed
22// deliberately — `status` prints them so a member can tell which credential
23// their app is running on, and match it against a rotation.
24//
25// THE SERIAL BUMPS EXACTLY WHERE CONFIG MOVED. Declaring changes nothing a
26// box reads, and neither does finishing a rotation (it deletes a key in the
27// backend and forgets it in the record); both are silent. Grant and rotate
28// write the app's config bundle, so both announce, and the fleet picks the new
29// credentials up in seconds instead of at the hourly floor.
30//
31// Spec: docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md
32// §3, §4, §5, §7, §8.
33package console
34
35import (
36 "context"
37 "crypto/ecdh"
38 "encoding/json"
39 "errors"
40 "fmt"
41 "log"
42 "net/http"
43 "os"
44 "strings"
45 "time"
46
47 "github.com/carlosframework/platform/internal/appconfig"
48 "github.com/carlosframework/platform/internal/objstore"
49 "github.com/carlosframework/platform/internal/release"
50 "github.com/carlosframework/platform/internal/usage"
51)
52
53// storeTimeout bounds every store round trip these handlers make — the same
54// 15s its siblings in apicli.go and apiconfig.go use, for the same reason: a
55// wedged bucket must cost one request 15 seconds, not a console goroutine
56// forever. Each handler derives its context from the request's, so a client
57// that hangs up still cancels immediately; this only adds a ceiling.
58const storeTimeout = 15 * time.Second
59
60// storeNoBucket is the 503 both doors give when this console has no
61// deployment bucket. The record IS a bucket object: there is nowhere to write
62// one and nothing to read one from, and answering "no store is declared"
63// instead would be a lie a member could act on (they'd declare again, forever).
64func storeNoBucket(w http.ResponseWriter) {
65 jsonError(w, http.StatusServiceUnavailable,
66 "no deployment bucket on this host — this console cannot serve object storage")
67}
68
69// storeEffectivePrefix is the prefix a record's variables are delivered
70// under. A record with no declaration at all is not a shape this file can
71// write — the create door always writes `declared` — but the operator walks
72// and hand-repaired records both exist, so the fallback is the same default a
73// fresh declaration would take rather than an empty prefix that would render
74// variables named "_BUCKET".
75// storeEnvParam reads the environment a store door is aimed at, from
76// ?environment=. Absent is "" — the app's own store, and every request made
77// before environment stores existed.
78//
79// One helper for all six doors on purpose: the create door is the only one
80// with a body, and rotate and finish document that they read none at all
81// ("a rotation has no arguments"), so a body field would have made the
82// spelling differ per door for no reason a member could predict. It is the
83// same query parameter, with the same name, that the config plane already
84// uses (apiconfig.go's ?environment=) — a store delivers INTO a bundle, so
85// the two planes naming it differently would be a seam with no meaning.
86//
87// Validated here rather than in the walk: a refusal costs nothing, and an
88// invalid name reaching objstore would be refused there anyway, one round
89// trip later and with a less useful sentence.
90func (s *Server) storeEnvParam(w http.ResponseWriter, r *http.Request) (string, bool) {
91 env := strings.TrimSpace(r.URL.Query().Get("environment"))
92 if env == "" {
93 return "", true
94 }
95 if !appconfig.ValidEnvironmentName(env) {
96 jsonError(w, http.StatusBadRequest,
97 fmt.Sprintf("invalid environment %q — want ^[a-z0-9-]{1,32}$, and not \"default\"", env))
98 return "", false
99 }
100 return env, true
101}
102
103func storeEffectivePrefix(rec *objstore.Record) string {
104 if rec != nil && rec.Declared != nil && rec.Declared.EnvPrefix != "" {
105 return rec.Declared.EnvPrefix
106 }
107 return objstore.DefaultEnvPrefix
108}
109
110// storeReply is the ONE answer shape both doors give, and the one the CLI
111// (`carlos store status`) and the operator doors parse. Kept in a single
112// function so create and status can never drift into describing the same
113// record differently — a create that reported a different `pending_finish`
114// from the status call right after it would be worse than no field at all.
115//
116// The record is echoed whole. Its three blocks are null-or-present, which is
117// exactly the tri-state a client needs to say "declared, waiting for an
118// operator" apart from "provisioned": an empty object for an absent block
119// would make those indistinguishable without inspecting fields.
120//
121// env_names is the SET OF NAMES this store delivers under its effective
122// prefix, in delivery order, and it includes the endpoint name only when the
123// store is provisioned WITH one. A deployment that provisions no endpoint
124// never writes that variable, so listing it would send someone hunting for a
125// variable nothing will ever set.
126//
127// missing_vars is the same list filtered to what has NOT arrived in the app's
128// config bundle, and it is computed ONLY for a provisioned store: before
129// provisioning there is no bucket, no key and nothing owed, so reporting four
130// missing variables would describe a healthy waiting-for-an-operator store as
131// a broken one. Both lists are always present and never null — a client reads
132// them rather than testing for absence.
133//
134// backend is the DEPLOYMENT's backend name, "" when none is configured, and
135// it is here because without it `status` cannot answer the question a waiting
136// member actually has: is my declaration queued behind an operator, or does
137// this deployment have no object storage at all and never will? The
138// no-infra-access rule (AGENTS.md) says the command alone must complete the
139// job, and "declared, nothing happening" with no way to tell those apart
140// fails it. Disclosure-wise it is the same class as secrets_pubkey and the
141// apps domain: a deployment-wide fact about the platform, carrying nothing
142// about any tenant, and every member of it can already see the consequences.
143// storeEntitlementWire is the TENANT-FACING projection of an Entitlement, and
144// exists because the record is operator commentary as much as it is tenant
145// fact. Decision.By is the address of the person who decided; Decision.Reason
146// is what they said about this account. §4 of the self-serve spec keeps both
147// out of the fleet read ACL by putting the record under store/ — this keeps
148// them out of the console's own answer, which is the other road a tenant could
149// read them by, and the one an Admin service credential travels.
150//
151// What travels, and to whom:
152//
153// Decision.By never. The operator stays answerable to the deployment,
154// not to the account they just declined.
155// Decision.Reason to a PERSON only. Requiring a reason on decline is
156// pointless if the account never learns it — that is the
157// four-day silence of §1 in a new hat — but it is written
158// for someone to read and act on, not for an app to branch
159// on.
160// Applied.* the applicant's own text about themselves, so it goes
161// back to a person untouched. A machine gets the At alone:
162// it needs the STATE, and free text a colleague typed is
163// not state.
164//
165// The shape mirrors Entitlement's tags exactly, so the CLI decodes this into
166// an objstore.Entitlement and the four-state printers keep working — a
167// withheld field simply arrives as its zero value.
168type storeEntitlementWire struct {
169 Applied *storeAppliedWire `json:"applied,omitempty"`
170 Decision *storeDecisionWire `json:"decision,omitempty"`
171}
172
173type storeAppliedWire struct {
174 By string `json:"by,omitempty"`
175 At time.Time `json:"at"`
176 Note string `json:"note,omitempty"`
177}
178
179type storeDecisionWire struct {
180 Approved bool `json:"approved"`
181 At time.Time `json:"at"`
182 Reason string `json:"reason,omitempty"`
183}
184
185// The caller is read from ctx rather than passed: every door that renders a
186// store reply builds its ctx from the request, so the principal is already
187// there, and a parameter threaded through five call sites is five chances to
188// pass the wrong one. A ctx with NO principal is treated as a machine — the
189// fail-closed direction, and the operator walk doors are the only callers in
190// that position, where the withheld reason is one they wrote themselves.
191func tenantEntitlement(e *objstore.Entitlement, ctx context.Context) *storeEntitlementWire {
192 if e == nil {
193 return nil
194 }
195 p, ok := principalFrom(ctx)
196 person := ok && p.Kind == PrincipalUser
197 out := &storeEntitlementWire{}
198 if e.Applied != nil {
199 out.Applied = &storeAppliedWire{At: e.Applied.At}
200 if person {
201 out.Applied.By, out.Applied.Note = e.Applied.By, e.Applied.Note
202 }
203 }
204 if e.Decision != nil {
205 out.Decision = &storeDecisionWire{Approved: e.Decision.Approved, At: e.Decision.At}
206 if person {
207 out.Decision.Reason = e.Decision.Reason
208 }
209 }
210 return out
211}
212
213func (s *Server) storeReply(ctx context.Context, acct *Account, app, env string, rec *objstore.Record) (map[string]any, error) {
214 entitlement, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid)
215 if err != nil {
216 return nil, err
217 }
218 prefix := storeEffectivePrefix(rec)
219 wantEndpoint := rec.Provisioned != nil && rec.Provisioned.Endpoint != ""
220 bucket, region, endpoint, accessKey, secretKey := objstore.EnvNames(prefix)
221 names := []string{bucket, region}
222 if wantEndpoint {
223 names = append(names, endpoint)
224 }
225 names = append(names, accessKey, secretKey)
226 missing := []string{}
227 if rec.Provisioned != nil {
228 // The bundle this store DELIVERS into, which for an environment store
229 // is not the app's default one. Asking the default bundle would
230 // report every delivered variable missing, on every status call, for
231 // every environment store.
232 got, err := objstore.EnvDeliveredIn(ctx, s.cfg.Releases, acct.Sqid, app, env, prefix, wantEndpoint)
233 if err != nil {
234 return nil, err
235 }
236 missing = append(missing, got...)
237 }
238 return map[string]any{
239 "app": app,
240 // "" for the app's own store. Omitting it when empty would make an
241 // old CLI and a new one disagree about which store a reply describes,
242 // so it is always present and always says which.
243 "environment": env,
244 "entitlement": tenantEntitlement(entitlement, ctx),
245 "backend": s.cfg.StoreDeployment.Backend,
246 "declared": rec.Declared,
247 "grant": rec.Grant,
248 "provisioned": rec.Provisioned,
249 "env_names": names,
250 // A rotation that minted a new key but has not yet deleted the old
251 // one. The member-visible half of §6's two-step: until it is finished,
252 // two credentials open this bucket.
253 "pending_finish": rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "",
254 "missing_vars": missing,
255 // Additive, and additive is the ONLY safe move here: release.PurgeApp
256 // parses this same object with its own struct and freezes the `grant`
257 // and `provisioned` names. A new key it does not know about is
258 // ignored; a renamed one it does know about is a purge that stops
259 // working. Once scanning exists, a status that said nothing about it
260 // would be a half-truth — but it is nil on every store that never
261 // opted in, which is all of them by default.
262 "scanning": rec.Scanning,
263 // The bucket's size from the daily storage sweep, absent until a
264 // sweep has measured this store. See storeSize.
265 "size": s.storeSize(ctx, acct, app, rec),
266 }, nil
267}
268
269// storeSizeLookback is how many UTC days storeSize walks back looking for a
270// storage sweep. The sweep runs on the console's first hourly economics wake
271// of each day, so today or yesterday normally answers; a week covers a
272// console that was down for a while, and anything older is too stale to
273// print as the bucket's size.
274const storeSizeLookback = 7
275
276// storeSizeReply is the "size" block of `carlos store status`. It is copied
277// from the storage sweep (usage/storage/<date>.json) and is NOT measured on
278// this request: sizing a bucket means listing every object in it, which is
279// what the daily sweep already does once. Unswept means the sweep ran but
280// could not size this store (bad credential, endpoint down), and then
281// StoreBytes is meaningless. BucketBytes is the app's own files in the
282// deployment bucket (releases, config), a separate figure from its store.
283type storeSizeReply struct {
284 AsOf string `json:"as_of"`
285 StoreBytes int64 `json:"store_bytes"`
286 Unswept bool `json:"unswept,omitempty"`
287 BucketBytes int64 `json:"bucket_bytes"`
288}
289
290// storeSize finds the newest storage sweep from the last storeSizeLookback
291// days that has an answer for this app's store, or nil. A sweep that did not
292// see the store provisioned (it ran before the grant, or predates the
293// store_provisioned field) has no answer: its StoreBytes of 0 would read as
294// an empty bucket. Read failures are logged and answer nil; the size is an
295// annotation on status, never a reason for status to fail.
296func (s *Server) storeSize(ctx context.Context, acct *Account, app string, rec *objstore.Record) *storeSizeReply {
297 if rec.Provisioned == nil {
298 return nil
299 }
300 key := acct.Sqid + "/" + app
301 now := time.Now()
302 for i := 0; i < storeSizeLookback; i++ {
303 date := usage.DayOf(now.AddDate(0, 0, -i))
304 raw, _, err := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date))
305 if errors.Is(err, os.ErrNotExist) {
306 continue
307 }
308 if err != nil {
309 log.Printf("console: api store size %s: %v", usage.StorageDayKey(date), err)
310 return nil
311 }
312 var day usage.StorageDay
313 if err := json.Unmarshal(raw, &day); err != nil {
314 log.Printf("console: api store size: parse %s: %v", usage.StorageDayKey(date), err)
315 return nil
316 }
317 st, ok := day.Apps[key]
318 if !ok || (!st.StoreProvisioned && !st.StoreUnswept) {
319 // The newest sweep did not see this store; an older one cannot
320 // have either.
321 return nil
322 }
323 return &storeSizeReply{AsOf: date, StoreBytes: st.StoreBytes,
324 Unswept: st.StoreUnswept, BucketBytes: st.BucketBytes}
325 }
326 return nil
327}
328
329// handleAPIStoreCreate answers `carlos store create`: record that this app
330// wants object storage, under a chosen (or default) env prefix. It is a
331// REQUEST, not a provisioning — nothing exists in the backend when this
332// returns 200, and on a deployment with no backend configured at all the
333// declaration simply waits for an operator who can grant it. That is
334// deliberate: a member cannot see whether their deployment has a backend, and
335// making them find out by being refused would teach them nothing they could
336// act on, while the declaration itself is what an operator needs to act.
337//
338// The doors, in order, and why this order — handleAPIRestart's exactly:
339//
340// token — cliUser's one 401, before any name is resolved.
341// account+app 404 — apiAccountApp: an unknown account, someone else's
342// account and an app that isn't there are one answer, so
343// probing this door teaches a stranger nothing. It also
344// means a TRASHED app cannot declare a store: the live-apps
345// lookup simply doesn't answer for it.
346// rate limit — the shared `write` budget, keyed on the ADDRESS like
347// promote's: a budget belongs to the person, not the
348// terminal.
349// Releases nil — 503. The declaration IS a bucket object; a console with
350// no bucket has nowhere to put it and must not answer as
351// if it had.
352// prefix — refused before the first store call, so a corrected
353// retry starts from exactly the state the caller left.
354//
355// A JSON BODY IS REQUIRED, even to take the default: readJSON's
356// content-type-and-decode is shared by every CLI door, so `{}` is the empty
357// request here — not a zero-length body, which decodes as malformed like
358// anywhere else.
359//
360// Re-declaring the same prefix preserves the author, timestamp and etag.
361// A no-op must not make an in-flight provision lose its record write. Members
362// re-run create after a failed walk, from second terminals and deploy scripts.
363func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) {
364 addr, acct, app, ok := s.apiAccountApp(w, r)
365 if !ok {
366 return
367 }
368 if s.refuseTemporary(w, acct, app, "give it object storage") {
369 return
370 }
371 p, _ := principalFrom(r.Context())
372 if !s.allowWrite(p, time.Now()) {
373 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
374 return
375 }
376 if s.cfg.Releases == nil {
377 storeNoBucket(w)
378 return
379 }
380 var req struct {
381 EnvPrefix string `json:"env_prefix"`
382 }
383 if !readJSON(w, r, &req) {
384 return
385 }
386 prefix := strings.TrimSpace(req.EnvPrefix)
387 if prefix == "" {
388 prefix = objstore.DefaultEnvPrefix
389 }
390 if !objstore.ValidEnvPrefix(prefix) {
391 // The charset is named rather than described: the caller has to be
392 // able to fix their own prefix from this sentence alone, and the
393 // regexp is shorter than the English for it.
394 jsonError(w, http.StatusBadRequest,
395 fmt.Sprintf("invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$ (e.g. %s)", req.EnvPrefix, objstore.DefaultEnvPrefix))
396 return
397 }
398 env, ok := s.storeEnvParam(w, r)
399 if !ok {
400 return
401 }
402 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
403 defer cancel()
404 // The environment-store ceiling (issue #412 §8) is enforced HERE as well
405 // as on the automatic path. A cap that only the create-an-instance door
406 // respected would be a cap anyone could step over by hand, and the
407 // footprint it bounds — a bucket and an IAM user per store, against
408 // IAM's per-account limits — does not care which door minted it.
409 // Re-declaring a store that already exists is never capped; storeCeiling
410 // carries that carve-out.
411 if ref := s.storeCeiling(ctx, acct, app, env); ref != nil {
412 jsonError(w, ref.Status, ref.Msg)
413 return
414 }
415 rec, etag, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env)
416 if err != nil {
417 // Logged, not returned: a store error carries bucket names, endpoints
418 // and request ids. The caller gets the fixed sentence, and a retry is
419 // safe — nothing was written.
420 log.Printf("console: api store read %s/%s: %v", acct.Sqid, app, err)
421 jsonError(w, http.StatusBadGateway, "could not read the store record — try again")
422 return
423 }
424 if rec == nil {
425 rec = &objstore.Record{}
426 }
427 // THE PREFIX IS FIXED ONCE AN OPERATOR HAS GRANTED. The delivered names
428 // are what a RUNNING app reads; renaming them would leave it looking for
429 // variables nothing will write again, while the old ones linger in its
430 // config pointing at a live credential. The refusal names the only honest
431 // way out rather than pretending this is a retry-able race.
432 //
433 // The guard keys on the GRANT, not on `provisioned`, because the grant
434 // walk is a sequence and its middle is exactly where a rename does the
435 // damage: the grant block is written first (§5 step 1) and the delivered
436 // variables land several steps before `provisioned` does. A re-declare
437 // squeezed into that window would be accepted under the old rule, and the
438 // vars already written under the OLD prefix would then be orphaned —
439 // nothing rewrites them, nothing reaps them, and they name a key the next
440 // pass revokes. Same widening Tasks 5 and 6 made to their own guards, for
441 // the same window.
442 if (rec.Grant != nil || rec.Provisioned != nil) && storeEffectivePrefix(rec) != prefix {
443 // Two sentences for two true states: an operator has GRANTED but the
444 // walk has not finished, versus a store that is fully provisioned. The
445 // way out is the same either way, and it is the half of the sentence
446 // that matters — but telling someone their store is "already
447 // provisioned" when no bucket exists yet is exactly the kind of
448 // confident lie an operator then has to talk them out of.
449 if rec.Provisioned == nil {
450 jsonError(w, http.StatusConflict,
451 "a store grant is already under way — the env prefix is fixed; destroy and re-create to change it")
452 return
453 }
454 jsonError(w, http.StatusConflict,
455 "store already provisioned — the env prefix is fixed; destroy and re-create to change it")
456 return
457 }
458 if rec.Declared == nil || storeEffectivePrefix(rec) != prefix {
459 rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: addr}
460 if _, err := claimAppCall(s, ctx, acct, app, "store-declare", func(ctx context.Context) (string, error) {
461 return objstore.PutIn(ctx, s.cfg.Releases, acct.Sqid, app, env, rec, etag)
462 }); err != nil {
463 if errors.Is(err, release.ErrCASConflict) {
464 // Someone else wrote this record between our read and our write —
465 // plausibly the operator's grant landing. Re-read and re-ask; this
466 // door deliberately does not retry, because the thing it would be
467 // overwriting is the half it doesn't own.
468 jsonError(w, http.StatusConflict, "the store record changed underneath you. try again")
469 return
470 }
471 log.Printf("console: api store create %s/%s: %v", acct.Sqid, app, err)
472 jsonError(w, http.StatusBadGateway, "could not record the store. nothing was declared; try again")
473 return
474 }
475 // Audited, unlike a read: this is the request an operator will act on by
476 // spending money in the deployment's own cloud account, so who asked and
477 // under what prefix has to be visible to anyone reading rows.
478 s.audit(acct, app, addr, "store-create", "declared a store with env prefix "+prefix+storeEnvSuffix(env)+" (cli)")
479 }
480 // No bumpSerial: nothing a box reads has changed. See the file comment.
481 out, err := s.storeReply(ctx, acct, app, env, rec)
482 if err != nil {
483 log.Printf("console: api store reply %s/%s: %v", acct.Sqid, app, err)
484 jsonError(w, http.StatusBadGateway, "the store was declared, but its status could not be read — try `carlos store status`")
485 return
486 }
487 writeJSON(w, http.StatusOK, out)
488}
489
490// handleAPIStoreGet answers `carlos store status`: the whole record plus the
491// two things a member with no box access cannot see any other way — which
492// delivered variables have not arrived in the app's config, and whether a
493// rotation is still half-done.
494//
495// THE 404 HERE SPEAKS, and that is not a weakening of the gate above it. It
496// fires only AFTER apiAccountApp has proved the caller is a member of the
497// account and that the app exists, so the sentence tells them nothing they
498// were not already entitled to. apiAccountApp's OWN refusals stay
499// byte-identical with http.NotFound's, which is what keeps a stranger from
500// learning that an account, an app or a store exists at all.
501//
502// Not rate limited, matching every other read door: an exhausted write budget
503// must not blind a member to the state of what they already asked for.
504func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) {
505 _, acct, app, ok := s.apiAccountApp(w, r)
506 if !ok {
507 return
508 }
509 if s.cfg.Releases == nil {
510 storeNoBucket(w)
511 return
512 }
513 env, ok := s.storeEnvParam(w, r)
514 if !ok {
515 return
516 }
517 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
518 defer cancel()
519 rec, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env)
520 if err != nil {
521 log.Printf("console: api store status %s/%s: %v", acct.Sqid, app, err)
522 jsonError(w, http.StatusBadGateway, "could not read the store record — try again")
523 return
524 }
525 if rec == nil {
526 // A state, not a failure — and a 404 rather than an empty record,
527 // because "no store" and "a store with nothing filled in yet" are
528 // genuinely different things for the CLI to print.
529 if env != "" {
530 jsonError(w, http.StatusNotFound,
531 "no store is declared for this app's "+env+" environment — run `carlos store create --environment "+env+"`")
532 return
533 }
534 // The app has no store of its OWN — but it may have environment
535 // stores, and this is the shape where answering "no store" is most
536 // wrong. An app whose storage is entirely per-environment (a managed
537 // product's, where every tenant is a bundle) would have every one of
538 // its buckets hidden behind a 404 that says it has none. That is the
539 // exact opposite of what this listing exists for: a store nobody
540 // reads still bills, and teardown is explicit.
541 envs, lerr := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app)
542 if lerr != nil {
543 log.Printf("console: api store environments %s/%s: %v", acct.Sqid, app, lerr)
544 jsonError(w, http.StatusBadGateway, "could not read the store record — try again")
545 return
546 }
547 if len(envs) == 0 {
548 jsonError(w, http.StatusNotFound, "no store is declared for this app — run `carlos store create`")
549 return
550 }
551 // A zero record, not a nil one: every block reads as absent, which is
552 // true, and the environments are named beside it.
553 out, rerr := s.storeReply(ctx, acct, app, "", &objstore.Record{})
554 if rerr != nil {
555 log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, rerr)
556 jsonError(w, http.StatusBadGateway, "could not read the store's status. try again")
557 return
558 }
559 out["environments"] = envs
560 writeJSON(w, http.StatusOK, out)
561 return
562 }
563 out, err := s.storeReply(ctx, acct, app, env, rec)
564 if err != nil {
565 log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, err)
566 jsonError(w, http.StatusBadGateway, "could not read the store's status. try again")
567 return
568 }
569 // Asked about the app's own store, a member is told what ELSE this app
570 // has: an environment store nobody remembers is a bucket nobody
571 // remembers, and this is the one screen where it can be seen (spec §8).
572 // Only on the app-store call — a request aimed at one environment
573 // answers about that environment.
574 if env == "" {
575 envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app)
576 if err != nil {
577 log.Printf("console: api store environments %s/%s: %v", acct.Sqid, app, err)
578 jsonError(w, http.StatusBadGateway, "could not read the store's status. try again")
579 return
580 }
581 out["environments"] = envs
582 }
583 writeJSON(w, http.StatusOK, out)
584}
585
586// storeEnvSuffix names the environment in an audit row, or says nothing at
587// all for the app's own store — so rows written before environment stores
588// existed and rows written after read identically for the same action.
589func storeEnvSuffix(env string) string {
590 if env == "" {
591 return ""
592 }
593 return " in environment " + env
594}
595
596// ---- the walk doors: grant, rotate, finish -----------------------------------
597
598// storeNameTakenMsg answers objstore.ErrStoreNameTaken (#218). Bucket and IAM
599// user names join account, app and environment with "-", so app "shop" with
600// environment "eu" and app "shop-eu" would get one estate; the walk refuses
601// the second before creating anything, and the sentence says why.
602const storeNameTakenMsg = "another store already uses this store's bucket or IAM user name (app and environment names are joined with \"-\"). nothing was created"
603
604// storeGrantTimeout bounds the grant walk. Longer than storeTimeout because a
605// grant is not one round trip: it creates a bucket, an IAM principal with an
606// inline policy, and a key, then writes config and the record. Bucket creation
607// alone can take seconds on a cold region, and a walk cut off mid-sequence
608// costs the operator a re-run they shouldn't need.
609const storeGrantTimeout = 60 * time.Second
610
611// storeRotateTimeout bounds rotate and finish. Both act on an estate that
612// already exists — delete, reap, mint, deliver — so they are shorter than a
613// grant but still well past the 15s a single object write gets.
614const storeRotateTimeout = 30 * time.Second
615
616// storeTeardownTimeout bounds a Teardown walk, wherever it is driven from (the
617// browser destroy door and the purge door, appsettings.go and ownership.go).
618// A grant's ceiling for a walk with a grant's shape and one step a grant does
619// not have: EmptyBucket is PAGINATED precisely because a real bucket does not
620// empty in one call, so a customer's store can be many round trips before the
621// bucket, the principal and the config write even begin. Under storeTimeout's
622// 15s — a ceiling documented for a SINGLE round trip — the deadline would land
623// mid-walk and be reported as a backend that could not be reached, which is
624// the wrong thing to tell someone whose store is simply large.
625const storeTeardownTimeout = 60 * time.Second
626
627// storeOperator reports whether this address may drive the grant door.
628//
629// EXACT MATCH ON THE CANONICAL FORM, never a fold. Every address entry point
630// in this codebase ASCII-lowercases on the way in (sign-in, team-add, the
631// materialize pass, `console member`), so the address on a CLI token is
632// already canonical; the list entry is lowercased and trimmed here so an
633// operator who typed Alice@Keymail.dev into a unit file still matches.
634//
635// strings.EqualFold would be the obvious way to write that and is WRONG for
636// the highest-authority gate in the API: it applies Unicode simple folding, in
637// which U+017F (ſ, long s) folds to "s" — and ſ is already lowercase, so it
638// survives every ToLower on the way in. ValidAddress permits it (anything
639// without whitespace and with an @). With a fold, owner@example.teſt would
640// match an allowlist entry of owner@example.test and be handed the authority
641// to spend the deployment's money.
642//
643// Empty list is fail-closed — the loop simply never matches — which is the
644// right direction for a gate on spending.
645func (s *Server) storeOperator(addr string) bool {
646 for _, op := range s.cfg.StoreOperators {
647 if strings.ToLower(strings.TrimSpace(op)) == addr {
648 return true
649 }
650 }
651 return false
652}
653
654// storeWalkGates are the gates every store WALK door shares, after the caller
655// has resolved the member (and, for grant, checked the operator allowlist):
656// the write budget, a bucket to record into, and a backend to act on.
657//
658// The provisioner check is here rather than inside the walks because a
659// deployment with no object storage is a configuration fact, not a failure of
660// this request: the member's declaration still stands and the sentence names
661// the variable that would wire it up, which is the only thing the person
662// reading it can do about it.
663func (s *Server) storeWalkGates(w http.ResponseWriter, p Principal) bool {
664 if !s.allowWrite(p, time.Now()) {
665 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
666 return false
667 }
668 if s.cfg.Releases == nil {
669 storeNoBucket(w)
670 return false
671 }
672 if s.cfg.StoreProvisioner == nil {
673 jsonError(w, http.StatusServiceUnavailable,
674 "object storage is not configured for this deployment (CARLOS_STORE_BACKEND)")
675 return false
676 }
677 return true
678}
679
680// storeSealKey is the gate that says: no sealing key, no minting.
681//
682// A credential this console cannot seal is one it cannot DELIVER, and a key
683// minted without delivery is the worst state in the system — it exists in the
684// backend, it is billable, its secret died in this process, and only a reap
685// finds it again. So grant and rotate refuse before the first backend call
686// rather than after. Finish does not call this at all: it seals nothing.
687func (s *Server) storeSealKey(w http.ResponseWriter) (*ecdh.PublicKey, bool) {
688 if s.cfg.SecretsPub == "" {
689 jsonError(w, http.StatusServiceUnavailable,
690 "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver a store credential, so it will not mint one")
691 return nil, false
692 }
693 pub, err := appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY")
694 if err != nil {
695 // Logged with the parse detail, answered without it: the caller cannot
696 // fix this console's configuration and the sentence they get is the one
697 // that tells the operator where to look.
698 log.Printf("console: api store seal key: %v", err)
699 jsonError(w, http.StatusServiceUnavailable,
700 "this console's secrets key (CARLOS_SECRETS_PUBKEY) cannot be read — it cannot deliver a store credential, so it will not mint one")
701 return nil, false
702 }
703 return pub, true
704}
705
706// storeBucketName is the bucket a record names, for an audit line: what was
707// provisioned if the walk got that far, otherwise what the grant chose.
708func storeBucketName(rec *objstore.Record) string {
709 switch {
710 case rec == nil:
711 return ""
712 case rec.Provisioned != nil:
713 return rec.Provisioned.Bucket
714 case rec.Grant != nil:
715 return rec.Grant.Bucket
716 }
717 return ""
718}
719
720// storeWalkReply is the shared tail of all three walk doors: render the same
721// answer shape the member doors give, from the record the walk just wrote.
722// A walk that succeeded and a reply that could not be rendered are different
723// events, and the sentence says so — the thing was done, go and look at it —
724// because telling someone their grant failed when it didn't is how a second
725// grant gets run over a live estate.
726func (s *Server) storeWalkReply(ctx context.Context, w http.ResponseWriter, acct *Account, app, env string, rec *objstore.Record) {
727 out, err := s.storeReply(ctx, acct, app, env, rec)
728 if err != nil {
729 log.Printf("console: api store reply %s/%s: %v", acct.Sqid, app, err)
730 jsonError(w, http.StatusBadGateway,
731 "the store changed, but its status could not be read — run `carlos store status`")
732 return
733 }
734 writeJSON(w, http.StatusOK, out)
735}
736
737// handleAPIStoreGrant answers `carlos store grant`: provision the declared
738// store and deliver its credentials. THIS IS THE AUTHORITY DOOR — the one
739// request in the whole API that creates a bucket, mints an IAM key and starts
740// a bill in the deployment's own cloud account.
741//
742// The doors, in order, and why this order:
743//
744// token — cliUser's one 401, before any name is resolved.
745// account+app 404 — apiAccountApp, FIRST and before the allowlist. An
746// operator is not automatically a member: eve holding the
747// operator role must still get the same 404 for an account
748// she isn't in that a stranger gets, or this door becomes
749// the one place in the API that confirms an account or an
750// app exists. Spec §3 wants both — the operator allowlist
751// AND account membership.
752// allowlist — 403, naming CARLOS_STORE_OPERATORS. Safe to speak here:
753// the caller has already proved membership, so all they
754// learn is what authority THEY lack.
755// rate limit — the shared write budget, keyed on the ADDRESS.
756// Releases nil — 503; the record and the delivered config are both bucket
757// objects.
758// backend nil — 503, naming CARLOS_STORE_BACKEND: this deployment has no
759// object storage at all, which is the one thing the member
760// who declared could not see for themselves.
761// seal key — 503. Cannot seal → cannot deliver → MUST NOT MINT.
762// bucket name — 400 before any backend call. An adopt name is
763// interpolated into the IAM policy's resource ARN, so an
764// unchecked one is a scope nobody meant to grant. The walk
765// validates it too (objstore.ValidBucketName, the same
766// function) — this copy exists so the refusal is a clean
767// 400 with the name in it rather than a 502.
768//
769// IDEMPOTENT, because the walk is: re-running over a healthy store is a no-op
770// that mints nothing. A re-run over a DRIFTED one — a delivered variable
771// unset, a key gone from the backend — repairs it by reaping and re-minting,
772// which means the app's key CHANGES. That is the repair working as designed
773// (§5), and it is why the client sentence for a failed grant says "re-run to
774// resume" rather than promising nothing will move.
775func (s *Server) handleAPIStoreGrant(w http.ResponseWriter, r *http.Request) {
776 addr, acct, app, ok := s.apiAccountApp(w, r)
777 if !ok {
778 return
779 }
780 if s.refuseTemporary(w, acct, app, "give it object storage") {
781 return
782 }
783 if !s.storeOperator(addr) {
784 jsonError(w, http.StatusForbidden,
785 "store grants need a deployment operator (CARLOS_STORE_OPERATORS)")
786 return
787 }
788 p, _ := principalFrom(r.Context())
789 if !s.storeWalkGates(w, p) {
790 return
791 }
792 pub, ok := s.storeSealKey(w)
793 if !ok {
794 return
795 }
796 // The body carries one optional field: a bucket to ADOPT instead of
797 // minting. `{}` is the ordinary request, as on the create door.
798 var req struct {
799 Bucket string `json:"bucket"`
800 }
801 if !readJSON(w, r, &req) {
802 return
803 }
804 bucket := strings.TrimSpace(req.Bucket)
805 if bucket != "" && !objstore.ValidBucketName(bucket) {
806 jsonError(w, http.StatusBadRequest,
807 fmt.Sprintf("invalid bucket name %q — want 3-63 characters of lowercase letters, digits, dots and hyphens, starting and ending alphanumeric", req.Bucket))
808 return
809 }
810 env, ok := s.storeEnvParam(w, r)
811 if !ok {
812 return
813 }
814 ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
815 defer cancel()
816 defer s.storeWalks.lock(acct.Sqid, app, env)()
817 rec, err := claimAppCall(s, ctx, acct, app, "store-grant", func(ctx context.Context) (*objstore.Record, error) {
818 return objstore.GrantIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment,
819 pub, acct.Sqid, app, env, addr, objstore.GrantSourceOperator, bucket, time.Now())
820 })
821 switch {
822 case errors.Is(err, objstore.ErrNotDeclared):
823 // The operator's verb never invents the member's half: the env prefix
824 // is the member's choice and there is nothing to deliver into.
825 jsonError(w, http.StatusConflict,
826 "no store is declared for this app. run `carlos store create` first")
827 return
828 case errors.Is(err, objstore.ErrStoreNameTaken):
829 // Refused before anything was created (#218): the bucket, or the IAM
830 // user this store would be given, is already another store's.
831 log.Printf("console: api store grant %s/%s (bucket %q): %v", acct.Sqid, app, bucket, err)
832 jsonError(w, http.StatusConflict, storeNameTakenMsg)
833 return
834 case err != nil:
835 // Logged, not returned: a backend error carries bucket names, ARNs,
836 // endpoints and request ids. The client sentence promises exactly what
837 // the walk's ordering guarantees — every resource this walk created is
838 // named by a record it wrote before creating it, so nothing is stranded
839 // where only a bill would find it — and the fix is to run the same verb
840 // again, which resumes from wherever it stopped.
841 log.Printf("console: api store grant %s/%s (bucket %q): %v", acct.Sqid, app, bucket, err)
842 jsonError(w, http.StatusBadGateway,
843 "could not provision the store — nothing unrecorded was left behind; re-run the grant to resume")
844 return
845 }
846 s.audit(acct, app, addr, "store-grant", "granted a store in bucket "+storeBucketName(rec)+storeEnvSuffix(env)+" (cli)")
847 // The app's config bundle changed: boxes read config only when the serial
848 // moves, so without this the credentials sit in the bucket until the hourly
849 // pass and the app keeps failing to reach a store it has been given.
850 s.bumpSerial(ctx)
851 s.storeWalkReply(ctx, w, acct, app, env, rec)
852}
853
854// handleAPIStoreRotate answers `carlos store rotate`: mint a fresh key,
855// deliver it, and leave the OLD key live until an explicit finish.
856//
857// A MEMBER VERB, not an operator one (spec §8, and the route table in §10).
858// Rotation spends nothing new — it cycles a credential inside an estate an
859// operator already granted — and the person who needs it most is a member who
860// has just leaked a key at 2am. Making them find a deployment operator first
861// would make the safety verb the slow one. The gates are therefore create's,
862// plus the two 503s that any minting door owes: a backend to mint in and a key
863// to seal with.
864//
865// The overlap the walk leaves behind is the point: adoption swaps a symlink,
866// it does not restart processes, so at the instant new credentials land there
867// are still hosts and hibernating tenants holding the old pair. `pending_finish`
868// in the reply is how a member sees that window is open.
869func (s *Server) handleAPIStoreRotate(w http.ResponseWriter, r *http.Request) {
870 addr, acct, app, ok := s.apiAccountApp(w, r)
871 if !ok {
872 return
873 }
874 p, _ := principalFrom(r.Context())
875 if !s.storeWalkGates(w, p) {
876 return
877 }
878 pub, ok := s.storeSealKey(w)
879 if !ok {
880 return
881 }
882 // No body: a rotation has no arguments, and handleAPIRestart — the other
883 // verb whose whole request is "do the thing to this app" — reads none
884 // either, so a CLI can POST with nothing at all.
885 env, ok := s.storeEnvParam(w, r)
886 if !ok {
887 return
888 }
889 ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout)
890 defer cancel()
891 defer s.storeWalks.lock(acct.Sqid, app, env)()
892 rec, err := claimAppCall(s, ctx, acct, app, "store-rotate", func(ctx context.Context) (*objstore.Record, error) {
893 return objstore.RotateIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, pub, acct.Sqid, app, env, time.Now())
894 })
895 switch {
896 case errors.Is(err, objstore.ErrNotProvisioned), errors.Is(err, objstore.ErrNotDeclared):
897 // Rotation edits an existing estate; it never creates one, because the
898 // coordinates it reseals come from the record and there is no
899 // defensible guess for them. The repair for this state is a grant.
900 jsonError(w, http.StatusConflict,
901 "no provisioned store to rotate — grant one first")
902 return
903 case errors.Is(err, release.ErrCASConflict):
904 // A concurrent write to the record. NOT a failed rotation: by the time
905 // the walk reaches its final Put it has already minted a key and
906 // delivered it into the app's config, so telling the caller nothing
907 // happened would send them looking for a key that is already live. The
908 // honest instruction is to run it again, which converges.
909 log.Printf("console: api store rotate %s/%s: %v", acct.Sqid, app, err)
910 jsonError(w, http.StatusConflict,
911 "another rotation is in progress — re-run to converge")
912 return
913 case err != nil:
914 log.Printf("console: api store rotate %s/%s: %v", acct.Sqid, app, err)
915 jsonError(w, http.StatusBadGateway,
916 "could not rotate the store credential — nothing unrecorded was left behind; re-run the rotation to resume")
917 return
918 }
919 key := ""
920 if rec.Provisioned != nil {
921 key = rec.Provisioned.KeyID
922 }
923 // Key ids are not secrets — `status` prints them — and the audit row is
924 // where a member later matches "which credential did we cut over to".
925 s.audit(acct, app, addr, "store-rotate", "rotated the store credential to key "+key+storeEnvSuffix(env)+" (cli)")
926 s.bumpSerial(ctx)
927 s.storeWalkReply(ctx, w, acct, app, env, rec)
928}
929
930// handleAPIStoreRotateFinish answers `carlos store rotate -finish`: delete the
931// previous key and stop naming it. The member's explicit "nothing is using it
932// any more" — the one destructive step in the rotation state machine, which is
933// why it is a verb someone asks for rather than a timer.
934//
935// TWO DIFFERENCES from its siblings, both deliberate:
936//
937// It needs NO SEALING KEY. Finishing delivers nothing — the app has been
938// reading the current key since the rotation — so a console that has lost its
939// CARLOS_SECRETS_PUBKEY can still close a two-key window. Requiring the key
940// uniformly would be tidier and would strand exactly the deployment that most
941// needs to retire a credential.
942//
943// It does NOT bump the serial. No object a box reads changed: the delivered
944// config is untouched, and the record is console-side. Waking the fleet to
945// tell it a key it never held is gone would be pure noise.
946func (s *Server) handleAPIStoreRotateFinish(w http.ResponseWriter, r *http.Request) {
947 addr, acct, app, ok := s.apiAccountApp(w, r)
948 if !ok {
949 return
950 }
951 p, _ := principalFrom(r.Context())
952 if !s.storeWalkGates(w, p) {
953 return
954 }
955 env, ok := s.storeEnvParam(w, r)
956 if !ok {
957 return
958 }
959 ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout)
960 defer cancel()
961 defer s.storeWalks.lock(acct.Sqid, app, env)()
962 rec, err := claimAppCall(s, ctx, acct, app, "store-rotate-finish", func(ctx context.Context) (*objstore.Record, error) {
963 return objstore.RotateFinishIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app, env)
964 })
965 switch {
966 case errors.Is(err, objstore.ErrNotProvisioned):
967 jsonError(w, http.StatusConflict,
968 "no provisioned store to rotate — grant one first")
969 return
970 case errors.Is(err, objstore.ErrNoPendingFinish):
971 // Refused rather than succeeding vacuously: a member who believes they
972 // just retired the old key, when an earlier finish already did, has a
973 // different picture of the estate than the estate does.
974 jsonError(w, http.StatusConflict, "no rotation is pending finish")
975 return
976 case err != nil:
977 log.Printf("console: api store rotate finish %s/%s: %v", acct.Sqid, app, err)
978 jsonError(w, http.StatusBadGateway,
979 "could not finish the rotation — the previous key may still be live; re-run to resume")
980 return
981 }
982 key := ""
983 if rec.Provisioned != nil {
984 key = rec.Provisioned.KeyID
985 }
986 s.audit(acct, app, addr, "store-rotate-finish",
987 "finished the rotation — the store now runs on key "+key+" alone"+storeEnvSuffix(env)+" (cli)")
988 // No bumpSerial: see the doc comment.
989 s.storeWalkReply(ctx, w, acct, app, env, rec)
990}
991