| new file mode 100644 |
| index 000000000..abbe666a8 |
| --- /dev/null |
| +++ b/internal/console/selfserve_gate.go |
| @@ -0,0 +1,93 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// The self-serve move doors' front half (self-serve moves plan 2, Task 1; |
| +// spec §5). In self-serve mode the three move mutations (request, cut, |
| +// abort) admit one credential only: an unrevoked instance token of exactly |
| +// this account and app that an account owner minted move-capable. Anything |
| +// else gets the move family's byte-identical 404 after authentication and |
| +// before any move evidence is read. |
| + |
| +// selfServeMode reports whether the three move mutation doors are the |
| +// self-serve doors: the switch is "offline" on a claims-off console with |
| +// Phase 1 off. Outside it they are the legacy doors, byte for byte. Task 4 |
| +// extends this with recovery mode (an open self-serve row, a pending |
| +// release, an undelivered event or a reservation found at start). |
| +func (s *Server) selfServeMode() bool { |
| + return s.cfg.SelfServeMoves == "offline" && !s.cfg.HostClaim && !s.cfg.MovesEnabled |
| +} |
| + |
| +// moveCapable reports whether p may ask for a self-serve move of acct's app: |
| +// a move-capable instance token bound to exactly this account and app. An |
| +// admin is refused here (operators have the operator doors), and so is a |
| +// person, a member or owner alike. |
| +func moveCapable(p Principal, acct *Account, app string) bool { |
| + return p.Kind == PrincipalService && p.Role == svcrole.Instance && |
| + p.AccountID == acct.ID && p.App == app && p.Moves |
| +} |
| + |
| +// selfServeGate is the self-serve mutations' gate, in this order: |
| +// |
| +// 1. authentication and the account/app binding (apiAccountAppSaying, in |
| +// main's sentences): an invalid or revoked credential keeps today's |
| +// answer, and this is the only SQL the gate performs; |
| +// 2. the hostname's shape, else the family's 404 (as moveGate); |
| +// 3. moveCapable, else the family's 404; |
| +// 4. and 5. the instance gate's rails (instanceRails): the rate limiter, |
| +// then the no-deployment-bucket 503. |
| +// |
| +// So an unauthorised caller always gets the 404, even under an exhausted |
| +// limiter or with no bucket, while an authorised caller keeps the rails. |
| +// The declared exception: an authorised token with a malformed hostname |
| +// gets the 404 before the rails, where moveGate would answer 429 or 503 |
| +// first. Nothing here reads the store or any move evidence. |
| +func (s *Server) selfServeGate(w http.ResponseWriter, r *http.Request) (p Principal, acct *Account, app, host string, ok bool) { |
| + c := mainMoveCopy().gate |
| + _, acct, app, ok = s.apiAccountAppSaying(w, r, s.store.HasAppIn, c) |
| + if !ok { |
| + return Principal{}, nil, "", "", false |
| + } |
| + p, _ = principalFrom(r.Context()) |
| + host = r.PathValue("host") |
| + if !provision.ValidHostShape(host) { |
| + http.NotFound(w, r) |
| + return Principal{}, nil, "", "", false |
| + } |
| + if !moveCapable(p, acct, app) { |
| + http.NotFound(w, r) |
| + return Principal{}, nil, "", "", false |
| + } |
| + if !s.instanceRails(w, r, acct, "instance", c) { |
| + return Principal{}, nil, "", "", false |
| + } |
| + return p, acct, app, host, true |
| +} |
| + |
| +// instanceRails are the instance gate's rails, applied after a caller is |
| +// proven: the per-token instance budget, then the deployment bucket. One |
| +// implementation for instanceGateSaying and selfServeGate, so both speak |
| +// the same sentences (noun and c) in the same order. |
| +func (s *Server) instanceRails(w http.ResponseWriter, r *http.Request, acct *Account, noun string, c gateCopy) bool { |
| + // Keyed on the TOKEN, not the address (see cliInstanceBurst): the |
| + // principal here is frequently an app, which has no address of its own, |
| + // and a signup burst is the workload rather than the abuse. |
| + if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, c.tooMany(noun)) |
| + return false |
| + } |
| + // A console with no deployment bucket configured can answer none of |
| + // these. Same shape as promote's signing-key check, and in the same |
| + // place: after the gate, before anything reaches for the store. |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, c.noBucket) |
| + return false |
| + } |
| + return true |
| +} |