| 1 | // CLI identity, HTTP half: the endpoints `carlos auth login` speaks to, and |
| 2 | // the two pages a human uses — approve a terminal, and see (or kill) every |
| 3 | // terminal that speaks as them. |
| 4 | // |
| 5 | // WHY THE CONSOLE AND NOT A SECOND KEYMAIL CLIENT. The console is already a |
| 6 | // registered Keymail OAuth client and already the deployment's identity |
| 7 | // authority: it knows who you are, which accounts you belong to, and what |
| 8 | // role you hold in each. Minting a SECOND OAuth client for the CLI would mean |
| 9 | // a second consent screen, a second grant to revoke, a second client_id to |
| 10 | // register per self-hosted deployment — and a CLI that trusts keymail.dev |
| 11 | // directly rather than trusting the console the operator actually runs. So |
| 12 | // the CLI authenticates against the deployment's own console, and the console |
| 13 | // authenticates the human with the Keymail sign-in it already has. One OAuth |
| 14 | // client, one place to revoke, and a self-hoster's CLI trusts their own box. |
| 15 | // |
| 16 | // WHY A TYPED CODE AND NOT A LOCALHOST CALLBACK. The terminal that needs a |
| 17 | // credential is very often an SSH session on a box, a container, or a CI |
| 18 | // shell. A browser-redirect flow assumes the browser can reach a listener on |
| 19 | // the machine running the CLI, which in all three cases it cannot. The typed |
| 20 | // code crosses that gap by hand: it is short enough to retype from a phone, |
| 21 | // and the credential is delivered back over the CLI's own polling channel. |
| 22 | package console |
| 23 | |
| 24 | import ( |
| 25 | "context" |
| 26 | "crypto/subtle" |
| 27 | "encoding/json" |
| 28 | "errors" |
| 29 | "fmt" |
| 30 | "log" |
| 31 | "net" |
| 32 | "net/http" |
| 33 | "net/url" |
| 34 | "strings" |
| 35 | "sync" |
| 36 | "time" |
| 37 | |
| 38 | "github.com/carlosframework/platform/internal/domains" |
| 39 | "github.com/carlosframework/platform/internal/svcrole" |
| 40 | ) |
| 41 | |
| 42 | // Rate limits. Deliberately generous for humans and tight for scripts: the |
| 43 | // numbers only have to make brute force and database-stuffing pointless, not |
| 44 | // police normal use. |
| 45 | const ( |
| 46 | cliRateWindow = time.Minute |
| 47 | // cliStartBurst bounds unauthenticated login starts per client IP. Each |
| 48 | // start writes a row, so this is the anti-stuffing limit. |
| 49 | cliStartBurst = 10 |
| 50 | // cliPollBurst is per device code — a polite CLI polls every 2s, so 60 a |
| 51 | // minute is twice what the honest client needs and nowhere near a spin. |
| 52 | // It is NOT a defence on its own: the device code is chosen by the |
| 53 | // caller, so a flood simply presents a fresh key every time. cliPollIPBurst |
| 54 | // is the limit that actually bounds a flood. |
| 55 | cliPollBurst = 60 |
| 56 | // cliPollIPBurst bounds polling per client IP, whatever device codes it |
| 57 | // claims to hold. Roomy enough for a shared NAT with a dozen developers |
| 58 | // mid-login at once. |
| 59 | cliPollIPBurst = 240 |
| 60 | // cliGuessBurst bounds WRONG typed codes, counted against the signed-in |
| 61 | // address and against the client IP. The user code is ~39 bits with a |
| 62 | // ten-minute life; at 10 guesses a minute it is not findable in that |
| 63 | // window by any margin that matters. |
| 64 | cliGuessBurst = 10 |
| 65 | // cliWriteBurst bounds authenticated mutations per address. |
| 66 | cliWriteBurst = 30 |
| 67 | // cliPollInterval is the wait the console asks the CLI to keep, seconds. |
| 68 | cliPollInterval = 2 |
| 69 | |
| 70 | // cliShipWindow is the minute window every other CLI limiter uses. It |
| 71 | // was an HOUR until 2026-08-23, on the premise that "a ship is a |
| 72 | // deliberate release cut, not a poll" — a premise a fixed-window |
| 73 | // counter turns into the worst refusal on this surface. Spend the hour |
| 74 | // budget in five minutes and the token is dead for the remaining |
| 75 | // fifty-five, with nothing in the answer to say so; the caller that |
| 76 | // actually does this is an agent iterating ship-test-ship, and what it |
| 77 | // learns is that shipping stopped working. A minute window bounds the |
| 78 | // same burst (see cliShipBurst) and bounds the WAIT too: the worst a |
| 79 | // refused caller can be asked for is one minute, which is a sleep, not |
| 80 | // an outage. |
| 81 | cliShipWindow = time.Minute |
| 82 | // cliShipBurst bounds ship uploads per TOKEN (see handleAPIShip) within |
| 83 | // that minute. Two, deliberately: a build-and-ship takes longer than |
| 84 | // half a minute to produce anything worth shipping, so two a minute is |
| 85 | // past what an honest iterating caller can generate — while a stolen |
| 86 | // token bursting flat out is held to a TIGHTER instantaneous rate than |
| 87 | // the old ten-in-an-hour allowed (which permitted ten back to back). |
| 88 | // The hourly ceiling rises 10 -> 120; the release retention sweep, not |
| 89 | // this counter, is what bounds what those 120 cost in storage. |
| 90 | cliShipBurst = 2 |
| 91 | |
| 92 | // cliInstanceBurst bounds the instance verbs (apiinstances.go) per |
| 93 | // TOKEN within the ordinary minute window. Keyed by token hash, not by |
| 94 | // address, because the caller here is usually not a person at all: an |
| 95 | // app token holds no identity to charge a person's budget against, and |
| 96 | // a burst of signups — several instances created in one minute — is the |
| 97 | // legitimate workload this API exists for. Sharing the address-keyed |
| 98 | // write budget would couple the two in both directions: a stolen user |
| 99 | // token's noise would starve the app's own signups, and a busy signup |
| 100 | // hour would refuse its minter's promotes. |
| 101 | cliInstanceBurst = 60 |
| 102 | // cliRunsBurst is the follow routes' per-token budget per window: a |
| 103 | // once-a-second poll, its acks and cancels, with room. |
| 104 | cliRunsBurst = 600 |
| 105 | |
| 106 | // cliCreateWindow is app creation's own budget window — a DAY, the |
| 107 | // longest here, because claiming an app name is the rarest deliberate |
| 108 | // act on this surface and the one whose spam is permanent: a claimed |
| 109 | // name is a row an operator has to clean up, not a pointer the next |
| 110 | // promote overwrites. The number is a ceiling on a stolen token (ten |
| 111 | // inert names a day, per address), not a policing of humans — the |
| 112 | // browser's New app form has no limit and is the escape hatch |
| 113 | // (2026-08-04-cli-app-create-design.md §3). |
| 114 | cliCreateWindow = 24 * time.Hour |
| 115 | // cliCreateBurst bounds app-claim ATTEMPTS per address within that day |
| 116 | // — attempts, not successes, or refused probes would be free. One caveat |
| 117 | // the limiter's maxRateKeys note doesn't cover: create's keys are real |
| 118 | // member addresses accumulating for a whole day, not attacker-minted |
| 119 | // strings, so a deployment with >8192 members claiming apps in one day |
| 120 | // would trip the wholesale map drop and reset every budget. Orders of |
| 121 | // magnitude beyond this fleet; recorded, not defended. |
| 122 | cliCreateBurst = 10 |
| 123 | |
| 124 | // cliTemporaryCreateBurst bounds temporary-app claim ATTEMPTS per |
| 125 | // principal per day (spec ruling 8, Paul 2026-09-26). Its own budget, |
| 126 | // not cliCreateBurst's: the scratchpad use is an agent in a loop, and |
| 127 | // ten a day would refuse it by lunchtime. The live cap (20) bounds |
| 128 | // storage while apps live; this bounds churn, since a trashed |
| 129 | // temporary app still holds storage for its 3-day window, so the |
| 130 | // worst case is about 150 trashed-but-unpurged apps per principal. |
| 131 | cliTemporaryCreateBurst = 50 |
| 132 | |
| 133 | // Service-principal budgets (Task 5, issue #220 consequence 1). A hub is |
| 134 | // not a person, and each number below is its own judgement calibrated |
| 135 | // for what a legitimate AUTOMATED caller does — not a derivation from |
| 136 | // the person numbers above, and not one shared story: read each |
| 137 | // comment on its own rather than assuming the others generalize it. |
| 138 | // |
| 139 | // All three are keyed on the principal NAME (principalKey), not the |
| 140 | // token hash: rotating a credential must not hand it a fresh budget, or |
| 141 | // a rotation mid-flood would be the limit doing nothing. |
| 142 | |
| 143 | // svcCreateBurst: 100 repos enabling Pages (or otherwise claiming an |
| 144 | // app) in one day — ten times cliCreateBurst's per-person ceiling, |
| 145 | // because one hub credential legitimately claims on behalf of many |
| 146 | // repos where one person claims only for themselves. |
| 147 | svcCreateBurst = 100 // per day, the cliCreateWindow |
| 148 | // svcWriteBurst: a mutation roughly every half second sustained for a |
| 149 | // full minute — four times cliWriteBurst's per-person ceiling, roomy |
| 150 | // for a CI fleet's shared credential running many promotes, rollbacks |
| 151 | // and restarts back to back. |
| 152 | svcWriteBurst = 120 // per minute, the cliRateWindow |
| 153 | // svcShipBurst: ten ships a minute sustained — five times |
| 154 | // cliShipBurst's per-token ceiling, for a busy CI fleet cutting many |
| 155 | // releases at once rather than one human's occasional deploy. It kept |
| 156 | // its multiple over the person budget when cliShipWindow went from an |
| 157 | // hour to a minute; the number moved (60 -> 10) only because the window |
| 158 | // under it did. |
| 159 | svcShipBurst = 10 // per minute, the cliShipWindow |
| 160 | |
| 161 | // wildFailBurst bounds FAILED wildcard enables per principal per |
| 162 | // minute — PEEKED before the DNS round trip and CONSUMED only after it |
| 163 | // fails (rateLimiter.tripped / .allow, C33 review), so a caller whose |
| 164 | // CNAME is correct never accumulates a failure and never meets it, |
| 165 | // while a retrying caller stops costing the console a live outbound |
| 166 | // DNS lookup once it has tripped. The successful path is exempt |
| 167 | // entirely (spec §4a): with wildcard DNS a Pages hub makes no per-repo |
| 168 | // hostname call at all, and an enable is genuinely once-per-domain. |
| 169 | // Roomy enough for a resolver blip, closed enough that an exempt door |
| 170 | // is not a way to make the console do unbounded outbound DNS. |
| 171 | wildFailBurst = 20 |
| 172 | |
| 173 | // domainsCeilingBurst is NOT a workload budget — attach/detach are |
| 174 | // exempt from the write/instance budgets by design (spec §4a: a |
| 175 | // wildcard hub makes near-zero per-repo calls, so there is no |
| 176 | // legitimate workload to bound). It is a CONVERGENCE-RATE governor |
| 177 | // instead: every EFFECTIVE attach or detach audits and bumps |
| 178 | // control/serial, and boxes poll that serial at 2s and run a full pass |
| 179 | // on every move, so an unlimited exempt door would let one |
| 180 | // authenticated credential drive fleet-wide convergence at request |
| 181 | // rate — an alternating attach/detach loop on one host, for instance. |
| 182 | // 180/min is deliberately clear of svcWriteBurst (120) rather than |
| 183 | // merely above it: the two used to sit at the same number, which meant |
| 184 | // no test volume could land strictly between them, so a regression that |
| 185 | // put the write budget back onto this door (re-adding allowWrite to |
| 186 | // domainsGate) would refuse at the SAME threshold this ceiling does — |
| 187 | // nothing would fail. A real gap between the two numbers is what makes |
| 188 | // TestWildcardEnableIsNotRateLimited able to pick a volume that proves |
| 189 | // the write-budget exemption specifically, not just "some limiter or |
| 190 | // other let this through." |
| 191 | domainsCeilingBurst = 180 // per minute, the cliRateWindow, keyed on the principal |
| 192 | ) |
| 193 | |
| 194 | // rateLimiter is a fixed-window counter, keyed by whatever the caller thinks |
| 195 | // identifies a client. In-memory and per-process on purpose: the console is |
| 196 | // one process on one box, and reaching for a shared store here would add a |
| 197 | // dependency to buy nothing. |
| 198 | type rateLimiter struct { |
| 199 | mu sync.Mutex |
| 200 | max int |
| 201 | window time.Duration |
| 202 | lastSweep time.Time |
| 203 | hits map[string]*rateWindow |
| 204 | } |
| 205 | |
| 206 | type rateWindow struct { |
| 207 | start time.Time |
| 208 | n int |
| 209 | } |
| 210 | |
| 211 | // maxRateKeys hard-bounds a limiter's map. Some limiters are keyed on |
| 212 | // caller-chosen values (a device code), so "one entry per distinct key" is a |
| 213 | // memory budget an attacker writes — this is where that stops. |
| 214 | const maxRateKeys = 8192 |
| 215 | |
| 216 | func newRateLimiter(max int, window time.Duration) *rateLimiter { |
| 217 | return &rateLimiter{max: max, window: window, hits: map[string]*rateWindow{}} |
| 218 | } |
| 219 | |
| 220 | // allow records one hit and reports whether it is within the window's budget. |
| 221 | // |
| 222 | // The sweep runs at most once per window, NEVER per request: an O(n) walk on |
| 223 | // every call — while holding this mutex, in front of a database limited to one |
| 224 | // writer that every console page also queues behind — turns a flood on one |
| 225 | // endpoint into a stall on the whole console. Over the hard bound the map is |
| 226 | // dropped wholesale rather than walked, because under a flood of made-up keys |
| 227 | // there is nothing to distinguish worth keeping, and forgetting counters is a |
| 228 | // far smaller failure than freezing the site. |
| 229 | func (l *rateLimiter) allow(key string, now time.Time) bool { |
| 230 | l.mu.Lock() |
| 231 | defer l.mu.Unlock() |
| 232 | if now.Sub(l.lastSweep) >= l.window { |
| 233 | for k, w := range l.hits { |
| 234 | if now.Sub(w.start) > l.window { |
| 235 | delete(l.hits, k) |
| 236 | } |
| 237 | } |
| 238 | l.lastSweep = now |
| 239 | } |
| 240 | if len(l.hits) > maxRateKeys { |
| 241 | l.hits = make(map[string]*rateWindow, 64) |
| 242 | l.lastSweep = now |
| 243 | } |
| 244 | w := l.hits[key] |
| 245 | if w == nil || now.Sub(w.start) > l.window { |
| 246 | l.hits[key] = &rateWindow{start: now, n: 1} |
| 247 | return true |
| 248 | } |
| 249 | w.n++ |
| 250 | return w.n <= l.max |
| 251 | } |
| 252 | |
| 253 | // tripped is allow's READ-ONLY half: it reports whether key is ALREADY over |
| 254 | // budget, in the current window, without recording a hit. It exists for a |
| 255 | // caller that wants to skip an expensive operation ENTIRELY once the budget |
| 256 | // is spent, rather than pay for the operation and only then be refused |
| 257 | // (Task 5 review, C33) — a live outbound DNS round trip is the case that |
| 258 | // motivated it, but the shape is general. |
| 259 | // |
| 260 | // Same sweep, same reason as allow: an expired window must read as fresh |
| 261 | // here too, or a peek and the allow that follows it a moment later could |
| 262 | // disagree about whether the caller is still bounded. It does NOT create an |
| 263 | // entry for a key it has never seen and does NOT run allow's over-the-hard- |
| 264 | // bound wholesale drop — a peek must never be the thing that evicts every |
| 265 | // OTHER key's count, which a bare read has no business doing. |
| 266 | func (l *rateLimiter) tripped(key string, now time.Time) bool { |
| 267 | l.mu.Lock() |
| 268 | defer l.mu.Unlock() |
| 269 | if now.Sub(l.lastSweep) >= l.window { |
| 270 | for k, w := range l.hits { |
| 271 | if now.Sub(w.start) > l.window { |
| 272 | delete(l.hits, k) |
| 273 | } |
| 274 | } |
| 275 | l.lastSweep = now |
| 276 | } |
| 277 | w := l.hits[key] |
| 278 | if w == nil || now.Sub(w.start) > l.window { |
| 279 | return false |
| 280 | } |
| 281 | return w.n >= l.max |
| 282 | } |
| 283 | |
| 284 | // resetIn reports how much of key's CURRENT window is left, so a refusal can |
| 285 | // say WHEN instead of "wait a while". It is meaningful only immediately |
| 286 | // after allow returned false for the same key: a key with no window, or one |
| 287 | // whose window has already run out, has nothing to wait for and answers 0. |
| 288 | // |
| 289 | // Read-only like tripped, and — unlike both allow and tripped — it does NOT |
| 290 | // sweep. The caller it exists for has just been through allow, which swept a |
| 291 | // moment ago; a second walk of the map under the same mutex, on the path |
| 292 | // that is by definition being hit hardest, is the stall allow's own comment |
| 293 | // is at pains to avoid. |
| 294 | // |
| 295 | // Rounded UP to a whole second, and never below one: Retry-After's unit is |
| 296 | // seconds, and a truthful "0" or "0.4" is an invitation to retry straight |
| 297 | // back into the same refusal. |
| 298 | func (l *rateLimiter) resetIn(key string, now time.Time) time.Duration { |
| 299 | l.mu.Lock() |
| 300 | defer l.mu.Unlock() |
| 301 | w := l.hits[key] |
| 302 | if w == nil { |
| 303 | return 0 |
| 304 | } |
| 305 | left := w.start.Add(l.window).Sub(now) |
| 306 | if left <= 0 { |
| 307 | return 0 |
| 308 | } |
| 309 | secs := left / time.Second |
| 310 | if left%time.Second != 0 { |
| 311 | secs++ |
| 312 | } |
| 313 | return secs * time.Second |
| 314 | } |
| 315 | |
| 316 | // retryAfter stamps the one part of a 429 a caller can act on without |
| 317 | // reading prose. The message beside it says the same thing in words for a |
| 318 | // human; this is for the script, the CI step and the agent, all of which |
| 319 | // otherwise have to choose between retrying immediately (into the same |
| 320 | // refusal) and giving up. Called BEFORE jsonError, which writes the status |
| 321 | // line and so closes the header map. |
| 322 | // |
| 323 | // A zero or negative wait is stamped as one second rather than omitted: the |
| 324 | // caller reaching this has been refused, and "retry whenever" is the answer |
| 325 | // that produced the spin. |
| 326 | func retryAfter(w http.ResponseWriter, d time.Duration) { |
| 327 | if d < time.Second { |
| 328 | d = time.Second |
| 329 | } |
| 330 | w.Header().Set("Retry-After", fmt.Sprintf("%d", d/time.Second)) |
| 331 | } |
| 332 | |
| 333 | // cliLimits is the set of limiters the CLI endpoints share, hung off the |
| 334 | // Server so a test gets a fresh set per fixture. |
| 335 | type cliLimits struct { |
| 336 | start, poll, pollIP, guessAddr, guessIP, write, ship, instances, create *rateLimiter |
| 337 | // db is the `carlos db` door's own budget, keyed by token (cliDBBurst, |
| 338 | // apidb.go): a restore is dozens of reads, a different shape from the |
| 339 | // instance verbs. |
| 340 | db *rateLimiter |
| 341 | // runs is the follow routes' budget (run spec §5.6): the CLI polls once |
| 342 | // a second, which the 60-a-minute instance window would refuse. The |
| 343 | // create door spends instances, as spec refusal 3 says. |
| 344 | runs *rateLimiter |
| 345 | // svcCreate, svcWrite and svcShip are the SERVICE side of create/write/ |
| 346 | // ship — separate counters, separate keys (principalKey's NAME, not the |
| 347 | // person's ADDRESS or the ship-person's token hash), so a credential's |
| 348 | // budget is never the operator's to spend (issue #220 consequence 1). |
| 349 | // allowCreate/allowWrite/allowShip (svcprincipal.go) pick between the |
| 350 | // person and the service limiter so no call site has to. |
| 351 | svcCreate, svcWrite, svcShip *rateLimiter |
| 352 | // tempCreate is /new-temporary's own budget (cliTemporaryCreateBurst), |
| 353 | // one limiter for both principal kinds, keyed like create by |
| 354 | // principalKey, separate from create and svcCreate so neither kind of |
| 355 | // create spends the other's day. |
| 356 | tempCreate *rateLimiter |
| 357 | // wildFail bounds only FAILED wildcard-enable attempts — see |
| 358 | // wildFailBurst. The domains doors are otherwise exempt from write/ |
| 359 | // instances entirely (spec §4a). |
| 360 | wildFail *rateLimiter |
| 361 | // domainsCeiling is the convergence-rate governor attach/detach keep |
| 362 | // even though they are exempt from write/instances — see |
| 363 | // domainsCeilingBurst. One limiter, one key shape (principalKey), |
| 364 | // regardless of person or service: the exemption never depended on |
| 365 | // which kind of caller it was, so neither does this ceiling. |
| 366 | domainsCeiling *rateLimiter |
| 367 | } |
| 368 | |
| 369 | func newCLILimits() *cliLimits { |
| 370 | return &cliLimits{ |
| 371 | start: newRateLimiter(cliStartBurst, cliRateWindow), |
| 372 | poll: newRateLimiter(cliPollBurst, cliRateWindow), |
| 373 | pollIP: newRateLimiter(cliPollIPBurst, cliRateWindow), |
| 374 | // Wrong codes are counted against the signed-in ADDRESS and the client |
| 375 | // IP, never the session token: a session is re-minted by signing in |
| 376 | // again, so a token-keyed counter is one the attacker resets at will. |
| 377 | guessAddr: newRateLimiter(cliGuessBurst, cliRateWindow), |
| 378 | guessIP: newRateLimiter(cliGuessBurst, cliRateWindow), |
| 379 | write: newRateLimiter(cliWriteBurst, cliRateWindow), |
| 380 | // Ship gets its own budget, on its own window, keyed by token rather |
| 381 | // than address (see handleAPIShip) — an upload is a different shape |
| 382 | // of cost (bandwidth, storage) from the JSON mutations write covers. |
| 383 | ship: newRateLimiter(cliShipBurst, cliShipWindow), |
| 384 | // The instance verbs' own budget, keyed by TOKEN (see |
| 385 | // cliInstanceBurst and apiinstances.go): the app token that does |
| 386 | // most of this work is not a person, so the address-keyed write |
| 387 | // budget is the wrong principal for it in both directions. |
| 388 | instances: newRateLimiter(cliInstanceBurst, cliRateWindow), |
| 389 | db: newRateLimiter(cliDBBurst, cliRateWindow), |
| 390 | runs: newRateLimiter(cliRunsBurst, cliRateWindow), |
| 391 | // Create gets its own budget on its own (day-long) window, keyed by |
| 392 | // address — the stolen-token ceiling handleAPICreateApp explains. |
| 393 | create: newRateLimiter(cliCreateBurst, cliCreateWindow), |
| 394 | // Temporary creates get their own budget on create's window (spec |
| 395 | // ruling 8) — see cliTemporaryCreateBurst. |
| 396 | tempCreate: newRateLimiter(cliTemporaryCreateBurst, cliCreateWindow), |
| 397 | |
| 398 | svcCreate: newRateLimiter(svcCreateBurst, cliCreateWindow), |
| 399 | svcWrite: newRateLimiter(svcWriteBurst, cliRateWindow), |
| 400 | svcShip: newRateLimiter(svcShipBurst, cliShipWindow), |
| 401 | wildFail: newRateLimiter(wildFailBurst, cliRateWindow), |
| 402 | |
| 403 | domainsCeiling: newRateLimiter(domainsCeilingBurst, cliRateWindow), |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | // cliRouteKey is the context key cliRoute stashes the resolved principal |
| 408 | // under. Unexported, and of an unexported type, so nothing outside this |
| 409 | // package can forge one onto a request. |
| 410 | type cliRouteKey struct{} |
| 411 | |
| 412 | // principalFrom reads the principal cliRoute resolved. ok is false only for a |
| 413 | // request that did not come through cliRoute — which, for anything under |
| 414 | // /api/cli/, TestEveryCLIDoorNamesItsRoles says cannot happen. |
| 415 | func principalFrom(ctx context.Context) (Principal, bool) { |
| 416 | p, ok := ctx.Value(cliRouteKey{}).(Principal) |
| 417 | return p, ok |
| 418 | } |
| 419 | |
| 420 | // cliRoute registers one CLI API door and is the ONLY way to register one. |
| 421 | // It resolves the bearer once, applies the role gate, and passes the |
| 422 | // principal down on the request context so the handler's own resolver does |
| 423 | // not repeat the lookup. |
| 424 | // |
| 425 | // THE ROLE GATE RUNS BEFORE THE HANDLER, AND THEREFORE BEFORE THE SCOPE |
| 426 | // CHECK — spec §3b. A role refusal reads neither the account segment nor the |
| 427 | // database: it is a constant function of the door and the caller's own role, |
| 428 | // identical whether the named account exists, belongs to someone else, or |
| 429 | // belongs to the caller. There is nothing in it to leak. Checking scope first |
| 430 | // would instead make the pair of answers a membership oracle — 404 for an |
| 431 | // account you do not hold, 403 for one you do. Role-first is also the only |
| 432 | // order that can be enforced structurally, out here, rather than asked of |
| 433 | // every handler. |
| 434 | // |
| 435 | // A PERSON never meets the gate. Their authority is their membership set, |
| 436 | // evaluated per request by the handler, and no role name resolves to it. |
| 437 | // |
| 438 | // allow is never optional and the zero Set permits nothing — not even admin — |
| 439 | // so a door added without naming its roles is unreachable by every service |
| 440 | // principal rather than open to all of them. |
| 441 | func (s *Server) cliRoute(pattern string, allow svcrole.Set, h http.HandlerFunc) { |
| 442 | if s.cliRoles == nil { |
| 443 | s.cliRoles = map[string]svcrole.Set{} |
| 444 | } |
| 445 | s.cliRoles[pattern] = allow |
| 446 | s.registerRoute(pattern, func(w http.ResponseWriter, r *http.Request) { |
| 447 | p, ok := s.store.CLIPrincipal(bearer(r), time.Now()) |
| 448 | if !ok { |
| 449 | // The same 401, with the same body, cliUser has always given: |
| 450 | // an expired token, a revoked one and a fabricated one are not |
| 451 | // distinguishable from outside. |
| 452 | s.cliNotSignedIn(w) |
| 453 | return |
| 454 | } |
| 455 | if p.Kind == PrincipalService && !allow.Permits(p.Role) { |
| 456 | if allow.Empty() { |
| 457 | // A closed door has no role that would work, so naming the |
| 458 | // caller's role and an empty list ("needs one of []") would |
| 459 | // read as a bug and send whoever hit it looking for the role |
| 460 | // that opens it. There is none: this is the escalation cut. |
| 461 | // Constant for every role and every account segment, which |
| 462 | // keeps it as leak-free as the refusal below. |
| 463 | jsonError(w, http.StatusForbidden, |
| 464 | "this door is closed to service credentials — no role reaches it; it needs a person") |
| 465 | return |
| 466 | } |
| 467 | jsonError(w, http.StatusForbidden, fmt.Sprintf( |
| 468 | "this credential holds role %q; this door needs one of %v", p.Role, allow.Roles())) |
| 469 | return |
| 470 | } |
| 471 | h(w, r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, p))) |
| 472 | }) |
| 473 | } |
| 474 | |
| 475 | // registerCLIRoutes wires the CLI-identity surface. Called from NewServer. |
| 476 | // |
| 477 | // The /auth/cli/ family and GET /settings below stay on plain |
| 478 | // mux.HandleFunc, deliberately: they are the OTHER half of default-deny. |
| 479 | // Every one of them that reads a bearer at all gates on cliUser, which |
| 480 | // answers only for a person, so a service credential meets there exactly the |
| 481 | // 401 a fabricated token meets. Pinned by TestAuthCLIFamilyResolvesOnlyPersons. |
| 482 | // |
| 483 | // Every /api/cli/ door goes through cliRoute and names its roles. Pinned by |
| 484 | // TestEveryCLIDoorNamesItsRoles. |
| 485 | func (s *Server) registerCLIRoutes() { |
| 486 | s.registerRoute("POST /auth/cli/start", s.handleCLIStart) |
| 487 | s.registerRoute("POST /auth/cli/poll", s.handleCLIPoll) |
| 488 | s.registerRoute("GET /auth/cli", s.handleCLIApprovePage) |
| 489 | s.registerRoute("POST /auth/cli/approve", s.handleCLIApprove) |
| 490 | s.registerRoute("GET /settings", s.handleCLITokens) |
| 491 | s.registerRoute("GET /auth/cli/tokens", func(w http.ResponseWriter, r *http.Request) { |
| 492 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 493 | }) |
| 494 | s.registerRoute("POST /auth/cli/tokens/revoke", s.handleCLITokenRevoke) |
| 495 | s.registerRoute("GET /auth/cli/whoami", s.handleCLIWhoami) |
| 496 | s.registerRoute("POST /auth/cli/revoke", s.handleCLIRevoke) |
| 497 | s.registerRoute("POST /auth/cli/accounts", s.handleCLINewAccount) |
| 498 | // The CLI's read-only API (apicli.go) — deliberately a different prefix |
| 499 | // (/api/cli/) from the identity routes above (/auth/cli/), which must not |
| 500 | // move now that CLIs depend on them. |
| 501 | s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIApps) |
| 502 | // The fleet-wide live tenancy read (discussion #11). An EMPTY role set: |
| 503 | // no service credential of any role reaches it, because the answer names |
| 504 | // every account's instances on every box, and that is an operator's view |
| 505 | // rather than any workload's. The operator check itself is inside the |
| 506 | // handler, since cliRoute's role set can only narrow SERVICE principals |
| 507 | // and a person always passes through it. |
| 508 | s.cliRoute("GET /api/cli/status/instances", svcrole.Set{}, s.handleAPIStatusInstances) |
| 509 | if s.cfg.HostClaim { |
| 510 | // Host loss inspection and submission are person-only and repeat the |
| 511 | // fleet-operator check in their handlers. Until the follow-on integration supplies the |
| 512 | // ActorDirectory both routes stop at the same inert refusal. |
| 513 | s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, s.handleAPIHostResolveInspect) |
| 514 | s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, s.handleAPIHostResolve) |
| 515 | // Box and group roles (region HA spec 2 §7, apiboxroles.go): |
| 516 | // operator-only, and registered only with CARLOS_REGION_HA on too, |
| 517 | // so with it off they answer as any unknown route, as on main. |
| 518 | if s.cfg.RegionHA { |
| 519 | s.cliRoute("POST /api/cli/system/boxes/{box}/role", svcrole.Set{}, s.handleAPISystemBoxRole) |
| 520 | s.cliRoute("POST /api/cli/system/groups/{region}/role", svcrole.Set{}, s.handleAPISystemGroupRole) |
| 521 | } |
| 522 | } |
| 523 | // carlos status --layout (plan 4, task 5): which layout each instance is |
| 524 | // on, box by box. Same posture as the door above — an empty role set, the |
| 525 | // operator check inside the handler. |
| 526 | s.cliRoute("GET /api/cli/status/layout", svcrole.Set{}, s.handleAPIStatusLayout) |
| 527 | // The literal `new` mirrors the browser door (/a/{sqid}/apps/new); no |
| 528 | // bare POST .../{app} route exists to collide with, and the mux prefers |
| 529 | // the literal if one ever does. |
| 530 | s.cliRoute("POST /api/cli/apps/{account}/new", svcrole.Allow(svcrole.Publish), s.handleAPICreateApp) |
| 531 | // Temporary apps (temporary static sites spec §3). A separate route, not a |
| 532 | // field on /new: /new's decoder accepts unknown fields, so an older |
| 533 | // console would take "temporary" silently and create a permanent app. An |
| 534 | // older console answers this route 404 and the CLI stops there. |
| 535 | s.cliRoute("POST /api/cli/apps/{account}/new-temporary", svcrole.Allow(svcrole.Publish), s.handleAPICreateTemporaryApp) |
| 536 | s.cliRoute("GET /api/cli/apps/{account}/list", svcrole.Allow(svcrole.Publish), s.handleAPIAppList) |
| 537 | s.cliRoute("POST /api/cli/apps/{account}/{app}/ttl", svcrole.Allow(svcrole.Publish), s.handleAPIAppTTL) |
| 538 | s.cliRoute("POST /api/cli/apps/{account}/{app}/keep", svcrole.Allow(svcrole.Publish), s.handleAPIAppKeep) |
| 539 | s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels) |
| 540 | s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote) |
| 541 | s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback) |
| 542 | // `carlos channels resign` (#253, spec §4.6): a person confirms the |
| 543 | // manifest digests the console then signs into the channel's pointer, so |
| 544 | // no credential reaches it. |
| 545 | s.cliRoute("POST /api/cli/apps/{account}/{app}/channels/resign", svcrole.Set{}, s.handleAPIChannelResign) |
| 546 | // `carlos releases authorize` (settings spec §4.1): GET shows a build's |
| 547 | // digests, POST signs a promotion record for the digest a person |
| 548 | // confirmed. A credential the app holds never vouches for a build. |
| 549 | s.cliRoute("GET /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize) |
| 550 | s.cliRoute("POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize) |
| 551 | s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip) |
| 552 | // `carlos restart` (issue #113): a member's "cycle this app's processes" |
| 553 | // with no version and no config to push — the same write-door class as |
| 554 | // promote and rollback, and the only one whose subject is a running |
| 555 | // process rather than a pointer. |
| 556 | s.cliRoute("POST /api/cli/apps/{account}/{app}/restart", svcrole.Allow(svcrole.Operate), s.handleAPIRestart) |
| 557 | // The NARROWED form (discussion #16): one route, or everything reading one |
| 558 | // config environment, plus the dry run that shows the set first. A |
| 559 | // SEPARATE path rather than a body on the door above, and deliberately so: |
| 560 | // that one reads no body, so a console predating this would answer a |
| 561 | // narrowed request by cycling the whole app. Here it answers 404 and the |
| 562 | // CLI says why. Same role — the subject is still a running process. |
| 563 | s.cliRoute("POST /api/cli/apps/{account}/{app}/restart/scope", svcrole.Allow(svcrole.Operate), s.handleAPIRestartScope) |
| 564 | // `carlos schedule` (apischedule.go, scheduled-work spec §8): declare |
| 565 | // recurring work, take it back, and make it fire now. The RESTART class, |
| 566 | // not the publish one — operating a running app rather than moving a |
| 567 | // version pointer — so the same Operate role reaches all four. `delete` |
| 568 | // and `run` are literal segments after the {name} wildcard, so a schedule |
| 569 | // named "delete" is still just a name; the mux prefers the literal |
| 570 | // regardless of registration order. |
| 571 | s.cliRoute("GET /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleList) |
| 572 | s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleSet) |
| 573 | s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/delete", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRemove) |
| 574 | s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/run", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRun) |
| 575 | // `carlos releases retention` (issue #136): a member's ambient-prune |
| 576 | // policy — read, set, clear, preview. Same identity gates as every |
| 577 | // door here; the safety list in release.PruneEligible is what keeps a |
| 578 | // policy from ever deleting anything promoted, tagged, or recent. |
| 579 | s.cliRoute("GET /api/cli/apps/{account}/{app}/retention", svcrole.Allow(svcrole.Publish), s.handleAPIRetentionGet) |
| 580 | s.cliRoute("POST /api/cli/apps/{account}/{app}/retention", svcrole.Allow(svcrole.Publish), s.handleAPIRetentionSet) |
| 581 | // The version-target, tags and releases-list API (apiversion.go, |
| 582 | // release versioning spec 2026-08-08). GET .../releases coexists with |
| 583 | // the POST above on the SAME path — a distinct method route, not a |
| 584 | // collision — for `carlos releases` to read what `carlos ship` writes. |
| 585 | s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIVersionTargetGet) |
| 586 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish), s.handleAPIVersionTargetSet) |
| 587 | s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagsList) |
| 588 | s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagGet) |
| 589 | s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList) |
| 590 | // The pipeline API (apipipeline.go, release pipelines v2). GET is |
| 591 | // release metadata like channels/releases above — Publish reaches it. |
| 592 | // PUT rewrites the policy governing every promotion into every channel |
| 593 | // (bake, passkey, approval quorum), account authority in the same |
| 594 | // register as ledgers and store grants below — Admin-only for a |
| 595 | // SERVICE credential. svcrole.Allow() alone would be the WRONG way to |
| 596 | // say that: the empty Set is fully closed, Admin included |
| 597 | // (TestEmptySetPermitsNothingIncludingAdmin, internal/svcrole) — the |
| 598 | // same zero value svcrole.Set{} spells for the person-only doors |
| 599 | // below. Naming Admin explicitly is what actually opens the door to an |
| 600 | // admin-role service and closes it to every other role, exactly like |
| 601 | // the ledgers and store families. |
| 602 | s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet) |
| 603 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Admin), s.handleAPIPipelinePut) |
| 604 | // What the app is waiting on: the proposals a gated promote, fast-track |
| 605 | // or policy edit turned into (pendinghandlers.go). Read-only, and Publish |
| 606 | // like channels and releases — a proposal names no secret, and the CLI |
| 607 | // needs to see one precisely because it can never confirm one itself |
| 608 | // (spec §4: confirming takes a passkey, in the console). |
| 609 | s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending) |
| 610 | // Trash and restore (apicli.go). Delete resolves through the LIVE-apps |
| 611 | // door and restore through the trashed-apps one, so each verb answers |
| 612 | // only for an app in the state it can act on — and both give a stranger |
| 613 | // the same 404. |
| 614 | s.cliRoute("POST /api/cli/apps/{account}/{app}/delete", svcrole.Allow(svcrole.Publish), s.handleAPITrashApp) |
| 615 | s.cliRoute("POST /api/cli/apps/{account}/{app}/restore", svcrole.Allow(svcrole.Publish), s.handleAPIRestoreApp) |
| 616 | // The ledger API (apiledger.go) — the only write path to ledgers/ from |
| 617 | // outside a box. The literal `publish` segment is kept adjacent to the |
| 618 | // {ledger} wildcards for readability; the mux prefers it regardless of |
| 619 | // registration order. |
| 620 | s.cliRoute("GET /api/cli/apps/{account}/{app}/ledgers", svcrole.Allow(svcrole.Admin), s.handleAPILedgerList) |
| 621 | s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/publish", svcrole.Allow(svcrole.Admin), s.handleAPILedgerPublish) |
| 622 | s.cliRoute("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", svcrole.Allow(svcrole.Admin), s.handleAPILedgerHead) |
| 623 | s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", svcrole.Allow(svcrole.Admin), s.handleAPILedgerAppend) |
| 624 | s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", svcrole.Allow(svcrole.Admin), s.handleAPILedgerBlob) |
| 625 | // The instance provisioning API (apiinstances.go) — the only door onto |
| 626 | // instances/ and domains/ from outside a box. It was once the only |
| 627 | // family a non-person could speak to; since the role gate landed that is |
| 628 | // a property of each door's Allow set, not of this block. `instance-token` is a literal segment beside the |
| 629 | // {host} wildcards; the mux prefers a literal regardless of |
| 630 | // registration order, and it deliberately sits under a DIFFERENT |
| 631 | // prefix from instances/{host} so a host can never be mistaken for the |
| 632 | // mint verb. |
| 633 | // `instances-manifest` is the member-facing half of `carlos instances |
| 634 | // enable` (issue #66): a literal sibling of instances/, never under it, |
| 635 | // so a host named "instances-manifest" could still never reach it. |
| 636 | // |
| 637 | // The zero Set, for the same escalation cut the mint door takes. This |
| 638 | // door writes the MANIFEST — standing policy about which domain, pool, |
| 639 | // regions and channel the app may ever be brought up on — and a |
| 640 | // credential the app itself holds must not be able to rewrite the |
| 641 | // bounds it runs inside. It takes no authority away: handleAPIInstanceEnable |
| 642 | // has always gated on apiAccountApp, which could not see a non-person at |
| 643 | // all, so no app token has ever reached it. The registration is where |
| 644 | // that is now said, because a registration reading "instance may pass" |
| 645 | // while the handler refuses is a registration that lies. As at the mint |
| 646 | // door, the ANSWER a credential gets here moves from 401 to 403. |
| 647 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances-manifest", svcrole.Set{}, s.handleAPIInstanceEnable) |
| 648 | s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceList) |
| 649 | // The armed-regions answer for consumer pickers (issue #346) — the |
| 650 | // same gate and role as the list beside it; see handleAPIRegions. |
| 651 | s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIRegions) |
| 652 | // The per-caller region guess (nearest-region spec §9.4): POST, so the |
| 653 | // hints never ride a URL a redirect could echo. |
| 654 | s.cliRoute("POST /api/cli/apps/{account}/{app}/regions/guess", svcrole.Allow(svcrole.Instance), s.handleAPIRegionGuess) |
| 655 | // Moving a route between channels (channelmove.go). The zero Set, for |
| 656 | // the manifest door's reason: this decides what a route SERVES, and a |
| 657 | // credential the app itself holds must not be able to walk its own route |
| 658 | // onto a channel whose rules it never met. Widening it later is easy; |
| 659 | // narrowing an authority already granted is not. |
| 660 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel) |
| 661 | // Update settings (instance update settings spec §5.1). Person-only until |
| 662 | // #155 step 6 adds an instance-bound credential: svcrole.Instance is |
| 663 | // app-wide, so it would let one instance's code set a sibling's pin. |
| 664 | s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesGet) |
| 665 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesPut) |
| 666 | // "Update now" asks root for the newest build the settings allow; the |
| 667 | // same people-only gate, and fast_track only for an account owner. |
| 668 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.Set{}, s.handleAPIUpdatesInstall) |
| 669 | // The app's instance update policy (spec §3.2) lives on the instances |
| 670 | // manifest, so it is closed for the manifest door's reason: a credential |
| 671 | // the app holds must not rewrite the bounds its instances run inside. |
| 672 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/updates", svcrole.Set{}, s.handleAPIUpdatesPolicy) |
| 673 | // The logs read (`carlos logs`, apilogs.go) — same gate, same 404 |
| 674 | // discipline, and an app token may read its own app's logs. |
| 675 | s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs) |
| 676 | // The errors read (`carlos errors`, apierrors.go) — the logs door's twin, |
| 677 | // over the same window from the other end: the same gate, the same roles, |
| 678 | // and an app token may read its own app's errors for the same reason it |
| 679 | // may read its own logs. |
| 680 | s.cliRoute("GET /api/cli/apps/{account}/{app}/errors", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIErrors) |
| 681 | s.cliRoute("GET /api/cli/apps/{account}/{app}/availability", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAvailability) |
| 682 | // The analytics read (`carlos analytics`, apianalytics.go) — the third of |
| 683 | // the same family, on the same footing: an app token that may read every |
| 684 | // line of its own logs is not learning anything new from a count of its |
| 685 | // own page views. The SETTING that decides whether those counts exist is |
| 686 | // the telemetry PUT below, and that stays Operate-only. |
| 687 | s.cliRoute("GET /api/cli/apps/{account}/{app}/analytics", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAnalytics) |
| 688 | // The platform's GeoIP answer for an arbitrary address (geoip spec |
| 689 | // §11.1): the same holders as the analytics read. |
| 690 | s.cliRoute("GET /api/cli/apps/{account}/{app}/geo", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIGeo) |
| 691 | // The telemetry SETTINGS write, Operate only and deliberately narrower |
| 692 | // than the read beside it: this decides what counts as an error for the |
| 693 | // app's owner, and a credential the app itself holds must not be able to |
| 694 | // widen or narrow what its owner is shown. |
| 695 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/telemetry", svcrole.Allow(svcrole.Operate), s.handleAPITelemetryPut) |
| 696 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceCreate) |
| 697 | s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceGet) |
| 698 | // PATCH is the multi-upstream repoint (issue #152): the two fields of a |
| 699 | // record — its addr set and its probe — that change while the instance |
| 700 | // keeps serving. Same {host} wildcard, same gate, same byte-identical |
| 701 | // 404 as its GET and DELETE siblings. |
| 702 | s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceUpdate) |
| 703 | // The residence-move doors (issue #331): requested by the tenant's |
| 704 | // ROUTER with the app-held instance token (titogo's residence design — |
| 705 | // never by an instance about itself... which the role system cannot yet |
| 706 | // express; the Instance role is the same authority the create and |
| 707 | // delete doors already grant this credential). |
| 708 | s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveStatus) |
| 709 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest) |
| 710 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut) |
| 711 | s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort) |
| 712 | // With CARLOS_MOVES_PHASE1 on, the three mutations above are |
| 713 | // operator-only (moveOperatorGate) and a resolve door joins them. Off, |
| 714 | // that route is never registered, so every request to it answers |
| 715 | // exactly as it did before Phase 1 (index D13). |
| 716 | if s.cfg.MovesEnabled { |
| 717 | s.registerMovesPhase1Routes() |
| 718 | } |
| 719 | // The offline operator move's doors: always registered, operator-only, |
| 720 | // and refused with MV-O20 on a console with host claims or Phase 1 on. |
| 721 | s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move/offline", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStatus) |
| 722 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStart) |
| 723 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/stage", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStage) |
| 724 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/seal", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbSeal)) |
| 725 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/flip", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbFlip)) |
| 726 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/open", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbOpen)) |
| 727 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/finish", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbFinish)) |
| 728 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/abort", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineAbort) |
| 729 | s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete) |
| 730 | // The import door (seeded-instances spec §5): the instance role, like |
| 731 | // the create door that begins it. |
| 732 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/import", svcrole.Allow(svcrole.Instance), s.handleAPIImportUpload) |
| 733 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped", svcrole.Allow(svcrole.Instance), s.handleAPIImportStopped) |
| 734 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit", svcrole.Allow(svcrole.Instance), s.handleAPIImportCommit) |
| 735 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort", svcrole.Allow(svcrole.Instance), s.handleAPIImportAbort) |
| 736 | // The run doors (service runs spec §1): a person on the member rule, or a |
| 737 | // run service, and no other role. Exactly, not Allow, so Admin does not |
| 738 | // join: a credential that may provision instances cannot run commands on |
| 739 | // them unless it was minted for that. |
| 740 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Exactly(svcrole.Run), s.handleAPIRunCreate) |
| 741 | s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Exactly(svcrole.Run), s.handleAPIRunKeyLookup) |
| 742 | s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}", svcrole.Exactly(svcrole.Run), s.handleAPIRunGet) |
| 743 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel", svcrole.Exactly(svcrole.Run), s.handleAPIRunCancel) |
| 744 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire", svcrole.Exactly(svcrole.Run), s.handleAPIRunExpire) |
| 745 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack", svcrole.Exactly(svcrole.Run), s.handleAPIRunAck) |
| 746 | // `carlos instances retry <host>` (apiretry.go): the operate family's, as |
| 747 | // restart is. The {host} wildcard and the 404 are the instance family's. |
| 748 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/retry", svcrole.Allow(svcrole.Operate), s.handleAPIInstanceRetry) |
| 749 | // The `carlos db` replica door (apidb.go; spec 2026-09-02 §6): an |
| 750 | // instance's replicated LTX files, by coordinate, for a member to |
| 751 | // restore on their own machine. The zero Set, deliberately: the payload |
| 752 | // is the whole customer database, and no legitimate credential needs it |
| 753 | // (an app reads its own database live, and CI has no business pulling |
| 754 | // production data). admin is refused with the rest. |
| 755 | s.cliRoute("GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}", svcrole.Set{}, s.handleAPIDBList) |
| 756 | s.cliRoute("GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}/{span}", svcrole.Set{}, s.handleAPIDBGet) |
| 757 | // The forwarded read (db region forward spec §9.1): people only, the db |
| 758 | // door's whole gate once, then the read inside the same request. |
| 759 | s.cliRoute("POST /api/cli/apps/{account}/{app}/db/{host}/reads", svcrole.Set{}, s.handleAPIDBRead) |
| 760 | // The zero Set, deliberately: a credential may never mint a credential, |
| 761 | // including itself. This is the escalation cut (spec §3). It takes no |
| 762 | // authority away — handleAPIInstanceToken gates on apiAccountApp, which |
| 763 | // has never been able to see a non-person at all — it moves the refusal |
| 764 | // out to the wrapper, where it is structural, and changes the ANSWER a |
| 765 | // credential gets here from 401 to 403. Pinned by |
| 766 | // TestNoRoleReachesAMintFleetOrPolicyDoor and TestAdminIsRefusedAtAClosedDoor. |
| 767 | s.cliRoute("POST /api/cli/apps/{account}/{app}/instance-token", svcrole.Set{}, s.handleAPIInstanceToken) |
| 768 | // Door keys (#175 P7): person-only, the instance-token rule. A door key |
| 769 | // reaches every instance of the app through every box's edge-local door. |
| 770 | s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList) |
| 771 | s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint) |
| 772 | s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke) |
| 773 | // The sidecar API (apisidecars.go) — the only door onto sidecars/ from |
| 774 | // outside a box, sharing the instance family's gate exactly |
| 775 | // (instanceGate) and, since the role gate landed, its Allow sets too: |
| 776 | // an app process provisions a sidecar per enrolled member with the |
| 777 | // CARLOS_INSTANCE_TOKEN it already holds, which is the Instance role. |
| 778 | // |
| 779 | // `sidecars-manifest` is the member-facing half of `carlos sidecars |
| 780 | // enable`: a literal sibling of sidecars/, never under it, so a sidecar |
| 781 | // named "sidecars-manifest" could still never reach it. It takes the |
| 782 | // ZERO Set for `instances-manifest`'s reason, and the reason is sharper |
| 783 | // here: this manifest carries `args` — the argv the platform execs |
| 784 | // after the release binary — plus the placement the workload is held |
| 785 | // to. A credential the app holds must not be able to rewrite what its |
| 786 | // own boxes will exec on its behalf. handleAPISidecarEnable has always |
| 787 | // gated on apiAccountApp, which cannot see a non-person at all, so this |
| 788 | // takes no authority away; it moves the refusal out to the registration |
| 789 | // and the answer from 401 to 403. |
| 790 | // |
| 791 | // `wake` is a literal segment UNDER {name}; the mux prefers a literal |
| 792 | // regardless of registration order, and provision.ValidSidecarName is |
| 793 | // one DNS label, so no sidecar name can ever be "wake"'s path either. |
| 794 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/sidecars-manifest", svcrole.Set{}, s.handleAPISidecarEnable) |
| 795 | s.cliRoute("GET /api/cli/apps/{account}/{app}/sidecars", svcrole.Allow(svcrole.Instance), s.handleAPISidecarList) |
| 796 | s.cliRoute("POST /api/cli/apps/{account}/{app}/sidecars", svcrole.Allow(svcrole.Instance), s.handleAPISidecarCreate) |
| 797 | s.cliRoute("GET /api/cli/apps/{account}/{app}/sidecars/{name}", svcrole.Allow(svcrole.Instance), s.handleAPISidecarGet) |
| 798 | s.cliRoute("DELETE /api/cli/apps/{account}/{app}/sidecars/{name}", svcrole.Allow(svcrole.Instance), s.handleAPISidecarDelete) |
| 799 | // No PATCH: a sidecar has no upstream set to repoint, which is the one |
| 800 | // thing an instance record's update verb exists for. The family is |
| 801 | // create-only plus delete. |
| 802 | s.cliRoute("POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake", svcrole.Allow(svcrole.Instance), s.handleAPISidecarWake) |
| 803 | // The extra-hostname API (apidomains.go, issue #151) — the only door onto |
| 804 | // domains/ from outside a box, sharing the instance family's gate exactly |
| 805 | // (instanceGate), so titogo's router attaches its customers' hostnames |
| 806 | // with the CARLOS_INSTANCE_TOKEN it already holds. |
| 807 | // |
| 808 | // Domains carries `Instance` for COMPATIBILITY, not because it belongs |
| 809 | // there: that router re-attaches every hostname on every reload pass with |
| 810 | // its CARLOS_INSTANCE_TOKEN, which is a kind='app' row and so holds role |
| 811 | // instance. Dropping it here 403s that router on the next console deploy. |
| 812 | // Same for logs, above. Pinned by |
| 813 | // TestInstanceRoleMatchesTodaysAppTokenReach; spec §5. |
| 814 | s.cliRoute("GET /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainList) |
| 815 | // The per-host read (#299): same roles as the list it narrows — it |
| 816 | // discloses strictly less (one hostname the caller already owns), so it |
| 817 | // cannot need a role the list does not. |
| 818 | s.cliRoute("GET /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainGet) |
| 819 | s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach) |
| 820 | s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach) |
| 821 | // The prefix-map publish (path-based routing): replace the WHOLE map of |
| 822 | // leading path segments a claimed hostname splits its traffic by. The |
| 823 | // tenant's own platform is the writer and the source of truth — it |
| 824 | // regenerates the map from its database and republishes it — which is |
| 825 | // why there is no per-prefix verb and no CLI writer at all. |
| 826 | // |
| 827 | // `Publish` AND `Instance`, matching the READ of the same state on |
| 828 | // handleAPIDomainGet above. An earlier revision made this Publish-only on |
| 829 | // the reasoning that a flip is deployment-shaped; the objection that |
| 830 | // carried is internal coherence — as Publish-only, an Instance credential |
| 831 | // could read a hostname's published prefix map and not write it, and this |
| 832 | // file's own header names titogo's router, calling the domains family |
| 833 | // with its CARLOS_INSTANCE_TOKEN, as the caller this door exists for. |
| 834 | // |
| 835 | // Widening crosses no tenancy boundary. The door is already owner-gated |
| 836 | // on account and app (domainsGate) and guarded by |
| 837 | // refuseServiceControlPlane, so an Instance token can only ever publish |
| 838 | // its OWN app's map, naming targets the reconcile pass then re-checks |
| 839 | // against the same account (aliases.go). It is not in |
| 840 | // TestInstanceRoleMatchesTodaysAppTokenReach's legacy map, because |
| 841 | // nothing reached it before service principals existed — see |
| 842 | // exemptFromLegacyPin, where the deliberate grant is recorded. Design |
| 843 | // spec §7. |
| 844 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainPaths) |
| 845 | // App feature flags (apifeatures.go, #347). The read carries Instance |
| 846 | // because the app IS the consumer — its router reads its own switches |
| 847 | // with the CARLOS_INSTANCE_TOKEN, the same credential and the same |
| 848 | // answer (the domains deployment block) it already uses. The write is |
| 849 | // Admin only: these are production switches, and the register is |
| 850 | // `pipeline` PUT's and `apps place`'s, not promote's. An app's own |
| 851 | // credential must never reach the write — a switch an app could flip |
| 852 | // on itself is a root env flag by another name. |
| 853 | s.cliRoute("GET /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIFeaturesGet) |
| 854 | s.cliRoute("POST /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Admin), s.handleAPIFeaturesSet) |
| 855 | // The config API (apiconfig.go) — `carlos env` and `carlos secrets` |
| 856 | // through the same gate as ship/promote, so deploy access no longer |
| 857 | // means bucket credentials (issue #43). Secrets arrive already sealed; |
| 858 | // the console relays an opaque envelope and holds no key to open it. |
| 859 | s.cliRoute("GET /api/cli/apps/{account}/{app}/config", svcrole.Allow(svcrole.Operate), s.handleAPIConfigGet) |
| 860 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/config", svcrole.Allow(svcrole.Operate), s.handleAPIConfigPut) |
| 861 | s.cliRoute("POST /api/cli/apps/{account}/{app}/config/sync", svcrole.Allow(svcrole.Operate), s.handleAPIConfigSync) |
| 862 | // The routes read (apiroutes.go, issue #64) — the member-only door onto |
| 863 | // this box's registry rows for one app, so a route's environment |
| 864 | // binding can be read back without a box shell. |
| 865 | // |
| 866 | // The ZERO Set, and the reasoning is that file's own: "An app token is |
| 867 | // a machine credential minted for one instance to register itself; this |
| 868 | // answer describes every route of the app, with upstream addresses, |
| 869 | // owning units and environment bindings, which is an operator's view |
| 870 | // rather than a workload's." A service credential is the same kind of |
| 871 | // caller an app token is, so the door that refuses one refuses all of |
| 872 | // them. It joins policyDoors for that reason — person-only by intent, |
| 873 | // not merely by bookkeeping. |
| 874 | s.cliRoute("GET /api/cli/apps/{account}/{app}/routes", svcrole.Set{}, s.handleAPIRoutes) |
| 875 | // The customer fleets API (fleets.go) — account-owned remote-box groups |
| 876 | // and where an app's routes/instances place onto them. `new` is a |
| 877 | // literal beside the {fleet} wildcard for the same reason apps/new is: |
| 878 | // the mux prefers a literal regardless of registration order, so there |
| 879 | // is no ambiguity with a fleet that happened to be named "new". |
| 880 | // |
| 881 | // Every one of them is the zero Set, deliberately: enrolling a box into a |
| 882 | // fleet, and rotating or detaching one, hands out a MACHINE's credentials |
| 883 | // and changes where a whole account's apps can be placed. That is a human |
| 884 | // act, and no service role reaches it — the same escalation cut as the |
| 885 | // mint door above. Pinned by TestNoRoleReachesAMintFleetOrPolicyDoor. |
| 886 | s.cliRoute("POST /api/cli/fleets/{account}/new", svcrole.Set{}, s.handleAPIFleetNew) |
| 887 | s.cliRoute("GET /api/cli/fleets/{account}", svcrole.Set{}, s.handleAPIFleetList) |
| 888 | s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes", svcrole.Set{}, s.handleAPIFleetAddBox) |
| 889 | s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", svcrole.Set{}, s.handleAPIFleetRotateBox) |
| 890 | s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", svcrole.Set{}, s.handleAPIFleetDetachBox) |
| 891 | s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", svcrole.Set{}, s.handleAPIFleetTagBox) |
| 892 | // The service-credential API (apiservices.go, issue #220) — the |
| 893 | // principal that is not an address. Every door here takes the ZERO role |
| 894 | // set: a credential may never mint, rotate or revoke a credential, |
| 895 | // including itself, which is what keeps a publish credential from |
| 896 | // quietly becoming an admin one. Owner-gated inside, because minting is |
| 897 | // an authority grant that outlives its minter. |
| 898 | // |
| 899 | // {name} is a wildcard with rotate and revoke as literal segments |
| 900 | // beneath it — the mux prefers a literal regardless of registration |
| 901 | // order, the same property the fleets and store families already rely |
| 902 | // on, so a credential named "rotate" cannot shadow the verb. |
| 903 | s.cliRoute("POST /api/cli/services/{account}", svcrole.Set{}, s.handleAPIServiceCreate) |
| 904 | s.cliRoute("GET /api/cli/services/{account}", svcrole.Set{}, s.handleAPIServiceList) |
| 905 | s.cliRoute("POST /api/cli/services/{account}/{name}/rotate", svcrole.Set{}, s.handleAPIServiceRotate) |
| 906 | s.cliRoute("POST /api/cli/services/{account}/{name}/revoke", svcrole.Set{}, s.handleAPIServiceRevoke) |
| 907 | // Placement lives under the apps family, not fleets: its subject is an |
| 908 | // APP, and a target fleet name is only ever the request body's data. |
| 909 | s.cliRoute("POST /api/cli/apps/{account}/{app}/place", svcrole.Allow(svcrole.Admin), s.handleAPIPlace) |
| 910 | // The build probe on an EXISTING app (discussion #13). An empty role set: |
| 911 | // the probe is what the deploy watch trusts instead of the header, so a |
| 912 | // credential that could rewrite it could make its own deploys report |
| 913 | // success over a process that never restarted — which is the exact bug |
| 914 | // the probe exists to prevent, handed to the wrong caller. |
| 915 | s.cliRoute("POST /api/cli/apps/{account}/{app}/build-probe", svcrole.Set{}, s.handleAPIBuildProbe) |
| 916 | // The swap policy (zero-downtime spec §4.1, apiswap.go). The ZERO Set: |
| 917 | // §11 says only app members change it, and a credential the app holds |
| 918 | // must not choose what its own migrations run beside. |
| 919 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/swap", svcrole.Set{}, s.handleAPISwapPut) |
| 920 | // The object-storage API (apistore.go, issue #55) — declare a store, read |
| 921 | // what became of it, and drive the walks that make it real. Beside the |
| 922 | // config routes on purpose: a store's credentials are DELIVERED as config, |
| 923 | // so these doors are halves of the same story for whoever reads this list. |
| 924 | // |
| 925 | // `grant` is the deployment's first operator-identity door: same member |
| 926 | // 404 as its siblings, then an address on CARLOS_STORE_OPERATORS. Rotate |
| 927 | // and finish are MEMBER verbs (spec §8) — they cycle a credential inside an |
| 928 | // estate a grant already paid for. All three are literal segments under |
| 929 | // .../store, never {wildcards}, so no app or host name can reach them. |
| 930 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreCreate) |
| 931 | s.cliRoute("GET /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGet) |
| 932 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/provision", svcrole.Allow(svcrole.Admin), s.handleAPIStoreProvision) |
| 933 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/grant", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGrant) |
| 934 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotate) |
| 935 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate/finish", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotateFinish) |
| 936 | // Account-level storage entitlement doors are person-only. Applying speaks |
| 937 | // for an account owner; approvals spend deployment money and deliberately |
| 938 | // resolve their target without requiring operator membership in it. |
| 939 | s.cliRoute("POST /api/cli/accounts/{account}/store/apply", svcrole.Set{}, s.handleAPIStoreApply) |
| 940 | s.cliRoute("POST /api/cli/accounts/{account}/store/approve", svcrole.Set{}, s.handleAPIStoreApprove) |
| 941 | s.cliRoute("GET /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanGet) |
| 942 | s.cliRoute("POST /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanSet) |
| 943 | s.cliRoute("POST /api/cli/accounts/{account}/store/decline", svcrole.Set{}, s.handleAPIStoreDecline) |
| 944 | s.cliRoute("GET /api/cli/store/approvals", svcrole.Set{}, s.handleAPIStoreApprovals) |
| 945 | // The scan family (internal/console/apiscan.go). `scan` itself is the |
| 946 | // member's request-and-status pair; grant, revoke, sweep and enforce are |
| 947 | // operator verbs. Literal segments again, so no app or host name reaches |
| 948 | // them. |
| 949 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan", svcrole.Allow(svcrole.Admin), s.handleAPIScanRequest) |
| 950 | s.cliRoute("GET /api/cli/apps/{account}/{app}/store/scan", svcrole.Allow(svcrole.Admin), s.handleAPIScanStatus) |
| 951 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/grant", svcrole.Allow(svcrole.Admin), s.handleAPIScanGrant) |
| 952 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/revoke", svcrole.Allow(svcrole.Admin), s.handleAPIScanRevoke) |
| 953 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/sweep", svcrole.Allow(svcrole.Admin), s.handleAPIScanSweep) |
| 954 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/enforce", svcrole.Allow(svcrole.Admin), s.handleAPIScanEnforce) |
| 955 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/callback-key/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIScanCallbackRotate) |
| 956 | // The ONE scan door an app's own credential reaches: "scan this object |
| 957 | // now" (spec §5.1). Service, not Admin, and the difference is the point — |
| 958 | // every verb above decides POLICY (who may scan, whether enforcement is |
| 959 | // on) and belongs to a person, while this one asks for work inside a |
| 960 | // policy somebody already set. It can add nothing an operator has not |
| 961 | // already granted: the consent chain in the handler refuses unless the |
| 962 | // store was requested, granted and enabled, so the worst a stolen service |
| 963 | // token achieves is queueing scans of its own app's objects. |
| 964 | s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/object", svcrole.Allow(svcrole.Service), s.handleAPIScanEnqueue) |
| 965 | // And reading the answer back (spec §5.2), same role and same reasoning: |
| 966 | // an app asking about its own object. |
| 967 | s.cliRoute("GET /api/cli/apps/{account}/{app}/store/scan/object", svcrole.Allow(svcrole.Service), s.handleAPIScanObject) |
| 968 | // The relay family (internal/console/apirelay.go, relay spec §5): the |
| 969 | // platform STUN/TURN relay's app-facing doors. enable/status/disable are |
| 970 | // the member's own verbs — declaring, inspecting and ending the app's own |
| 971 | // relay estate is not an authority over anything but that app, unlike the |
| 972 | // object store's grant. adopted is the member's cutover ack for a |
| 973 | // TCP-address route this platform cannot restart. Literal segments under |
| 974 | // .../relay, same as store's, so no app or host name can reach them. |
| 975 | s.cliRoute("POST /api/cli/apps/{account}/{app}/relay", svcrole.Allow(svcrole.Admin), s.handleAPIRelayEnable) |
| 976 | s.cliRoute("GET /api/cli/apps/{account}/{app}/relay", svcrole.Allow(svcrole.Admin), s.handleAPIRelayGet) |
| 977 | s.cliRoute("POST /api/cli/apps/{account}/{app}/relay/disable", svcrole.Allow(svcrole.Admin), s.handleAPIRelayDisable) |
| 978 | s.cliRoute("POST /api/cli/apps/{account}/{app}/relay/adopted", svcrole.Allow(svcrole.Admin), s.handleAPIRelayAdopted) |
| 979 | // rotate is the ONE relay door with no {account} segment: it moves a |
| 980 | // REGION's issuer pointer and re-issues every app enabled there, an |
| 981 | // authority over the deployment's key material (RelayOperators, server.go) |
| 982 | // rather than over any one tenant's app. apiRelayOperator is its whole |
| 983 | // gate. |
| 984 | s.cliRoute("POST /api/cli/relay/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIRelayRotate) |
| 985 | // `carlos system` (apisystemupdate.go and apisystem.go, spec §5) — the |
| 986 | // deployment-wide doors. No {account} segment, so no service credential |
| 987 | // can be scoped to them at all. Two families sit here: the operator's |
| 988 | // promote of the PLATFORM's own binary (what is running where, roll the |
| 989 | // fleet forward onto a signed public release or a local build, and roll |
| 990 | // it back), and the deployment-wide antivirus policy. |
| 991 | // |
| 992 | // Every one of them is the ZERO Set, and for the sharpest version of the |
| 993 | // escalation cut in this file: the subject is the substrate every tenant |
| 994 | // runs inside. A credential that reached the update doors could point |
| 995 | // every box in the deployment at a binary of its choosing, which is not a |
| 996 | // widening of any workload's authority — it is the end of the boundary |
| 997 | // the roles are drawn inside. An empty Set shuts them to every |
| 998 | // credential, and closedDoors pins that shut. |
| 999 | // |
| 1000 | // The person check itself is in the handlers, because cliRoute's Set can |
| 1001 | // only narrow SERVICE principals and a person always passes through it. |
| 1002 | // The update doors want an OWNER of the ops account or an address on |
| 1003 | // CARLOS_SYSTEM_OPERATORS, with upload the owner's alone |
| 1004 | // (apiSystemRoller); the antivirus doors want an address on |
| 1005 | // CARLOS_STORE_OPERATORS (apiSystemOperator). |
| 1006 | s.cliRoute("GET /api/cli/system/status", svcrole.Set{}, s.handleAPISystemStatus) |
| 1007 | s.cliRoute("POST /api/cli/system/update", svcrole.Set{}, s.handleAPISystemUpdate) |
| 1008 | s.cliRoute("GET /api/cli/system/jobs/{id}", svcrole.Set{}, s.handleAPISystemJob) |
| 1009 | s.cliRoute("PUT /api/cli/system/upload", svcrole.Set{}, s.handleAPISystemUpload) |
| 1010 | s.cliRoute("POST /api/cli/system/rollback", svcrole.Set{}, s.handleAPISystemRollback) |
| 1011 | // I-4: releasing an executable activation reservation that was never |
| 1012 | // published. The same person gate as rollback (apiSystemRoller). |
| 1013 | s.cliRoute("POST /api/cli/system/activation/cancel", svcrole.Set{}, s.handleAPISystemActivationCancel) |
| 1014 | s.cliRoute("GET /api/cli/system/layout", svcrole.Set{}, s.handleAPISystemLayoutGet) |
| 1015 | s.cliRoute("POST /api/cli/system/layout", svcrole.Set{}, s.handleAPISystemLayoutSet) |
| 1016 | s.cliRoute("GET /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusGet) |
| 1017 | s.cliRoute("POST /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusSet) |
| 1018 | s.cliRoute("POST /api/cli/system/antivirus/rotate-webhook-key", svcrole.Set{}, s.handleAPISystemWebhookRotate) |
| 1019 | // Platform GeoIP (geoip spec §4.2): apiSystemRoller, like the update |
| 1020 | // doors, never CARLOS_STORE_OPERATORS. |
| 1021 | s.cliRoute("PUT /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipPut) |
| 1022 | s.cliRoute("GET /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipGet) |
| 1023 | s.cliRoute("DELETE /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipDelete) |
| 1024 | // The seeds floor (seeded-instances spec §3.3): apiSystemRoller. |
| 1025 | s.cliRoute("POST /api/cli/system/seeds-floor", svcrole.Set{}, s.handleAPISystemSeedsFloor) |
| 1026 | // Legacy route assignments (seeded-instances spec §6.4): apiSystemRoller. |
| 1027 | s.cliRoute("POST /api/cli/system/legacy-routes", svcrole.Set{}, s.handleAPISystemLegacyRoutes) |
| 1028 | // Pointer digests (#253, spec §4.6): each account's emission marker and |
| 1029 | // the channels still without digests. apiSystemRoller, like the update |
| 1030 | // doors. |
| 1031 | s.cliRoute("GET /api/cli/system/pointer-digests", svcrole.Set{}, s.handleAPISystemPointerDigests) |
| 1032 | s.cliRoute("POST /api/cli/system/pointer-digests", svcrole.Set{}, s.handleAPISystemPointerDigests) |
| 1033 | } |
| 1034 | |
| 1035 | // clientIP is who the rate limiter counts against. |
| 1036 | // |
| 1037 | // X-Forwarded-For is only consulted when the PEER is one we put there: the |
| 1038 | // edge on this box (loopback — the console listens on 127.0.0.1) or a |
| 1039 | // configured edge address. From anyone else the header is just a string the |
| 1040 | // client typed, and honouring it would let one caller present a fresh "IP" |
| 1041 | // per request and walk straight past every per-IP limit below. |
| 1042 | // |
| 1043 | // Loopback is not the edge alone, though: any process on this box can |
| 1044 | // connect to it. A loopback peer's chain is therefore believed only with the |
| 1045 | // edge's signature beside it (forwardedByEdge, edgeproof.go); without one the |
| 1046 | // request counts against the peer address like any other. |
| 1047 | // |
| 1048 | // When the peer is trusted, the LAST element is the one to take: the edge |
| 1049 | // appends its own view of the peer with SetXForwarded, so everything before |
| 1050 | // it may have been supplied by the client. |
| 1051 | func (s *Server) clientIP(r *http.Request) string { |
| 1052 | host, _, err := net.SplitHostPort(r.RemoteAddr) |
| 1053 | if err != nil { |
| 1054 | host = r.RemoteAddr |
| 1055 | } |
| 1056 | if !s.trustsProxy(host) { |
| 1057 | return host |
| 1058 | } |
| 1059 | // Every line, joined: that is the chain the edge signed, and a second |
| 1060 | // line is how a client would try to slip its own element in. |
| 1061 | xff := strings.Join(r.Header.Values("X-Forwarded-For"), ", ") |
| 1062 | if xff == "" { |
| 1063 | return host |
| 1064 | } |
| 1065 | if isLoopbackPeer(host) && !s.forwardedByEdge(r, xff) { |
| 1066 | return host |
| 1067 | } |
| 1068 | parts := strings.Split(xff, ",") |
| 1069 | if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" { |
| 1070 | return ip |
| 1071 | } |
| 1072 | return host |
| 1073 | } |
| 1074 | |
| 1075 | // trustsProxy reports whether a peer is allowed to speak for somebody else. |
| 1076 | func (s *Server) trustsProxy(host string) bool { |
| 1077 | ip := net.ParseIP(host) |
| 1078 | if ip == nil { |
| 1079 | return false |
| 1080 | } |
| 1081 | if ip.IsLoopback() { |
| 1082 | return true // the edge, on this box, which is the only deployment shape |
| 1083 | } |
| 1084 | for _, e := range s.cfg.EdgeIPs { |
| 1085 | if other := net.ParseIP(strings.TrimSpace(e)); other != nil && other.Equal(ip) { |
| 1086 | return true |
| 1087 | } |
| 1088 | } |
| 1089 | return false |
| 1090 | } |
| 1091 | |
| 1092 | // ---- JSON plumbing ---------------------------------------------------------- |
| 1093 | |
| 1094 | func writeJSON(w http.ResponseWriter, status int, body any) { |
| 1095 | w.Header().Set("Content-Type", "application/json") |
| 1096 | w.Header().Set("Cache-Control", "no-store") |
| 1097 | w.WriteHeader(status) |
| 1098 | if err := json.NewEncoder(w).Encode(body); err != nil { |
| 1099 | log.Printf("console: write json: %v", err) |
| 1100 | } |
| 1101 | } |
| 1102 | |
| 1103 | func jsonError(w http.ResponseWriter, status int, msg string) { |
| 1104 | writeJSON(w, status, map[string]string{"error": msg}) |
| 1105 | } |
| 1106 | |
| 1107 | // jsonErrorCode is jsonError with a stable machine-readable code beside the |
| 1108 | // sentence. The run handlers' own refusals use it (runRefusalCodes); a |
| 1109 | // shared gate never does, so its body stays the same at every door. |
| 1110 | func jsonErrorCode(w http.ResponseWriter, status int, code, msg string) { |
| 1111 | writeJSON(w, status, map[string]string{"error": msg, "code": code}) |
| 1112 | } |
| 1113 | |
| 1114 | // readJSON decodes a CLI request body, insisting on a JSON content type. |
| 1115 | // |
| 1116 | // That insistence IS the CSRF defence for the unauthenticated endpoints: a |
| 1117 | // cross-site HTML form can only send urlencoded, multipart or plain text, so |
| 1118 | // a body this accepts cannot have been sent by a page the user merely |
| 1119 | // visited. Anything that can set application/json cross-origin has to pass a |
| 1120 | // CORS preflight first, and this console answers none. |
| 1121 | func readJSON(w http.ResponseWriter, r *http.Request, out any) bool { |
| 1122 | // A login request — and every other door but the config PUT — is a |
| 1123 | // handful of short strings; anything larger is not one of ours. |
| 1124 | return readJSONLimit(w, r, out, 8<<10) |
| 1125 | } |
| 1126 | |
| 1127 | // readJSONLimit is readJSON with the caller's own byte cap — the seam the |
| 1128 | // config PUT door uses (apiconfig.go), because a request carrying sealed |
| 1129 | // secret envelopes is legitimately larger than "a handful of short strings": |
| 1130 | // an envelope is ~4/3 the size of the plaintext it seals, and one long |
| 1131 | // base64 credential can pass 8KiB on its own. A body over the cap answers |
| 1132 | // its own 413 rather than hiding inside "malformed JSON body" — the caller's |
| 1133 | // fix (split the request, shorten the value) is nothing like a JSON fix. |
| 1134 | func readJSONLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool { |
| 1135 | return decodeJSONBody(w, r, out, limit, false) |
| 1136 | } |
| 1137 | |
| 1138 | // readJSONStrictLimit is readJSONLimit that also refuses a field the struct |
| 1139 | // does not declare, with a 400 that NAMES it. |
| 1140 | // |
| 1141 | // It is for the doors where an unrecognised key would otherwise be dropped and |
| 1142 | // the request acted on as if it had not been sent — which, at a door whose job |
| 1143 | // is to NARROW something, silently widens it. The narrowed restart door |
| 1144 | // (apirestartscope.go) is the case it was written for: a client that renames |
| 1145 | // `host` to `hosts` would, without this, POST a body with no selector in it |
| 1146 | // and be answered by a restart of every route the app owns. A door that exists |
| 1147 | // because another door widens must not itself widen on a typo. |
| 1148 | // |
| 1149 | // The unknown-field check is a prefix match on encoding/json's own message |
| 1150 | // because the package gives no typed error for it; the message is stable and |
| 1151 | // already names the field, which is the whole thing the caller needs. |
| 1152 | func readJSONStrictLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool { |
| 1153 | return decodeJSONBody(w, r, out, limit, true) |
| 1154 | } |
| 1155 | |
| 1156 | // decodeJSONBody is the shared body of the two above: content type, byte cap, |
| 1157 | // decode, and one refusal per failure shape. |
| 1158 | func decodeJSONBody(w http.ResponseWriter, r *http.Request, out any, limit int64, strict bool) bool { |
| 1159 | ct, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";") |
| 1160 | if strings.TrimSpace(strings.ToLower(ct)) != "application/json" { |
| 1161 | jsonError(w, http.StatusUnsupportedMediaType, "send a JSON body with Content-Type: application/json") |
| 1162 | return false |
| 1163 | } |
| 1164 | r.Body = http.MaxBytesReader(w, r.Body, limit) |
| 1165 | dec := json.NewDecoder(r.Body) |
| 1166 | if strict { |
| 1167 | dec.DisallowUnknownFields() |
| 1168 | } |
| 1169 | if err := dec.Decode(out); err != nil { |
| 1170 | var tooLarge *http.MaxBytesError |
| 1171 | if errors.As(err, &tooLarge) { |
| 1172 | jsonError(w, http.StatusRequestEntityTooLarge, "request body too large") |
| 1173 | return false |
| 1174 | } |
| 1175 | if strict && strings.HasPrefix(err.Error(), "json: unknown field ") { |
| 1176 | jsonError(w, http.StatusBadRequest, err.Error()+ |
| 1177 | " — this door refuses a body it does not fully understand rather than acting on the part it does") |
| 1178 | return false |
| 1179 | } |
| 1180 | jsonError(w, http.StatusBadRequest, "malformed JSON body") |
| 1181 | return false |
| 1182 | } |
| 1183 | return true |
| 1184 | } |
| 1185 | |
| 1186 | // bearer pulls the token out of the Authorization header. |
| 1187 | func bearer(r *http.Request) string { |
| 1188 | h := r.Header.Get("Authorization") |
| 1189 | if len(h) < 7 || !strings.EqualFold(h[:7], "bearer ") { |
| 1190 | return "" |
| 1191 | } |
| 1192 | return strings.TrimSpace(h[7:]) |
| 1193 | } |
| 1194 | |
| 1195 | // cliNotSignedIn is the ONE 401 the CLI surface gives, written once so the |
| 1196 | // two authenticators cannot drift. cliUser answers it for a person's door and |
| 1197 | // cliRoute for an API door, and the bodies must be byte-identical: a |
| 1198 | // differently-phrased 401 over here would make "this bearer is a service |
| 1199 | // credential at a person's door" distinguishable from "this bearer does not |
| 1200 | // exist", which is the whole property the person-only doors hold. |
| 1201 | // Pinned by TestCLIRouteUnauthorizedMatchesCLIUser. |
| 1202 | func (s *Server) cliNotSignedIn(w http.ResponseWriter) { |
| 1203 | jsonError(w, http.StatusUnauthorized, "not signed in — run: carlos auth login --console "+s.cfg.Origin) |
| 1204 | } |
| 1205 | |
| 1206 | // cliUser authenticates a CLI request AS A PERSON. Every failure is the same |
| 1207 | // 401 with the same body: an expired token, a revoked one, a fabricated one |
| 1208 | // and a service credential are not distinguishable from outside. |
| 1209 | func (s *Server) cliUser(w http.ResponseWriter, r *http.Request) (string, bool) { |
| 1210 | addr, ok := s.store.CLITokenAddress(bearer(r), time.Now()) |
| 1211 | if !ok { |
| 1212 | s.cliNotSignedIn(w) |
| 1213 | return "", false |
| 1214 | } |
| 1215 | return addr, true |
| 1216 | } |
| 1217 | |
| 1218 | // ---- the flow --------------------------------------------------------------- |
| 1219 | |
| 1220 | // cleanLabel bounds and de-fangs what a terminal calls itself. |
| 1221 | // |
| 1222 | // PRINTABLE ASCII ONLY, which is stricter than it looks like it needs to be. |
| 1223 | // This is the one field on the approval page a human is asked to recognise — |
| 1224 | // "is that my terminal?" — and it is chosen by whoever is asking for the |
| 1225 | // credential. Allowing the full printable Unicode range would allow |
| 1226 | // right-to-left overrides and zero-width joiners, which exist precisely to |
| 1227 | // make a string render as something other than what it is. A label is |
| 1228 | // user@host; nobody needs U+202E in one. |
| 1229 | func cleanLabel(in string) string { |
| 1230 | var b strings.Builder |
| 1231 | for _, r := range in { |
| 1232 | if r < 0x20 || r > 0x7e { |
| 1233 | continue |
| 1234 | } |
| 1235 | b.WriteRune(r) |
| 1236 | if b.Len() >= 64 { |
| 1237 | break |
| 1238 | } |
| 1239 | } |
| 1240 | return strings.TrimSpace(b.String()) |
| 1241 | } |
| 1242 | |
| 1243 | // handleCLIStart opens a login attempt. Unauthenticated by necessity — the |
| 1244 | // terminal has no credential yet — which is why it is rate-limited per IP, |
| 1245 | // mints nothing but a ten-minute row, and grants nothing until a signed-in |
| 1246 | // human approves it. |
| 1247 | func (s *Server) handleCLIStart(w http.ResponseWriter, r *http.Request) { |
| 1248 | var req struct { |
| 1249 | Label string `json:"label"` |
| 1250 | } |
| 1251 | if !readJSON(w, r, &req) { |
| 1252 | return |
| 1253 | } |
| 1254 | ip := s.clientIP(r) |
| 1255 | if !s.cli.start.allow(ip, time.Now()) { |
| 1256 | jsonError(w, http.StatusTooManyRequests, "too many login attempts from here — wait a minute and try again") |
| 1257 | return |
| 1258 | } |
| 1259 | userCode, deviceCode, err := s.store.NewCLIAuth(cleanLabel(req.Label), ip, time.Now()) |
| 1260 | if err != nil { |
| 1261 | log.Printf("console: cli auth start: %v", err) |
| 1262 | jsonError(w, http.StatusInternalServerError, "could not start the login — try again") |
| 1263 | return |
| 1264 | } |
| 1265 | writeJSON(w, http.StatusOK, map[string]any{ |
| 1266 | "user_code": userCode, |
| 1267 | "device_code": deviceCode, |
| 1268 | "verification_uri": s.cfg.Origin + "/auth/cli", |
| 1269 | "verification_uri_complete": s.cfg.Origin + "/auth/cli?code=" + url.QueryEscape(userCode), |
| 1270 | "interval": cliPollInterval, |
| 1271 | "expires_in": int(CLIAuthTTL.Seconds()), |
| 1272 | }) |
| 1273 | } |
| 1274 | |
| 1275 | // handleCLIPoll is the CLI's waiting room. Every answer is 200 with a status |
| 1276 | // word — the CLI is a program, and a status it can switch on beats an HTTP |
| 1277 | // code it has to interpret. |
| 1278 | func (s *Server) handleCLIPoll(w http.ResponseWriter, r *http.Request) { |
| 1279 | var req struct { |
| 1280 | DeviceCode string `json:"device_code"` |
| 1281 | } |
| 1282 | if !readJSON(w, r, &req) { |
| 1283 | return |
| 1284 | } |
| 1285 | // Both keys, and the IP one first. The device code is chosen by the |
| 1286 | // caller, so a per-code limit alone is no limit at all — every made-up |
| 1287 | // code is a fresh budget, and a fresh map entry. The per-code limit still |
| 1288 | // earns its place: it stops one honest-but-broken client spinning, without |
| 1289 | // two developers behind one NAT throttling each other. |
| 1290 | now := time.Now() |
| 1291 | if !s.cli.pollIP.allow(s.clientIP(r), now) || !s.cli.poll.allow(hashToken(req.DeviceCode), now) { |
| 1292 | jsonError(w, http.StatusTooManyRequests, "polling too fast") |
| 1293 | return |
| 1294 | } |
| 1295 | status, token, addr, err := s.store.PollCLIAuth(req.DeviceCode, time.Now()) |
| 1296 | if err != nil { |
| 1297 | log.Printf("console: cli auth poll: %v", err) |
| 1298 | jsonError(w, http.StatusInternalServerError, "could not check the login — try again") |
| 1299 | return |
| 1300 | } |
| 1301 | out := map[string]any{"status": status, "interval": cliPollInterval} |
| 1302 | if status == CLIAuthApproved { |
| 1303 | out["token"] = token |
| 1304 | out["address"] = addr |
| 1305 | out["expires_at"] = time.Now().Add(CLITokenTTL).UTC().Format(time.RFC3339) |
| 1306 | out["console"] = s.cfg.Origin |
| 1307 | go s.notifyCLIIssued(addr, token) |
| 1308 | } |
| 1309 | writeJSON(w, http.StatusOK, out) |
| 1310 | } |
| 1311 | |
| 1312 | // notifyCLIIssued tells the human that a terminal now holds their identity — |
| 1313 | // naming it, where it dialled from, and how to kill it. Someone who signed in |
| 1314 | // with Keymail hears in their own Keymail inbox, through their grant; someone |
| 1315 | // who signed in by email (no grant) hears through deliver: Keymail first if |
| 1316 | // the address turns out to have a mailbox there, else the console's own email. |
| 1317 | // |
| 1318 | // This is the control that makes a phished approval DETECTABLE. Every other |
| 1319 | // defence in this flow is preventive and therefore fallible; a message that |
| 1320 | // arrives whether or not the approval was a good idea is what turns "somebody |
| 1321 | // has been me for two months" into "I revoked that within the hour". Same |
| 1322 | // channels as invites. |
| 1323 | // |
| 1324 | // Off the request goroutine on purpose: a slow Keymail must never stall the |
| 1325 | // poll the CLI is waiting on. Issuance has already happened and the request row |
| 1326 | // is burnt, so a CLI that gave up would otherwise leave a token nobody |
| 1327 | // collected. Best-effort throughout: a notice that cannot be delivered is |
| 1328 | // logged, never a reason to fail a login. |
| 1329 | func (s *Server) notifyCLIIssued(address, rawToken string) { |
| 1330 | defer func() { |
| 1331 | if s.cliNotified != nil { |
| 1332 | s.cliNotified <- struct{}{} |
| 1333 | } |
| 1334 | }() |
| 1335 | grant, err := s.store.GrantToken(address) |
| 1336 | if err != nil { |
| 1337 | return |
| 1338 | } |
| 1339 | label, from := "", "" |
| 1340 | if toks, err := s.store.CLITokens(address); err == nil { |
| 1341 | for _, t := range toks { |
| 1342 | if t.ID == hashToken(rawToken) { |
| 1343 | label, from = t.Label, t.RemoteAddr |
| 1344 | } |
| 1345 | } |
| 1346 | } |
| 1347 | if label == "" { |
| 1348 | label = "(unnamed terminal)" |
| 1349 | } |
| 1350 | if from == "" { |
| 1351 | from = "an address the console could not determine" |
| 1352 | } |
| 1353 | subject := "A command line signed in as you on the CARLOS platform" |
| 1354 | body := "A terminal was just approved to act as " + address + " on the CARLOS platform.\n\n" + |
| 1355 | " terminal: " + label + "\n" + |
| 1356 | " from: " + from + "\n" + |
| 1357 | " when: " + time.Now().UTC().Format("2006-01-02 15:04 UTC") + "\n\n" + |
| 1358 | "It can do anything you can do from the command line, for the next " + |
| 1359 | fmt.Sprint(int(CLITokenTTL/(24*time.Hour))) + " days.\n\n" + |
| 1360 | "If that was you running `carlos auth login`, nothing to do.\n\n" + |
| 1361 | "If it was NOT you — if you clicked a link somebody sent you — revoke it now:\n\n" + |
| 1362 | s.cfg.Origin + "/settings\n" |
| 1363 | if grant == "" { |
| 1364 | // Signed in by email: no grant to send with. Keymail first if the |
| 1365 | // address turns out to have a mailbox, then the console's own email. |
| 1366 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| 1367 | defer cancel() |
| 1368 | if _, err := s.deliver(ctx, address, subject, body, 12*time.Second); err != nil { |
| 1369 | log.Printf("console: could not notify %s of a CLI token issue: %v", address, err) |
| 1370 | } |
| 1371 | return |
| 1372 | } |
| 1373 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| 1374 | defer cancel() |
| 1375 | if _, err := s.km.SendApp(ctx, grant, address, subject, body); err != nil { |
| 1376 | log.Printf("console: could not notify %s of a CLI token issue: %v", address, err) |
| 1377 | } |
| 1378 | } |
| 1379 | |
| 1380 | // cliPage is the approval and tokens pages' data. It embeds page so the |
| 1381 | // shared chrome ("top"/"bottom") keeps working unchanged. |
| 1382 | type cliPage struct { |
| 1383 | page |
| 1384 | Code string // the code the human typed, echoed back into the form |
| 1385 | // LinkCode is the code carried by the ?code= in the URL they followed. |
| 1386 | // Shown ONLY as something to check against the terminal's screen — never |
| 1387 | // prefilled into the form, never resolved. See handleCLIApprovePage. |
| 1388 | LinkCode string |
| 1389 | Console string // this deployment's origin — the -console the CLI needs |
| 1390 | Request *CLIAuthRequest |
| 1391 | Tokens []CLIToken |
| 1392 | Days int |
| 1393 | // ServiceAccounts maps a service credential's account id to that |
| 1394 | // account's sqid, so the read-only mirror can say WHERE a row is managed |
| 1395 | // and link there. Built here rather than hung on CLIToken because the |
| 1396 | // sqid is page chrome, not part of the stored row — and a row whose |
| 1397 | // account this console cannot resolve is simply absent from the map, |
| 1398 | // which the template renders as "managed on its own account" rather than |
| 1399 | // as a broken link. |
| 1400 | ServiceAccounts map[int64]string |
| 1401 | } |
| 1402 | |
| 1403 | func (s *Server) renderCLI(w http.ResponseWriter, status int, name string, p cliPage) { |
| 1404 | s.chrome(&p.page) |
| 1405 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 1406 | w.WriteHeader(status) |
| 1407 | if err := templates.ExecuteTemplate(w, name, p); err != nil { |
| 1408 | log.Printf("console: render %s: %v", name, err) |
| 1409 | } |
| 1410 | } |
| 1411 | |
| 1412 | // handleCLIApprovePage is where the printed link lands. A signed-out visitor |
| 1413 | // gets the front page, asked to sign in, with next set back here (spec 8). A |
| 1414 | // code-shaped ?code= rides along in next only so it can be shown again for |
| 1415 | // comparison; the console never carries a pending approval through an |
| 1416 | // authentication it has not finished. |
| 1417 | // |
| 1418 | // IT DELIBERATELY DOES NOT ACT ON ?code=. Not to resolve it, not to look it |
| 1419 | // up, and above all not to render an Approve button for it. A prefilled code |
| 1420 | // that arrives one click from approval is the device-code phishing attack that |
| 1421 | // has been run at scale against Microsoft tenants: the attacker starts the |
| 1422 | // flow, sends the victim the ready-made link with a plausible pretext, and one |
| 1423 | // click binds a 90-day credential — held by the attacker — to the victim's |
| 1424 | // identity. Requiring the code to be TYPED is what breaks it: to type it you |
| 1425 | // have to be reading it off a terminal you started, and a code you cannot see |
| 1426 | // is one you cannot transcribe. The value from the URL is shown only as |
| 1427 | // something to check against your screen, never as something to submit. |
| 1428 | func (s *Server) handleCLIApprovePage(w http.ResponseWriter, r *http.Request) { |
| 1429 | addr, raw, ok := s.session(r) |
| 1430 | if !ok { |
| 1431 | next := "/auth/cli" |
| 1432 | if c := NormalizeUserCode(r.URL.Query().Get("code")); len(c) == userCodeLen+1 { |
| 1433 | next += "?code=" + url.QueryEscape(c) |
| 1434 | } |
| 1435 | s.renderHome(w, r, homeForm{Next: next, Banner: copyText("home.cli_prompt")}) |
| 1436 | return |
| 1437 | } |
| 1438 | p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw}, |
| 1439 | Days: int(CLITokenTTL / (24 * time.Hour))} |
| 1440 | // Echoed for comparison only — no store lookup, which also means this page |
| 1441 | // is not an oracle for enumerating live codes. |
| 1442 | p.LinkCode = NormalizeUserCode(r.URL.Query().Get("code")) |
| 1443 | s.renderCLI(w, http.StatusOK, "cli_approve", p) |
| 1444 | } |
| 1445 | |
| 1446 | // handleCLIApprove is both steps a human takes, distinguished by action: |
| 1447 | // |
| 1448 | // continue — a typed code is looked up and the terminal asking is DESCRIBED |
| 1449 | // (label, where it dialled from, when it expires) |
| 1450 | // approve — the decision, on a code the human has now both typed and seen |
| 1451 | // described |
| 1452 | // deny — "that wasn't me", which is final |
| 1453 | // |
| 1454 | // All three are session-gated and CSRF-checked with the double-submitted |
| 1455 | // session token, exactly like every other mutation here: a cross-site POST |
| 1456 | // that could approve a terminal would be the whole attack. |
| 1457 | func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) { |
| 1458 | addr, raw, ok := s.session(r) |
| 1459 | if !ok { |
| 1460 | http.Redirect(w, r, "/", http.StatusFound) |
| 1461 | return |
| 1462 | } |
| 1463 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 1464 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 1465 | return |
| 1466 | } |
| 1467 | code := NormalizeUserCode(r.FormValue("code")) |
| 1468 | action := r.FormValue("action") |
| 1469 | now := time.Now() |
| 1470 | p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw}, |
| 1471 | Code: code, Days: int(CLITokenTTL / (24 * time.Hour))} |
| 1472 | |
| 1473 | // Wrong codes are counted wherever a code is resolved, and against both |
| 1474 | // the signed-in address and the client IP. The exposure this bounds is not |
| 1475 | // the obvious one: guessing somebody else's PENDING code and approving it |
| 1476 | // binds THEIR terminal to YOUR address, which is the inverted-payoff half |
| 1477 | // of this flow and the reason the counter has to exist even though ~39 |
| 1478 | // bits in ten minutes is not findable. |
| 1479 | badCode := func() bool { |
| 1480 | ip := s.clientIP(r) |
| 1481 | okAddr := s.cli.guessAddr.allow(addr, now) |
| 1482 | okIP := s.cli.guessIP.allow(ip, now) |
| 1483 | return okAddr && okIP |
| 1484 | } |
| 1485 | tooMany := func() { |
| 1486 | s.renderMessage(w, r, http.StatusTooManyRequests, "Too many attempts", |
| 1487 | "Too many bad login codes. Wait a minute, then try again.") |
| 1488 | } |
| 1489 | |
| 1490 | switch action { |
| 1491 | case "approve", "deny": |
| 1492 | // Passkey step-up on APPROVE only (spec §7a, stepup.go). Approving |
| 1493 | // mints a 30-day credential that acts as this identity from a machine |
| 1494 | // that has never been seen — the single largest thing a stolen session |
| 1495 | // cookie could do here, and the reason presence has to be proved rather |
| 1496 | // than inferred. Denying is left ungated on purpose: "that wasn't me" |
| 1497 | // is the panic button, it only ever destroys a pending request, and |
| 1498 | // putting an authenticator prompt in front of it would be friction on |
| 1499 | // exactly the path we want people to take without hesitating. |
| 1500 | if action == "approve" && !s.requireFreshAuth(w, r, addr, raw) { |
| 1501 | return |
| 1502 | } |
| 1503 | var err error |
| 1504 | if action == "deny" { |
| 1505 | err = s.store.DenyCLIAuth(code, now) |
| 1506 | } else { |
| 1507 | err = s.store.ApproveCLIAuth(code, addr, now) |
| 1508 | } |
| 1509 | if err != nil { |
| 1510 | if !badCode() { |
| 1511 | tooMany() |
| 1512 | return |
| 1513 | } |
| 1514 | p.Error = "That code has expired or was already used. Run carlos auth login again for a fresh one." |
| 1515 | s.renderCLI(w, http.StatusOK, "cli_approve", p) |
| 1516 | return |
| 1517 | } |
| 1518 | p.Title = "Approved" |
| 1519 | if action == "deny" { |
| 1520 | p.Title = "Denied" |
| 1521 | } |
| 1522 | s.renderCLI(w, http.StatusOK, "cli_decided", p) |
| 1523 | default: |
| 1524 | // "continue": describe the terminal, then let the human decide. |
| 1525 | req, err := s.store.CLIAuthByUserCode(code, now) |
| 1526 | if err != nil { |
| 1527 | if !badCode() { |
| 1528 | tooMany() |
| 1529 | return |
| 1530 | } |
| 1531 | p.Error = "No login is waiting on that code — check what your terminal printed. " + |
| 1532 | "Codes last ten minutes." |
| 1533 | s.renderCLI(w, http.StatusOK, "cli_approve", p) |
| 1534 | return |
| 1535 | } |
| 1536 | p.Request = req |
| 1537 | s.renderCLI(w, http.StatusOK, "cli_approve", p) |
| 1538 | } |
| 1539 | } |
| 1540 | |
| 1541 | // serviceAccountSqids resolves the accounts the viewer's SERVICE credentials |
| 1542 | // are bound to, for the read-only mirror's "lives on account <sqid> — manage |
| 1543 | // there" link. |
| 1544 | // |
| 1545 | // Service rows only: an app token keeps its own Revoke button on this page |
| 1546 | // (it always has had one, and its minter is not necessarily an account |
| 1547 | // owner), so it needs no pointer elsewhere. A lookup that fails is left out |
| 1548 | // of the map rather than filled with a guess — a link to /a//settings is |
| 1549 | // worse than plain words. |
| 1550 | func (s *Server) serviceAccountSqids(toks []CLIToken) map[int64]string { |
| 1551 | out := map[int64]string{} |
| 1552 | for _, t := range toks { |
| 1553 | if !t.IsService() || t.AccountID == 0 { |
| 1554 | continue |
| 1555 | } |
| 1556 | if _, done := out[t.AccountID]; done { |
| 1557 | continue |
| 1558 | } |
| 1559 | acct, err := s.store.AccountByID(t.AccountID) |
| 1560 | if err != nil || acct == nil { |
| 1561 | log.Printf("console: settings mirror: account %d: %v", t.AccountID, err) |
| 1562 | continue |
| 1563 | } |
| 1564 | out[t.AccountID] = acct.Sqid |
| 1565 | } |
| 1566 | return out |
| 1567 | } |
| 1568 | |
| 1569 | // handleCLITokens lists the signed-in user's own CLI credentials. Per USER, |
| 1570 | // not per account: a token is an identity, and it carries whatever accounts |
| 1571 | // that identity belongs to — so the person who can kill it is the person it |
| 1572 | // speaks as. |
| 1573 | // |
| 1574 | // SERVICE credentials the viewer minted stay listed here, READ-ONLY. They are |
| 1575 | // managed on their account's own pane (svcpage.go), where the owner gate and |
| 1576 | // the audit trail live — but absorbing them into that listing must not make |
| 1577 | // them vanish from a page people already know, so the row stays and points |
| 1578 | // there instead of carrying a button. |
| 1579 | func (s *Server) handleCLITokens(w http.ResponseWriter, r *http.Request) { |
| 1580 | addr, raw, ok := s.session(r) |
| 1581 | if !ok { |
| 1582 | http.Redirect(w, r, "/", http.StatusFound) |
| 1583 | return |
| 1584 | } |
| 1585 | toks, err := s.store.CLITokens(addr) |
| 1586 | if err != nil { |
| 1587 | log.Printf("console: cli tokens: %v", err) |
| 1588 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your terminals. Please try again.") |
| 1589 | return |
| 1590 | } |
| 1591 | p := cliPage{page: page{Title: "Your settings", Address: addr, CSRF: raw}, |
| 1592 | Tokens: toks, Console: s.cfg.Origin, Days: int(CLITokenTTL / (24 * time.Hour)), |
| 1593 | ServiceAccounts: s.serviceAccountSqids(toks)} |
| 1594 | p.Flash, p.FlashErr = s.takeFlash(w, r) |
| 1595 | s.renderCLI(w, http.StatusOK, "settings", p) |
| 1596 | } |
| 1597 | |
| 1598 | // serviceRowOf finds a live SERVICE credential of this address's by listing |
| 1599 | // id, for handleCLITokenRevoke's refusal below. It answers three ways — |
| 1600 | // found, not-a-service, and COULD NOT TELL — because the third is not the |
| 1601 | // second. |
| 1602 | // |
| 1603 | // ok=false with err!=nil means the read failed, and the caller FAILS CLOSED |
| 1604 | // on it. That is the asymmetry of the two mistakes: refusing a person's own |
| 1605 | // terminal revoke for as long as a database hiccup lasts is annoying and |
| 1606 | // retryable, while letting an unreadable row through routes a service |
| 1607 | // credential into RevokeCLIToken's hard DELETE, which is neither. |
| 1608 | func (s *Server) serviceRowOf(addr, id string) (tok CLIToken, isService bool, err error) { |
| 1609 | if id == "" { |
| 1610 | return CLIToken{}, false, nil |
| 1611 | } |
| 1612 | toks, err := s.store.CLITokens(addr) |
| 1613 | if err != nil { |
| 1614 | return CLIToken{}, false, err |
| 1615 | } |
| 1616 | for _, t := range toks { |
| 1617 | if t.ID == id { |
| 1618 | return t, t.IsService(), nil |
| 1619 | } |
| 1620 | } |
| 1621 | return CLIToken{}, false, nil |
| 1622 | } |
| 1623 | |
| 1624 | func (s *Server) handleCLITokenRevoke(w http.ResponseWriter, r *http.Request) { |
| 1625 | addr, raw, ok := s.session(r) |
| 1626 | if !ok { |
| 1627 | http.Redirect(w, r, "/", http.StatusFound) |
| 1628 | return |
| 1629 | } |
| 1630 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 1631 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 1632 | return |
| 1633 | } |
| 1634 | // C49: a SERVICE credential is not revocable here, and withholding the |
| 1635 | // button from the template was never the control — this door accepts any |
| 1636 | // listing id whose row carries the viewer's address, and a service |
| 1637 | // credential's `address` column IS its minter. |
| 1638 | // |
| 1639 | // Three things make that the wrong door rather than merely a second one. |
| 1640 | // RevokeCLIToken is a hard DELETE where the account pane writes the |
| 1641 | // revoked_at TOMBSTONE the store's design depends on (CLIPrincipal goes |
| 1642 | // on refusing the exact secret; a later mint of the same name gets a NEW |
| 1643 | // row rather than resurrecting this one's identity). It writes no ACCOUNT |
| 1644 | // audit row, so the feed an owner reads would say nothing — which would |
| 1645 | // hollow out the very assurance the pane's missing revoke-confirmation |
| 1646 | // leans on (see handleServiceRevokePage, C48). And it is scoped to the |
| 1647 | // MINTER, so it outlives ownership: mint a credential, be transferred out |
| 1648 | // of the account, and this door would still let you stop a production |
| 1649 | // service you no longer have any authority over. |
| 1650 | // |
| 1651 | // Refused BEFORE requireFreshAuth: no gesture could make this the right |
| 1652 | // door, so asking for one would waste it. |
| 1653 | // |
| 1654 | // SCOPED TO SERVICE ROWS ONLY. A legacy kind='app' row keeps working here |
| 1655 | // (TestPersonalSettingsStillRevokesALegacyAppToken pins it) — its minter |
| 1656 | // is not necessarily an account owner, so this may be the only door they |
| 1657 | // have. The same hard-DELETE-and-no-audit inconsistency is true of it and |
| 1658 | // predates this branch; widening the refusal would take away a working |
| 1659 | // control to fix nothing here. |
| 1660 | // |
| 1661 | // But only while the minter is still a MEMBER of the token's account |
| 1662 | // (discussion #219, item 3). The app token speaks for that account's app, |
| 1663 | // so revoking it stops that app's service; somebody removed from the |
| 1664 | // account has no authority over it any more, and having minted it once |
| 1665 | // does not keep any. |
| 1666 | svc, isService, err := s.serviceRowOf(addr, r.FormValue("id")) |
| 1667 | switch { |
| 1668 | case err != nil: |
| 1669 | log.Printf("console: cli token revoke: classify %s: %v", addr, err) |
| 1670 | s.setFlash(w, "err", "Could not check what that credential is, so nothing was revoked. Try again.") |
| 1671 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 1672 | return |
| 1673 | case isService: |
| 1674 | where := "its account's settings page" |
| 1675 | if acct, err := s.store.AccountByID(svc.AccountID); err == nil && acct != nil { |
| 1676 | where = "/a/" + acct.Sqid + "/settings" |
| 1677 | } |
| 1678 | s.setFlash(w, "err", "“"+svc.Name+"” is a service credential, not a terminal of yours — it "+ |
| 1679 | "belongs to its account, and is revoked from "+where+", where the revocation is "+ |
| 1680 | "recorded in that account's activity. Nothing was revoked.") |
| 1681 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 1682 | return |
| 1683 | case svc.IsApp(): |
| 1684 | // Fails closed on a read error, the same direction as the classify |
| 1685 | // above, and is refused before the passkey for the same reason. |
| 1686 | role, rerr := s.store.Role(svc.AccountID, addr) |
| 1687 | if rerr != nil { |
| 1688 | log.Printf("console: cli token revoke: membership of %s in %d: %v", addr, svc.AccountID, rerr) |
| 1689 | s.setFlash(w, "err", "Could not check your membership, so nothing was revoked. Try again.") |
| 1690 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 1691 | return |
| 1692 | } |
| 1693 | if role == "" { |
| 1694 | s.setFlash(w, "err", "That credential belongs to an account you are no longer a member of, "+ |
| 1695 | "so it is not yours to revoke. Nothing was revoked.") |
| 1696 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 1697 | return |
| 1698 | } |
| 1699 | } |
| 1700 | // Step-up (spec §7a): killing a terminal is a security action, and the |
| 1701 | // person doing it should be the person whose terminals they are. An |
| 1702 | // attacker with a stolen cookie revoking the real owner's credentials is a |
| 1703 | // lockout, and the freshness check is what stops it being free. |
| 1704 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 1705 | return |
| 1706 | } |
| 1707 | // Scoped to the signed-in address inside the store call: an id belonging |
| 1708 | // to somebody else simply is not found. |
| 1709 | // |
| 1710 | // A legacy app token's DELETE is admitted for its app (the claim gate), so |
| 1711 | // it runs under the request context; a refused or failed admission is not |
| 1712 | // "already gone", and the row is still live. |
| 1713 | if err := s.store.RevokeCLITokenContext(r.Context(), addr, r.FormValue("id")); err != nil { |
| 1714 | if s.cfg.HostClaim && !errors.Is(err, errNoSuchCLIToken) { |
| 1715 | log.Printf("console: cli token revoke %s: %v", addr, err) |
| 1716 | s.setFlash(w, "err", "Could not revoke that credential, so it may still be live. Try again.") |
| 1717 | } else { |
| 1718 | s.setFlash(w, "err", "That terminal is already gone.") |
| 1719 | } |
| 1720 | } else { |
| 1721 | s.setFlash(w, "ok", "Revoked — that terminal can no longer act as you.") |
| 1722 | } |
| 1723 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 1724 | } |
| 1725 | |
| 1726 | // handleCLIWhoami answers `carlos auth whoami`: who this token speaks as, and |
| 1727 | // until when. It is also the liveness check every verb that acts on the |
| 1728 | // token's identity makes before acting. |
| 1729 | func (s *Server) handleCLIWhoami(w http.ResponseWriter, r *http.Request) { |
| 1730 | addr, ok := s.cliUser(w, r) |
| 1731 | if !ok { |
| 1732 | return |
| 1733 | } |
| 1734 | out := map[string]any{"address": addr, "console": s.cfg.Origin} |
| 1735 | if toks, err := s.store.CLITokens(addr); err == nil { |
| 1736 | hash := hashToken(bearer(r)) |
| 1737 | for _, t := range toks { |
| 1738 | if t.ID == hash { |
| 1739 | out["label"] = t.Label |
| 1740 | out["expires_at"] = t.ExpiresAt.Format(time.RFC3339) |
| 1741 | } |
| 1742 | } |
| 1743 | } |
| 1744 | writeJSON(w, http.StatusOK, out) |
| 1745 | } |
| 1746 | |
| 1747 | // handleCLIRevoke is `carlos auth logout`: retire this credential rather than |
| 1748 | // merely forgetting it locally. |
| 1749 | func (s *Server) handleCLIRevoke(w http.ResponseWriter, r *http.Request) { |
| 1750 | if _, ok := s.cliUser(w, r); !ok { |
| 1751 | return |
| 1752 | } |
| 1753 | if err := s.store.RevokeCLITokenValueContext(r.Context(), bearer(r)); err != nil { |
| 1754 | log.Printf("console: cli revoke: %v", err) |
| 1755 | jsonError(w, http.StatusInternalServerError, "could not revoke the token") |
| 1756 | return |
| 1757 | } |
| 1758 | writeJSON(w, http.StatusOK, map[string]any{"revoked": true}) |
| 1759 | } |
| 1760 | |
| 1761 | // handleCLINewAccount is the point of the whole feature: `carlos accounts |
| 1762 | // create` from a logged-in CLI mints the account THROUGH the console, so the |
| 1763 | // human who ran it is its owner from the first instant. |
| 1764 | // |
| 1765 | // The alternative — the CLI minting straight into the bucket and hoping |
| 1766 | // something later notices who owns it — is exactly what produced accounts |
| 1767 | // nobody could open in the console. The console already has mintAccount: the |
| 1768 | // bucket manifest and the owner membership in one path. |
| 1769 | func (s *Server) handleCLINewAccount(w http.ResponseWriter, r *http.Request) { |
| 1770 | addr, ok := s.cliUser(w, r) |
| 1771 | if !ok { |
| 1772 | return |
| 1773 | } |
| 1774 | if !s.cli.write.allow(addr, time.Now()) { |
| 1775 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 1776 | return |
| 1777 | } |
| 1778 | var req struct { |
| 1779 | Name string `json:"name"` |
| 1780 | } |
| 1781 | if !readJSON(w, r, &req) { |
| 1782 | return |
| 1783 | } |
| 1784 | name := strings.TrimSpace(req.Name) |
| 1785 | if !domains.ValidAccountName(name) { |
| 1786 | jsonError(w, http.StatusBadRequest, |
| 1787 | "an account name appears in URLs and --account: 1-63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen") |
| 1788 | return |
| 1789 | } |
| 1790 | ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| 1791 | defer cancel() |
| 1792 | // Plans 3a: one free account per person (spec §8.1). |
| 1793 | defer s.planPersonLock(addr)() |
| 1794 | if err := s.checkNewFreeAccount(addr); err != nil { |
| 1795 | if isPlanCap(err) { |
| 1796 | writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| 1797 | return |
| 1798 | } |
| 1799 | log.Printf("console: cli mint account for %s: plan cap: %v", addr, err) |
| 1800 | jsonError(w, http.StatusBadGateway, "could not create the account — try again") |
| 1801 | return |
| 1802 | } |
| 1803 | a, err := s.mintAccount(ctx, name, addr) |
| 1804 | if err != nil { |
| 1805 | // Logged, not returned: every other failure in this console answers a |
| 1806 | // caller with a fixed sentence and keeps the detail on the box. A |
| 1807 | // mint failure is a bucket error, and bucket errors carry key names, |
| 1808 | // endpoints and occasionally credentials-adjacent text. |
| 1809 | log.Printf("console: cli mint account for %s: %v", addr, err) |
| 1810 | jsonError(w, http.StatusBadGateway, "could not create the account — try again") |
| 1811 | return |
| 1812 | } |
| 1813 | writeJSON(w, http.StatusOK, map[string]any{ |
| 1814 | "id": a.ID, "sqid": a.Sqid, "name": a.Name, "owner": addr, |
| 1815 | "url": s.cfg.Origin + "/a/" + url.PathEscape(a.Sqid) + "/", |
| 1816 | }) |
| 1817 | } |
| 1818 | |