| 1 | package 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 | |
| 13 | import ( |
| 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. |
| 37 | func (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. |
| 62 | func (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). |
| 95 | func (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. |
| 188 | func (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. |
| 228 | func (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. |
| 338 | func (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. |
| 565 | func (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. |
| 576 | func 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. |
| 615 | func (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. |
| 633 | func (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. |
| 642 | func 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 | |
| 649 | func 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 | |
| 659 | func 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. |
| 677 | func (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. |
| 728 | func (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. |
| 780 | func (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. |
| 805 | func (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 | |