carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The three owner-only account-administration actions the multi-account spec
4// names (§1) and the step-up spec gates (§7a): remove a member, transfer
5// ownership, delete the account. They live together because they share one
6// shape — owner gate, CSRF, passkey step-up, then a store call that cannot
7// leave an account with nobody able to administer it.
8//
9// All three were previously reachable only through `carlos members …` or not at
10// all. Putting them behind the console's gates is what makes "membership says
11// who you are, not whether you are here now" something a browser can act on.
12
13import (
14 "context"
15 "crypto/subtle"
16 "database/sql"
17 "errors"
18 "fmt"
19 "log"
20 "net/http"
21 "net/url"
22 "os"
23 "strings"
24 "time"
25
26 "github.com/carlosframework/platform/internal/domains"
27 "github.com/carlosframework/platform/internal/hostops"
28 "github.com/carlosframework/platform/internal/mail"
29 "github.com/carlosframework/platform/internal/objstore"
30 "github.com/carlosframework/platform/internal/release"
31)
32
33// handleRemoveMember drops someone's membership. Owner-only, CSRF-checked and
34// step-up gated, in that order: a non-owner must meet the byte-identical 404
35// before step-up is ever mentioned, or the redirect itself would confirm the
36// account exists.
37func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
38 addr, raw, acct, ok := s.owner(w, r)
39 if !ok {
40 return
41 }
42 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
43 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
44 return
45 }
46 if !s.requireFreshAuth(w, r, addr, raw) {
47 return
48 }
49 target := strings.TrimSpace(r.FormValue("address"))
50 if err := s.store.RemoveMember(acct.ID, target); err != nil {
51 log.Printf("console: remove member: %v", err)
52 s.setFlash(w, "err", removalAdvice(err, target))
53 } else {
54 s.setFlash(w, "ok", target+" is no longer a member of this account.")
55 }
56 http.Redirect(w, r, "/a/"+acct.Sqid+"/team", http.StatusSeeOther)
57}
58
59// handleTransferOwnership hands the account to another member and steps the
60// current owner down to member — one transaction, so there is no instant with
61// two owners by accident or none by failure.
62func (s *Server) handleTransferOwnership(w http.ResponseWriter, r *http.Request) {
63 addr, raw, acct, ok := s.owner(w, r)
64 if !ok {
65 return
66 }
67 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
68 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
69 return
70 }
71 if !s.requireFreshAuth(w, r, addr, raw) {
72 return
73 }
74 target := strings.TrimSpace(r.FormValue("address"))
75 if err := s.store.TransferOwnership(acct.ID, addr, target, time.Now()); err != nil {
76 log.Printf("console: transfer ownership: %v", err)
77 s.setFlash(w, "err", transferAdvice(err, target))
78 } else {
79 s.setFlash(w, "ok", target+" now owns this account. You are a member of it.")
80 }
81 http.Redirect(w, r, "/a/"+acct.Sqid+"/team", http.StatusSeeOther)
82}
83
84// handleDeleteAccount retires an EMPTY account. Deliberately narrow: an account
85// that still holds apps is refused rather than cascading, because the objects,
86// registry routes and platform domains an app owns are not this handler's to
87// destroy and undoing that is not a thing anyone can do. The useful case — a
88// mistyped or abandoned account — is fully served, and the dangerous case is
89// left to a deliberate migration.
90//
91// A control-plane app is refused by NAME on top of that, before the passkey:
92// the flag's whole job is to stop the platform deleting itself, and a guard on
93// the app's doors that an account deletion walks around would read as
94// protection without being it (controlplane.go).
95func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
96 addr, raw, acct, ok := s.owner(w, r)
97 if !ok {
98 return
99 }
100 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
101 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
102 return
103 }
104 // An account holding a control-plane app is not retirable: deleting the
105 // account is the same destruction the app's own doors refuse, reached one
106 // level up (controlplane.go). Refused here — after the owner gate, before
107 // the typed name and before requireFreshAuth — because no gesture answers
108 // the flag, and the refusal must not cost a passkey touch.
109 if msg := s.controlPlaneAccountRefusal(acct.ID); msg != "" {
110 s.setFlash(w, "err", msg)
111 http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
112 return
113 }
114 // Typing the name is not a security control — it is a "did you mean this
115 // account?" control, and it is checked before step-up so a slip does not
116 // cost a passkey touch.
117 if strings.TrimSpace(r.FormValue("confirm")) != acct.Name {
118 s.setFlash(w, "err", "Type the account's name exactly to delete it.")
119 http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
120 return
121 }
122 if !s.requireFreshAuth(w, r, addr, raw) {
123 return
124 }
125 // Both refusals that DeleteAccount enforces are re-checked HERE, before the
126 // bucket is touched. They are cheap, and getting them in the wrong order is
127 // expensive: tombstoning an account the store then refuses to delete would
128 // leave a live account marked retired, which the next reconcile pass would
129 // act on by dropping its registry row.
130 if acct.ID == OpsAccountID {
131 s.setFlash(w, "err", deletionAdvice(fmt.Errorf("console: ops")))
132 http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
133 return
134 }
135 if n, err := s.store.AppCount(acct.ID); err != nil || n > 0 {
136 if err != nil {
137 log.Printf("console: count apps for %s: %v", acct.Sqid, err)
138 s.setFlash(w, "err", "Could not check whether this account still holds apps. Nothing was deleted.")
139 } else {
140 s.setFlash(w, "err", deletionAdvice(fmt.Errorf("console: still holds %d app(s)", n)))
141 }
142 http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
143 return
144 }
145 // The bucket first, and the deletion is abandoned if it will not take.
146 //
147 // This console's rows are not where account identity lives — every console
148 // start materializes accounts from accounts/<sqid>/account.json, so a
149 // deletion that only emptied these tables would be undone by the next
150 // deploy, silently, re-creating the account AND re-adopting the owner the
151 // manifest names. That is not the person who deleted it if ownership was
152 // ever transferred.
153 //
154 // Ordering is the safe way round. Tombstone-then-rows can fail after the
155 // tombstone, leaving an account that still works here and is retired in the
156 // bucket — visibly wrong, recoverable, and harmless while it lasts, because
157 // an account with apps never reaches this line. Rows-then-tombstone fails
158 // the other way: the account is gone from under the operator and comes back
159 // on its own, which is the bug this replaces.
160 if err := s.tombstoneAccount(r.Context(), acct); err != nil {
161 log.Printf("console: tombstone account %s: %v", acct.Sqid, err)
162 s.setFlash(w, "err", "Could not retire this account in the deployment bucket, so nothing "+
163 "was deleted — an account deleted only here would come back on the next restart. "+
164 "Try again once the bucket is reachable.")
165 http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
166 return
167 }
168 if err := s.store.DeleteAccount(acct.ID, time.Now()); err != nil {
169 log.Printf("console: delete account: %v", err)
170 s.setFlash(w, "err", deletionAdvice(err))
171 http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
172 return
173 }
174 s.setFlash(w, "ok", "Deleted “"+acct.Name+"”.")
175 s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther)
176}
177
178// ---- the Trash --------------------------------------------------------------
179
180// handleTrash is the account's Trash: what has been deleted, who deleted it,
181// when the platform will destroy it unasked, and the two buttons that end the
182// wait early in either direction.
183//
184// Any MEMBER may look, through the SAME gate the account home opens with
185// (s.account) — so account names resolve here exactly as they do everywhere
186// else, and a non-member gets the byte-identical 404 an unrouted URL gets
187// rather than anything that confirms this account, or its trash, exists.
188func (s *Server) handleTrash(w http.ResponseWriter, r *http.Request) {
189 addr, raw, acct, role, ok := s.account(w, r)
190 if !ok {
191 return
192 }
193 rows, err := s.store.TrashedAppsIn(acct.ID)
194 if err != nil {
195 log.Printf("console: trash %s: %v", acct.Sqid, err)
196 s.renderMessage(w, r, http.StatusInternalServerError, "Error",
197 "Could not read this account's trash. Please try again.")
198 return
199 }
200 p := s.accountPage(w, r, addr, raw, acct, role)
201 p.Title = "Trash"
202 p.ActiveNav = "apps"
203 p.Trashed = TrashedApps(r.Context(), s.cfg.Releases, acct.Sqid, rows)
204 s.render(w, http.StatusOK, "trash", p)
205}
206
207// handleRestoreApp is the undo button, and it is priced like one: a member, a
208// CSRF token, and nothing else.
209//
210// The gates, in this order and for these reasons:
211//
212// member (s.account) — first, always: a non-member must meet the
213// byte-identical 404 before any other answer exists.
214// CSRF — constant-time, before anything is written.
215// in this trash? — the app arrives in the FORM, not the path, so it is
216// checked here. A refusal is a FLASH, not a 404: the
217// viewer already proved membership, so a name that is
218// not in this trash is a mistake to explain rather than
219// a fact to withhold.
220// bucket, then row — the marker IS the trash state and rows are
221// materialized from it (materialize.go), so a row
222// restored while the marker survived would un-restore
223// itself on the next pass.
224//
225// There is deliberately NO step-up. A passkey demand on the one action that
226// puts things back would make the cheap half of this feature expensive, and
227// restoring destroys nothing that a second restore could not undo.
228func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) {
229 addr, raw, acct, _, ok := s.account(w, r)
230 if !ok {
231 return
232 }
233 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
234 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
235 return
236 }
237 fail := s.trashFailure(w, r, acct)
238 app := strings.TrimSpace(r.FormValue("app"))
239 has, err := s.store.HasTrashedAppIn(acct.ID, app)
240 if err != nil {
241 log.Printf("console: restore %s/%s: %v", acct.Sqid, app, err)
242 fail("Could not read this account's trash. Nothing was restored.")
243 return
244 }
245 if !has {
246 fail("There is nothing called “" + app + "” in this account's trash.")
247 return
248 }
249 if s.cfg.Releases == nil {
250 fail("There is no deployment bucket on this host, so nothing can be restored here.")
251 return
252 }
253 // restoreApp (restore.go) is the one restore every door runs: it locks,
254 // settles, checks the temporary cap and orders the row and marker writes.
255 var planCap *planCapError
256 var capErr *temporaryCapError
257 var rf *restoreFailure
258 switch err := s.restoreApp(r.Context(), acct, app, time.Now()); {
259 case err == nil:
260 case errors.Is(err, errNotInTrash):
261 fail("There is nothing called “" + app + "” in this account's trash.")
262 return
263 case errors.As(err, &planCap):
264 fail(planCap.Browser())
265 return
266 case errors.As(err, &capErr):
267 fail(capErr.Error())
268 return
269 case errors.As(err, &rf) && rf.Marker:
270 log.Printf("console: restore %s/%s: %v", acct.Sqid, app, err)
271 fail("Could not remove the trash marker, so nothing was restored. Try again once the bucket is reachable.")
272 return
273 default:
274 log.Printf("console: restore row %s/%s: %v", acct.Sqid, app, err)
275 // The marker is gone, so the bucket already says restored: tell the
276 // fleet now rather than at the hourly floor. The row catches up on
277 // the next settle.
278 s.bumpSerial(r.Context())
279 fail("The trash marker was removed, but this console's own record did not update — " +
280 "it will catch up on the next restart.")
281 return
282 }
283 s.audit(acct, app, addr, "restore", "restored "+app+" from the trash")
284 // Box-consumed (carlos-deploy §3): removing the trash marker re-enables
285 // instance minting for this app, and minting from records is leg 1 of the
286 // fast pass's reconcile. Without the bump a restore is invisible to the
287 // fleet until the hourly floor comes round. Trash (handleAppDelete) bumps
288 // the same way now — symmetric in and out; purge (handlePurgeApp, below)
289 // still does not, because by then the fleet has nothing left to converge
290 // on.
291 s.bumpSerial(r.Context())
292 // The anti-ghost rule, said where the person can read it: restoring brings
293 // back releases and channels and no routes at all (spec §3), so an app
294 // restored and left alone serves nothing and nobody can tell why.
295 if release.IsTemporaryName(app) {
296 s.setFlash(w, "ok", restoredTemporaryNote(app))
297 } else {
298 s.setFlash(w, "ok", "Restored “"+app+"”, with its releases and channels intact and with NO routes: "+
299 "run `carlos add <host>` to serve it again.")
300 }
301 // Home, not back to the Trash: the app is not in the trash any more, and
302 // the list it rejoined is the thing worth looking at.
303 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
304}
305
306// handlePurgeApp is "Delete now" — the one irreversible button in the
307// console, and the only one that destroys an app's objects rather than
308// marking them.
309//
310// The gates are handleDeleteAccount's idiom (see above) applied to an app,
311// in exactly this order:
312//
313// owner (s.owner) — a member probing this gets the same 404 a stranger
314// does; "not allowed" and "no such account" are one
315// answer.
316// CSRF — constant-time, before any pending action can be
317// parked by a cross-site post.
318// control plane — refused outright, before the typed name and before
319// step-up: an app flagged as the control plane holds the
320// release history the fleet is running, and clearing the
321// flag is the separate act that unlocks this door.
322// type the APP name — not a security control, a "did you mean this app?"
323// control, and checked BEFORE step-up so a slip does not
324// cost a passkey touch.
325// requireFreshAuth — LAST of the auth gates, so no redirect into Keymail
326// ever happens for someone who should have got a 404.
327//
328// What gets destroyed, and in what order, is the other half of the design: the
329// app's OBJECT STORE goes first (see the block below), because it is the one
330// piece of the app that lives outside the deployment bucket — a real bucket
331// with a real credential — and store.json, which is the only thing naming it,
332// is inside the prefix this purge is about to sweep.
333//
334// Only then does anything get destroyed, and PurgeApp re-reads the marker and
335// re-checks the production flag itself, immediately before deleting: a
336// restore that landed while this form sat open wins, and an app somebody
337// flagged production after trashing it survives its own deletion ceremony.
338func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) {
339 addr, raw, acct, ok := s.owner(w, r)
340 if !ok {
341 return
342 }
343 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
344 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
345 return
346 }
347 fail := s.trashFailure(w, r, acct)
348 app := strings.TrimSpace(r.FormValue("app"))
349 // An empty app name would make the confirmation below compare "" against
350 // "" and pass — a ceremony that confirms nothing. Refused here rather than
351 // left to PurgeApp, which would only fail later and after a passkey.
352 if app == "" {
353 fail("Choose an app to delete permanently.")
354 return
355 }
356 // The control plane is the one app this door must never destroy: its
357 // prefix holds the release history the fleet is RUNNING. Refused before
358 // the typed name and before the passkey — neither gesture can answer the
359 // flag, and asking for one would only spend it (controlplane.go).
360 if msg, _ := s.controlPlaneRefusal(acct, app, "deleted"); msg != "" {
361 fail(msg)
362 return
363 }
364 if strings.TrimSpace(r.FormValue("confirm")) != app {
365 fail("Type “" + app + "” exactly to delete it permanently. Nothing was deleted.")
366 return
367 }
368 if !s.requireFreshAuth(w, r, addr, raw) {
369 return
370 }
371 if s.cfg.Releases == nil {
372 fail("There is no deployment bucket on this host, so nothing can be deleted here.")
373 return
374 }
375 // Every check above ran before the lock and is only a hint (spec §2).
376 // From here the settle, the re-check, PurgeReady, the email and store
377 // checks, every teardown and the sweep all run under the app's one lock.
378 defer s.lockApp(acct.Sqid, app)()
379 if _, ref := s.recheckUnderLock(r.Context(), acct, app, "deleted", true, time.Now()); ref != nil {
380 switch ref.Kind {
381 case lockedControlPlane:
382 fail(ref.Msg)
383 case lockedGone:
384 // Existing copy: purgeAdvice's "not in the trash ... may have been
385 // restored, or already deleted".
386 fail(purgeAdvice(release.ErrNotTrashed, app))
387 default:
388 // lockedWrongState carries ErrNotTrashed; lockedUnreadable carries
389 // the read error, which purgeAdvice answers with its existing
390 // "still in the trash, try again" sentence.
391 fail(purgeAdvice(ref.Err, app))
392 }
393 return
394 }
395 // PurgeApp's OWN preconditions, asked BEFORE anything is destroyed. The
396 // teardown below is irreversible and reaches outside the deployment bucket
397 // — a customer's objects, their IAM principal, the credentials their live
398 // hosts are holding — so it must not run in front of a purge that PurgeApp
399 // would then refuse: an app restored while this form sat open, a production
400 // flag set after trashing, an unreadable flag, a console with no release
401 // key. Without this, every one of those refusals would arrive AFTER the
402 // store was destroyed, on an app that is still alive and (purge deliberately
403 // not bumping the serial) whose boxes would keep serving dead credentials
404 // until the hourly floor.
405 //
406 // One list, not two: PurgeReady is the block PurgeApp itself runs, so the
407 // two cannot drift. PurgeApp re-runs it immediately before destroying
408 // anything, which keeps the residual race to the one round trip between
409 // here and there — the design's stated tolerance.
410 if err := release.PurgeReady(r.Context(), s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app); err != nil {
411 log.Printf("console: purge preflight %s/%s: %v", acct.Sqid, app, err)
412 fail(purgeAdvice(err, app))
413 return
414 }
415 if s.cfg.HostClaim {
416 if s.cfg.ClaimPurge == nil {
417 s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrContract))
418 return
419 }
420 id, err := s.cfg.ClaimPurge.Request(r.Context(), acct.Sqid, app,
421 s.cfg.ClaimPurge.Controller.NewID(), "owner", addr)
422 if err != nil && !errors.Is(err, hostops.ErrPending) {
423 s.renderDoorRefusal(w, r, claimResponseRefusal(err))
424 return
425 }
426 if id == "" {
427 s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrPending))
428 return
429 }
430 journal, _, readErr := hostops.ReadPurge(r.Context(), s.cfg.Releases, acct.Sqid, app)
431 if readErr == nil && journal.ID == id {
432 s.renderClaimAccepted(w, r, app, id, journal.Phase)
433 return
434 }
435 if errors.Is(readErr, os.ErrNotExist) {
436 // Only this call's successful final completion reaches this branch.
437 if err == nil {
438 s.renderMessage(w, r, http.StatusOK, "Operation complete", "The app was permanently deleted.")
439 return
440 }
441 }
442 s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrContract))
443 return
444 }
445
446 // R5 (review follow-up): this refusal — "there is an email estate but
447 // no MailProvisioner to tear it down with" — is read and checked BEFORE
448 // the store teardown below, not after. It used to sit next to the
449 // mail.Teardown call further down, which is AFTER objstore.Teardown
450 // already destroys the object store — so a purge that was always going
451 // to be refused destroyed the store first. Hoisted here, matching how
452 // appsettings.go's store-destroy door already orders its own
453 // backend-missing checks before either destructive call. mrec and
454 // mailEstate are reused below, at the actual mail.Teardown call, so the
455 // two can never disagree about what estate exists.
456 mrec, _, mrerr := mail.Get(r.Context(), s.cfg.Releases, acct.Sqid, app)
457 if mrerr != nil {
458 log.Printf("console: purge mail read %s/%s: %v", acct.Sqid, app, mrerr)
459 fail("Could not read “" + app + "”'s email record — nothing was deleted; try again.")
460 return
461 }
462 mailEstate := mrec != nil && (mrec.Provisioned != nil || len(mrec.Standalone) > 0)
463 if !mailEstate {
464 // A domain claim can exist with no delivered credential at all —
465 // `carlos email domains add` alone, never followed by `enable` —
466 // and mail.Teardown releases that claim too. The audit trail
467 // below should say so.
468 if doms, derr := s.mailAppDomains(r.Context(), acct, app); derr == nil && len(doms) > 0 {
469 mailEstate = true
470 }
471 }
472 if mailEstate && s.cfg.MailProvisioner == nil {
473 fail("“" + app + "” has a provisioned email credential, and this console has no SES backend configured — it cannot be deleted here until email is torn down.")
474 return
475 }
476 // EVERY store this app has is torn down BEFORE the prefix sweep — this
477 // door is the only purge path with backend authority (objstore spec §9;
478 // box-side auto-purge refuses via release.ErrStoreProvisioned). Failure
479 // aborts the purge with the records intact: re-entering resumes.
480 if msg := s.purgeAppStores(r.Context(), acct, app, addr); msg != "" {
481 fail(msg)
482 return
483 }
484 // (purgeAppStores is below.)
485 //
486 // An email estate is torn down BEFORE the prefix sweep too, for the
487 // identical reason the store is above (I2, review follow-up):
488 // mail.Teardown is the ONLY place that releases this app's domain
489 // claims — the create-only global gate at
490 // email/domain-claims/<domain>.json (spec §4a) — and release.PurgeApp
491 // below deletes the sender record (email/senders/app.json, plus a
492 // legacy email.json if one is still there) out from under it. Without
493 // this, a purged app leaves a live IAM user with an active key, its
494 // SES identity, its published DNS, and — worst — a domain claim that
495 // DomainRecord.Apps can never again name this (now-deleted) app to
496 // release: the domain is denied to every CARLOS account, including
497 // its rightful owner, permanently.
498 //
499 // mail.Teardown is nil-record-safe (it sweeps this account's domain
500 // records and this app's ORPHANED claims even when the app never
501 // declared email at all — its own doc comment), so it runs whenever a
502 // MailProvisioner is configured, not only when a Provisioned/Standalone
503 // estate is already known to exist. mrec and mailEstate were already
504 // computed and refused on above (R5), before the store teardown ran.
505 if s.cfg.MailProvisioner != nil {
506 mctx, mcancel := context.WithTimeout(r.Context(), storeTeardownTimeout)
507 defer mcancel()
508 mdep, mwriter := s.mailDeploymentFor()
509 if terr := mail.Teardown(mctx, s.cfg.Releases, s.cfg.MailProvisioner, mwriter, mdep, acct.Sqid, app); terr != nil {
510 log.Printf("console: purge mail teardown %s/%s: %v", acct.Sqid, app, terr)
511 fail("Could not tear down “" + app + "”'s email estate — nothing was deleted; try again.")
512 return
513 }
514 if mailEstate {
515 s.audit(acct, app, addr, "email-teardown", "tore down the email estate (credentials, IAM principal, domain claims) during permanent deletion")
516 }
517 }
518 if err := release.PurgeApp(r.Context(), s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, time.Now()); err != nil {
519 log.Printf("console: purge %s/%s: %v", acct.Sqid, app, err)
520 fail(purgeAdvice(err, app))
521 return
522 }
523 // The relay's authority record is DERIVED from the relay.json objects
524 // under apps/<sqid>/<app>/, and the sweep above just deleted this app's.
525 // Republish now so the app is out of every relay's authority at once
526 // (relay spec §5, "app deletion") rather than keeping its credentials
527 // valid until the five-minute reconcile. Only the lease holder may
528 // publish, and a failure here is a log line, not a failed purge: the
529 // bucket is already swept, and the next holder's unconditional rebuild —
530 // which lists records rather than hunting for unfinished ones — is
531 // exactly the repair for this case.
532 if s.relayHold() {
533 if err := s.relayPublishAggregate(r.Context()); err != nil {
534 log.Printf("console: purge %s/%s: relay aggregate (the reconcile republishes within five minutes): %v",
535 acct.Sqid, app, err)
536 }
537 }
538 // No bumpSerial here, deliberately: PurgeApp does delete box-parsed
539 // shapes (instance records, config objects) under apps/<sqid>/<app>/,
540 // but the fleet-visible teardown already happened, and was already
541 // announced, at TRASH time (domains/reconcile.go leg 1c,
542 // provision/reconcile.go's trashedFor bumped then). Once the trash
543 // marker itself is gone, a bump here buys nothing — reconcile's
544 // trashed-set no longer contains this app to converge on either way.
545 // The row LAST, mirroring the bucket-first rule the rest of this feature
546 // keeps: the row is the name reservation, and freeing a name while objects
547 // survived would let the next claim ship into another app's prefix.
548 if err := s.store.PurgeAppRow(acct.ID, app); err != nil {
549 log.Printf("console: purge row %s/%s: %v", acct.Sqid, app, err)
550 fail("“" + app + "” was deleted from the deployment bucket, but this console's own record " +
551 "did not update — it will catch up on the next restart.")
552 return
553 }
554 // PurgeAppRow revoked the app's door keys; tell the boxes now.
555 s.republishDoorKeys(r, acct, app)
556 s.audit(acct, app, addr, "purge", "permanently deleted "+app)
557 s.setFlash(w, "ok", "Deleted “"+app+"” permanently. The name is free to claim again.")
558 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/trash", http.StatusSeeOther)
559}
560
561// trashFailure is the shared refusal for both trash actions: a flash and a
562// redirect back to the Trash page. It is not a 404 — every caller has already
563// passed the membership or ownership gate, so the person reading it is
564// entitled to know what went wrong.
565func (s *Server) trashFailure(w http.ResponseWriter, r *http.Request, acct *Account) func(string) {
566 return func(msg string) {
567 s.setFlash(w, "err", msg)
568 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/trash", http.StatusSeeOther)
569 }
570}
571
572// purgeAdvice turns release.PurgeApp's refusals into something the person who
573// pressed the button can act on. The production wording is deliberately the
574// same sentence the CLI's 409 gives (apicli.go), so an operator who meets both
575// doors hears one instruction rather than two.
576func purgeAdvice(err error, app string) string {
577 switch {
578 case errors.Is(err, release.ErrNotTrashed):
579 return "“" + app + "” is not in the trash, so nothing was deleted. It may have been " +
580 "restored, or already deleted, since this page was loaded."
581 case errors.Is(err, release.ErrStoreProvisioned):
582 // Belt and braces: handlePurgeApp tears a store down before it gets
583 // here, so reaching this means the record changed under the door — or
584 // that PurgeApp could not READ it. The guard wraps this sentinel for
585 // an unreadable or unparseable store.json as well as for a store that
586 // really is there (release/trash.go), so the sentence must not claim
587 // certainty it does not have: "has (or may have)" is the whole truth,
588 // and both ways out are named because only one of them will apply.
589 return "“" + app + "” has (or may have) a provisioned object store that this deletion could " +
590 "not verify as gone — tear it down on the app's Settings page, or try again once the " +
591 "store record is readable. Nothing was deleted."
592 case strings.Contains(err.Error(), "cannot read the production flag"):
593 return "Could not read the production setting for “" + app + "” — an unreadable flag is treated " +
594 "as production, so deleting is refused. Set it again with the production switch on the " +
595 "app page, then try again."
596 case strings.Contains(err.Error(), "flagged production"):
597 return "“" + app + "” is marked as production, so deleting is refused. Turn production off " +
598 "first — the off-switch on the app page (account owner, passkey, and a hold on every box) " +
599 "is the only way through — then try again."
600 case strings.Contains(err.Error(), "no release public key"):
601 return "This console has no release public key, so it cannot prove “" + app + "” is out of " +
602 "production. Nothing was deleted."
603 }
604 // Mid-purge failures land here. The marker is deleted LAST, so the app is
605 // still in the trash and a retry finishes the job — which is what this
606 // says, rather than claiming nothing happened.
607 return "Could not finish deleting “" + app + "”. It is still in the trash — try again once the " +
608 "deployment bucket is reachable."
609}
610
611// tombstoneAccount retires the account where identity actually lives. A
612// deployment with no release store configured cannot delete an account at all,
613// and says so: the alternative is a console-only deletion that undoes itself,
614// which is worse than a refusal because it looks like it worked.
615func (s *Server) tombstoneAccount(ctx context.Context, acct *Account) error {
616 if s.cfg.Releases == nil {
617 return errors.New("console: no deployment release store is configured on this box")
618 }
619 return domains.TombstoneAccount(ctx, s.cfg.Releases, acct.Sqid, time.Now())
620}
621
622// AppCount reports how many apps are homed in an account. Read-only pre-flight
623// for deletion; DeleteAccount re-checks it inside its own transaction, which is
624// the check that actually binds.
625//
626// Deliberately UNFILTERED by deleted_at, unlike AppsIn/HasAppIn: a trashed
627// app's payload — its objects, routes, everything that made it expensive in
628// the first place — still exists until purge, so account deletion must keep
629// refusing while trashed rows remain. Counting only live apps would let an
630// owner trash every app, delete the account out from under them, and strand
631// a name-reservation (and, before purge, real bucket state) with no account
632// left to hold it accountable.
633func (s *Store) AppCount(accountID int64) (int, error) {
634 var n int
635 err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ?`, accountID).Scan(&n)
636 return n, err
637}
638
639// removalAdvice / transferAdvice / deletionAdvice turn a store error into
640// something a human can act on. The store's own messages are written for a log;
641// these are written for the person who just pressed the button.
642func removalAdvice(err error, target string) string {
643 if strings.Contains(err.Error(), "last owner") {
644 return target + " is the last owner of this account — transfer ownership first."
645 }
646 return "Could not remove " + target + " from this account."
647}
648
649func transferAdvice(err error, target string) string {
650 if strings.Contains(err.Error(), "not a member") {
651 return target + " is not a member of this account. Invite them first, then transfer."
652 }
653 if strings.Contains(err.Error(), "already") {
654 return target + " already owns this account."
655 }
656 return "Could not transfer ownership to " + target + "."
657}
658
659func deletionAdvice(err error) string {
660 if strings.Contains(err.Error(), "still holds") {
661 return "This account still holds apps. Move or retire them first — deleting an account " +
662 "never deletes an app."
663 }
664 if strings.Contains(err.Error(), "ops") {
665 return "The ops account is the platform's own and cannot be deleted."
666 }
667 return "Could not delete this account."
668}
669
670// ---- store ------------------------------------------------------------------
671
672// TransferOwnership promotes `to` and demotes `from`, atomically. Both must
673// already be members: ownership is handed to someone who is here, never used as
674// a back door for adding a person. The order inside the transaction matters —
675// promote first, so lastOwnerGuard sees two owners when the demotion runs and
676// there is no window in which the account has none.
677func (s *Store) TransferOwnership(accountID int64, from, to string, now time.Time) error {
678 if strings.EqualFold(from, to) {
679 return fmt.Errorf("console: %s already owns this account", to)
680 }
681 tx, err := s.db.Begin()
682 if err != nil {
683 return err
684 }
685 defer tx.Rollback()
686 var role string
687 err = tx.QueryRow(`SELECT role FROM memberships WHERE account_id = ? AND address = ?`,
688 accountID, to).Scan(&role)
689 if err == sql.ErrNoRows {
690 return fmt.Errorf("console: %s is not a member of this account", to)
691 }
692 if err != nil {
693 return err
694 }
695 if _, err := tx.Exec(`UPDATE memberships SET role = ? WHERE account_id = ? AND address = ?`,
696 RoleOwner, accountID, to); err != nil {
697 return err
698 }
699 if err := lastOwnerGuard(tx, accountID, from); err != nil {
700 return err
701 }
702 if _, err := tx.Exec(`UPDATE memberships SET role = ? WHERE account_id = ? AND address = ?`,
703 RoleMember, accountID, from); err != nil {
704 return err
705 }
706 return tx.Commit()
707}
708
709// DeleteAccount retires an account that owns nothing. It refuses the ops
710// account (id 1 is the platform's own) and refuses while any app is still
711// homed here — an account row is cheap to delete and an app's objects, routes
712// and domains are not, so the two are never destroyed by one click.
713//
714// The accounts ROW is MARKED (deleted_at set), never removed — this is the
715// console's own path, and the identical resurrection hole the spec's §8
716// describes for materialization exists here too if the row simply vanishes:
717// account identity is minted into the bucket and re-read into this table on
718// every start (MaterializeAccounts), so a console that forgets a sqid was
719// ever seen would let anyone with bucket write access rewrite the manifest
720// un-tombstoned, name themselves owner, and get the account BACK on the next
721// restart — same id, same sqid, same platform hostnames. Keeping the marked
722// row is what lets AccountAnyBySqid answer "seen, and deleted" instead of
723// "never seen", which is the only way materialization's birth-only adoption
724// rule stays safe. An account id is spent once; there is no un-deleting one.
725//
726// Audit rows are deliberately left behind: they are the record of what was
727// done, and a deletion is not a reason to lose it.
728func (s *Store) DeleteAccount(accountID int64, now time.Time) error {
729 if accountID == OpsAccountID {
730 return fmt.Errorf("console: the ops account cannot be deleted")
731 }
732 tx, err := s.db.Begin()
733 if err != nil {
734 return err
735 }
736 defer tx.Rollback()
737 var apps int
738 if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ?`, accountID).Scan(&apps); err != nil {
739 return err
740 }
741 if apps > 0 {
742 return fmt.Errorf("console: this account still holds %d app(s)", apps)
743 }
744 for _, stmt := range []string{
745 `DELETE FROM memberships WHERE account_id = ?`,
746 `DELETE FROM invites WHERE account_id = ?`,
747 } {
748 if _, err := tx.Exec(stmt, accountID); err != nil {
749 return err
750 }
751 }
752 if _, err := tx.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, now.Unix(), accountID); err != nil {
753 return err
754 }
755 // Anyone whose landing spot was this account gets sent back to the picker
756 // rather than to a 404 they cannot explain.
757 if _, err := tx.Exec(`UPDATE users SET last_account = 0 WHERE last_account = ?`, accountID); err != nil {
758 return err
759 }
760 return tx.Commit()
761}
762
763// purgeAppStores tears down every store this app has — its own and one per
764// config environment — and returns the flash to show on failure, "" on
765// success.
766//
767// Since 2026-09-19 an app may have more than one store, and release.PurgeApp
768// refuses the box-side auto-purge while ANY of them holds a grant or a
769// provisioned block. So a purge that tore down only the app's own store would
770// not leave a partial cleanup behind: it would leave an app that can never be
771// purged at all, by this door or any other.
772//
773// One lock per store, held across that store's read AND its walk — the estate
774// check is made from the record, and a grant landing in between would leave
775// the sweep deleting a prefix whose bucket and IAM user were just re-created
776// (objstore/walk.go's window, from the other end). Taken in turn rather than
777// all at once: two stores of one app share no estate, so there is nothing to
778// serialise between them, and holding several locks to do nothing with them
779// is how a deadlock gets written later.
780func (s *Server) purgeAppStores(ctx context.Context, acct *Account, app, addr string) string {
781 envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app)
782 if err != nil {
783 // Refusing on an unreadable listing is the same direction as
784 // refusing on an unreadable record: "I could not ask" is not "no
785 // store", and the sweep below is irreversible.
786 log.Printf("console: purge store list %s/%s: %v", acct.Sqid, app, err)
787 return "Could not list “" + app + "”'s object stores — nothing was deleted; try again."
788 }
789 // The app's own store LAST. It is the one whose grant block the purge
790 // guard has always keyed on, so a run that stops part way leaves a state
791 // that still reads as "this app has a store" — which is true, and which
792 // re-entering resumes.
793 for _, env := range append(envs, "") {
794 if msg := s.purgeOneStore(ctx, acct, app, env, addr); msg != "" {
795 return msg
796 }
797 }
798 return ""
799}
800
801// purgeOneStore is purgeAppStores' body for a single store. Guard on
802// Provisioned OR Grant (controller ruling, Task 5 review): a grant block
803// without a provisioned block is a crashed grant whose backend estate is
804// real.
805func (s *Server) purgeOneStore(ctx context.Context, acct *Account, app, env, addr string) string {
806 defer s.storeWalks.lock(acct.Sqid, app, env)()
807 rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env)
808 if rerr != nil {
809 log.Printf("console: purge store read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), rerr)
810 return "Could not read “" + app + "”'s object-store record — nothing was deleted; try again."
811 }
812 if rec == nil || (rec.Provisioned == nil && rec.Grant == nil) {
813 return ""
814 }
815 if s.cfg.StoreProvisioner == nil {
816 return "“" + app + "” has a provisioned object store, and this console has no storage backend configured (CARLOS_STORE_BACKEND) — it cannot be deleted here until the store is torn down."
817 }
818 // The walk gets its own ceiling rather than the request's open-ended
819 // context: a teardown is many round trips (EmptyBucket is paginated), and
820 // it must not be able to hold this handler open indefinitely.
821 tctx, cancel := context.WithTimeout(ctx, storeTeardownTimeout)
822 defer cancel()
823 if terr := objstore.TeardownIn(tctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, acct.Sqid, app, env); terr != nil {
824 log.Printf("console: purge store teardown %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), terr)
825 return "Could not tear down “" + app + "”'s object store — nothing was deleted; try again."
826 }
827 // rec.Provisioned may be nil here (crashed-grant shape) — name the bucket
828 // from Grant, which the guard above proves non-nil when Provisioned is.
829 bucket := ""
830 if rec.Provisioned != nil {
831 bucket = rec.Provisioned.Bucket
832 } else if rec.Grant != nil {
833 bucket = rec.Grant.Bucket
834 }
835 s.audit(acct, app, addr, "store-destroy",
836 "tore down the object store (bucket "+bucket+")"+storeEnvSuffix(env)+" during permanent deletion")
837 return ""
838}
839