console: a server no longer competes with its operator for budget
Task 5, issue #220 consequence 1: rate limits keyed on the principal. principalKey (svcprincipal.go) keys a person on their address, byte for byte as before, and a service on its NAME (not its token hash), so rotating a credential does not hand it a fresh budget. Three pickers — allowCreate, allowWrite, allowShip — choose the person or service limiter from the principal so no call site has to remember which one it is talking to; allowShip's person branch stays keyed on the token hash exactly as handleAPIShip already documented. Service budgets: svcCreateBurst=100/day, svcWriteBurst=120/min, svcShipBurst=60/hour. Converted every write/create call site that resolves through apiAccount/apiAccountApp/apiFleet across apicli.go, apiconfig.go, apiinstances.go, apiledger.go, apiretention.go, apistore.go, apiversion.go and fleets.go. Left untouched, deliberately: handleCLINewAccount (/auth/cli/accounts never sees a service principal), handleAPIShip's person branch (still token-keyed, per its own comment), s.cli.instances.allow (already token-keyed, cliInstanceBurst explains why), and the /auth/cli/ limiters (start, poll, pollIP, guessAddr, guessIP). The domains doors (attach/detach) are exempt from the write budget rather than uplifted: wildcard DNS means a Pages hub makes no per-repo hostname call at all. domainsGate is instanceGate with the budget step removed. The hazard the exemption has to survive is bounded instead: wildFailBurst=20/min counts only FAILED wildcard-enable attempts, after the CNAME lookup fails, so a succeeding enable never meets it and an idempotent re-attach (enabling=false) skips it entirely. setDomainCapabilities now takes the Principal instead of a bare audit-actor string, deriving the audit string with auditActor at the point it's needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
13 files changed,
+504
−75
internal/console/apicli.go+45 −30internal/console/apiconfig.go+15 −10internal/console/apidomains.go+46 −4internal/console/apidomains_test.go+91 −0internal/console/apiinstances.go+4 −2internal/console/apiledger.go+15 −11internal/console/apiretention.go+2 −1internal/console/apistore.go+10 −6internal/console/apiversion.go+5 −4internal/console/authcli.go+39 −0internal/console/authcli_test.go+163 −0internal/console/fleets.go+15 −7internal/console/svcprincipal.go+54 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 096d0ef1a..900d5cde0 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -139,9 +139,12 @@ func (s *Server) serviceBinding(p Principal) (*Account, bool) { |
| // It is the SHARED BODY, not a convenience for some future constituency. |
| // Three callers today: apiAccountAppBy and handleAPICreateApp, which both |
| // need the typed principal to check an app binding, and apiAccount, which |
| -// renders the audit actor for everyone else. Spec §4's principalKey moves |
| -// the rate limiters onto it in a later task; that is a consequence of this |
| -// shape, not the reason for it. |
| +// renders the audit actor for everyone else. Task 5's rate limiters |
| +// (allowCreate/allowWrite/allowShip, svcprincipal.go's principalKey) are the |
| +// budget-key constituency this shape was built for — they read the |
| +// principal back via principalFrom on the request context instead of |
| +// calling this a fourth time, the same principal cliRoute already resolved, |
| +// cheaper than resolving the account segment all over again. |
| // |
| // A SERVICE's scope is a BINDING, not a membership set, so the segment is |
| // matched as a sqid ONLY. It holds no memberships, so there is no set within |
| @@ -344,9 +347,11 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| addr := auditActor(p, acct) |
| - // Keyed on the ADDRESS, like every write budget here: a token-keyed |
| - // counter is one the holder of several tokens resets at will. |
| - if !s.cli.create.allow(addr, time.Now()) { |
| + // allowCreate picks the person or the service create budget from the |
| + // principal (svcprincipal.go) — a person keyed on the ADDRESS as before |
| + // (a token-keyed counter is one the holder of several tokens resets at |
| + // will), a service on its NAME, own budget from the operator's. |
| + if !s.allowCreate(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, |
| "app creation limit reached for today — use the console's New app form") |
| return |
| @@ -565,13 +570,15 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - // Keyed on the ADDRESS, not the token: the principal a write budget |
| - // belongs to is the PERSON, not the terminal they happen to be typing on |
| - // — the same key handleCLINewAccount already uses on this limiter. |
| - // Keying by token instead would give someone with several live tokens an |
| - // independent budget per token, which is the limit doing less than it |
| - // should, not more. |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + // allowWrite picks the person or the service write budget from the |
| + // principal (svcprincipal.go). A person is keyed on the ADDRESS, not the |
| + // token — the same key handleCLINewAccount already uses on this |
| + // limiter: keying by token instead would give someone with several live |
| + // tokens an independent budget per token, which is the limit doing less |
| + // than it should, not more. A service is keyed on its NAME, its own |
| + // budget separate from the operator who minted it (issue #220). |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -688,13 +695,15 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - // Keyed on the ADDRESS, not the token: the principal a write budget |
| - // belongs to is the PERSON, not the terminal they happen to be typing on |
| - // — the same key handleCLINewAccount already uses on this limiter. |
| - // Keying by token instead would give someone with several live tokens an |
| - // independent budget per token, which is the limit doing less than it |
| - // should, not more. |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + // allowWrite picks the person or the service write budget from the |
| + // principal (svcprincipal.go). A person is keyed on the ADDRESS, not the |
| + // token — the same key handleCLINewAccount already uses on this |
| + // limiter: keying by token instead would give someone with several live |
| + // tokens an independent budget per token, which is the limit doing less |
| + // than it should, not more. A service is keyed on its NAME, its own |
| + // budget separate from the operator who minted it (issue #220). |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -797,7 +806,8 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -872,7 +882,8 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -982,7 +993,8 @@ func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -1198,13 +1210,16 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - // Keyed on the TOKEN, not the address: ships are rare and heavy — bytes |
| - // on the wire, an object written per artifact — so a token is the |
| - // natural principal for an upload, unlike a write budget. Keying by |
| - // address instead would make a laptop and a CI runner sharing one |
| - // human's identity share one shipping budget between them, which is the |
| - // limit doing less than it should, not more. |
| - if !s.cli.ship.allow(hashToken(bearer(r)), time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + // allowShip picks the budget: a person stays keyed on the TOKEN, not the |
| + // address — ships are rare and heavy (bytes on the wire, an object |
| + // written per artifact), so a token is the natural principal for an |
| + // upload, unlike a write budget. Keying by address instead would make a |
| + // laptop and a CI runner sharing one human's identity share one |
| + // shipping budget between them, which is the limit doing less than it |
| + // should, not more. A service gets its own budget, keyed on its NAME |
| + // (svcprincipal.go), separate from the operator who minted it. |
| + if !s.allowShip(p, r, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many uploads — wait a while and try again") |
| return |
| } |
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go| index 31ec0179b..722b97bb3 100644 |
| --- a/internal/console/apiconfig.go |
| +++ b/internal/console/apiconfig.go |
| @@ -55,18 +55,21 @@ const configStoreTimeout = 15 * time.Second |
| // (256MiB) is where real bytes belong. |
| const configPutMaxBytes = 256 << 10 |
| -// configWriteAllowed is the write budget both MUTATING doors pass, in the |
| -// same shape and with the same key as promote/rollback/new-account/ledgers. |
| +// configWriteAllowed is the write budget both MUTATING doors pass, via |
| +// allowWrite (svcprincipal.go), the same picker every other write door here |
| +// uses. |
| // |
| -// Keyed on the ADDRESS, not the token: the principal a write budget belongs |
| -// to is the PERSON, not the terminal they happen to be typing on. Keying by |
| -// token instead would give someone with several live tokens an independent |
| -// budget per token, which is the limit doing less than it should, not more. |
| +// A person is keyed on the ADDRESS, not the token: the principal a write |
| +// budget belongs to is the PERSON, not the terminal they happen to be |
| +// typing on. Keying by token instead would give someone with several live |
| +// tokens an independent budget per token, which is the limit doing less |
| +// than it should, not more. A service is keyed on its NAME, its own budget |
| +// separate from the operator who minted it (issue #220). |
| // |
| // The GET door is deliberately unlimited, matching its read-only siblings: |
| // an exhausted write budget must not blind a CLI to what it already wrote. |
| -func (s *Server) configWriteAllowed(w http.ResponseWriter, addr string) bool { |
| - if !s.cli.write.allow(addr, time.Now()) { |
| +func (s *Server) configWriteAllowed(w http.ResponseWriter, p Principal) bool { |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return false |
| } |
| @@ -278,7 +281,8 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.configWriteAllowed(w, addr) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.configWriteAllowed(w, p) { |
| return |
| } |
| // No s.cfg.Key check, unlike promote/rollback: config manifests are not |
| @@ -489,7 +493,8 @@ func (s *Server) handleAPIConfigSync(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.configWriteAllowed(w, addr) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.configWriteAllowed(w, p) { |
| return |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout) |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 6b6d3b492..7e06e2642 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -135,6 +135,34 @@ func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, a |
| return "", false |
| } |
| +// domainsGate is instanceGate's principal, binding and readiness checks with |
| +// the instance-verb BUDGET step removed (spec §4a). Wildcard DNS changes the |
| +// budget question: a Pages hub with wildcard DNS makes NO per-repo hostname |
| +// call at all — one claim on the hub host with both flags, and every repo's |
| +// subdomain serves off the catchall — so gating attach and detach on the |
| +// instance family's per-minute request budget would uplift a call that |
| +// makes no per-repo call at all. The one thing on this path that IS bounded |
| +// is the FAILING half of a wildcard enable, and it is bounded separately, |
| +// inside setDomainCapabilities (wildFailBurst) — a live, sequential DNS |
| +// round trip on the request path is a hazard whether or not the ordinary |
| +// write budget would have caught it. |
| +func (s *Server) domainsGate(w http.ResponseWriter, r *http.Request) (Principal, *Account, string, bool) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return Principal{}, nil, "", false |
| + } |
| + p, _ := principalFrom(r.Context()) |
| + // A console with no deployment bucket configured can answer none of |
| + // these — instanceGate's own check, kept here since this gate stops |
| + // short of calling it. |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — the console is running without one") |
| + return Principal{}, nil, "", false |
| + } |
| + return p, acct, app, true |
| +} |
| + |
| // handleAPIDomainAttach is the point of the feature: one claim written to the |
| // bucket, in the spec's refusal order — gate, body, hostname, deny list, |
| // route, write. Idempotent by design (the titogo contract): re-attaching a |
| @@ -151,10 +179,11 @@ func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, a |
| // route. route and kind together are refused: an alias target and a |
| // first-route mint are two different instructions. |
| func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| - principal, acct, app, ok := s.instanceGate(w, r) |
| + p, acct, app, ok := s.domainsGate(w, r) |
| if !ok { |
| return |
| } |
| + principal := auditActor(p, acct) |
| var in struct { |
| Host string `json:"host"` |
| Route string `json:"route"` |
| @@ -268,7 +297,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // the same "absent means unchanged" contract the CLI's fs.Visit-gated |
| // flags rely on. |
| if in.Wildcard != nil || in.Catchall != nil { |
| - if !s.setDomainCapabilities(w, r, ctx, acct, app, principal, host, in.Wildcard, in.Catchall) { |
| + if !s.setDomainCapabilities(w, r, ctx, acct, app, p, host, in.Wildcard, in.Catchall) { |
| return |
| } |
| } |
| @@ -284,7 +313,8 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // capabilities as the baseline and overrides only the field(s) the caller |
| // actually named, so a caller flipping catchall alone cannot accidentally |
| // clear an already-enabled wildcard, and vice versa. |
| -func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app, principal, host string, wantWildcard, wantCatchall *bool) (ok bool) { |
| +func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string, p Principal, host string, wantWildcard, wantCatchall *bool) (ok bool) { |
| + principal := auditActor(p, acct) |
| cur, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| if err != nil { |
| log.Printf("console: api domains capabilities %s/%s %s: read after attach: %v", acct.Sqid, app, host, err) |
| @@ -325,6 +355,17 @@ func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, c |
| return false |
| } |
| if !s.delegationCNAMEDelegated(ctx, host) { |
| + // Bounded ONLY on the failing path (spec §4a). A succeeding |
| + // enable is once-per-domain and exempt (domainsGate skips the |
| + // write budget entirely); a failure is a live, sequential DNS |
| + // round trip a caller can repeat forever. Counted AFTER the |
| + // lookup, so the bound is on FAILURES rather than on attempts — |
| + // a caller whose CNAME is correct never meets it at all. |
| + if !s.cli.wildFail.allow(principalKey(p), time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, |
| + "too many wildcard attempts with no _acme-challenge CNAME in place — wait a minute and retry") |
| + return false |
| + } |
| jsonError(w, http.StatusUnprocessableEntity, |
| "create CNAME _acme-challenge."+host+" -> _acme-challenge."+host+"."+s.delegationZone+", then retry") |
| return false |
| @@ -388,10 +429,11 @@ func (s *Server) delegationCNAMEDelegated(ctx context.Context, host string) bool |
| // nothing for a box to converge onto, and an audit row per probe would let a |
| // prober write to someone else's account log. |
| func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) { |
| - principal, acct, app, ok := s.instanceGate(w, r) |
| + p, acct, app, ok := s.domainsGate(w, r) |
| if !ok { |
| return |
| } |
| + principal := auditActor(p, acct) |
| // Shape-checked before the key builder, not after: a {host} segment |
| // arrives percent-decoded, so "..%2f..%2finstances.json" reaches this |
| // handler as a path with separators in it, and ClaimKey would happily |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index 3127077bc..b21875fa9 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -30,6 +30,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| func domainsURL(fx *consoleFixture, acct, app string) string { |
| @@ -1089,3 +1090,93 @@ func TestDelegationZoneInvalidAtConstruction(t *testing.T) { |
| t.Fatalf("422 body = %s, want it to mention no delegation zone", out) |
| } |
| } |
| + |
| +// ---- wildcard exemption from the write/instance budgets (Task 5, spec §4a) |
| + |
| +// TestWildcardEnableIsNotRateLimited: a Pages hub with wildcard DNS makes no |
| +// per-repo hostname call at all — one claim on the hub host with both flags, |
| +// every repo's subdomain serves off the catchall. So the domains doors are |
| +// EXEMPT from the write budget rather than uplifted: far more than |
| +// svcWriteBurst SUCCEEDING wildcard enables in the window must never 429. |
| +func TestWildcardEnableIsNotRateLimited(t *testing.T) { |
| + const zone = "acme.example" |
| + fx, _ := instanceFixture(t, "code-wc-exempt") |
| + svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish) |
| + fx.srv.delegationZone = zone |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| + return name + "." + zone, nil |
| + } |
| + for i := 0; i < svcWriteBurst*3; i++ { |
| + host := fmt.Sprintf("r%d.pages.example", i) |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc, |
| + `{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`) |
| + if code == http.StatusTooManyRequests { |
| + t.Fatalf("attach %d = 429: %s", i, out) |
| + } |
| + if code != http.StatusOK { |
| + t.Fatalf("attach %d = %d: %s", i, code, out) |
| + } |
| + } |
| +} |
| + |
| +// TestFailedWildcardEnableIsBounded is the hazard the exemption has to |
| +// survive: the CNAME check is a live, sequential DNS round trip on the |
| +// request path, and a caller whose CNAME is not there yet can retry |
| +// forever. A SUCCEEDING enable is once-per-domain and exempt (the test |
| +// above); a FAILING one is bounded at wildFailBurst per principal per |
| +// minute. |
| +func TestFailedWildcardEnableIsBounded(t *testing.T) { |
| + const zone = "acme.example" |
| + fx, _ := instanceFixture(t, "code-wc-bounded") |
| + svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish) |
| + fx.srv.delegationZone = zone |
| + fx.srv.lookupCNAME = func(context.Context, string) (string, error) { |
| + return "", errors.New("not found") // no _acme-challenge CNAME anywhere |
| + } |
| + var saw429 bool |
| + for i := 0; i < wildFailBurst+5; i++ { |
| + code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc, |
| + `{"host":"retry.pages.example","route":"hub.pages.example","wildcard":true}`) |
| + if code == http.StatusTooManyRequests { |
| + saw429 = true |
| + break |
| + } |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("failing attach %d = %d, want 422 (until the bound trips)", i, code) |
| + } |
| + } |
| + if !saw429 { |
| + t.Error("unbounded failing wildcard enables; want a 429 after wildFailBurst") |
| + } |
| +} |
| + |
| +// TestIdempotentReattachTouchesNeitherBudget: titogo's router re-attaches |
| +// every hostname with its capability flags on every reload pass. `enabling` |
| +// is a false->true transition, so a re-send of wildcard:true on an |
| +// already-wildcard claim skips the DNS lookup entirely — it must skip the |
| +// failure bound with it, even once the resolver goes dark. |
| +func TestIdempotentReattachTouchesNeitherBudget(t *testing.T) { |
| + const zone = "acme.example" |
| + fx, _ := instanceFixture(t, "code-wc-idempotent") |
| + svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance) |
| + fx.srv.delegationZone = zone |
| + const host = "one.pages.example" |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| + return name + "." + zone, nil |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc, |
| + `{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("first attach = %d: %s", code, out) |
| + } |
| + fx.srv.lookupCNAME = func(context.Context, string) (string, error) { |
| + return "", errors.New("resolver is dark") // a re-attach must not care |
| + } |
| + for i := 0; i < wildFailBurst*2; i++ { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc, |
| + `{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("re-attach %d = %d: %s", i, code, out) |
| + } |
| + } |
| +} |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index e4e63eabe..445b30597 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -854,7 +854,8 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -932,7 +933,8 @@ func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
diff --git a/internal/console/apiledger.go b/internal/console/apiledger.go| index d6f32a48e..1b3c683fb 100644 |
| --- a/internal/console/apiledger.go |
| +++ b/internal/console/apiledger.go |
| @@ -44,13 +44,14 @@ import ( |
| // cancels immediately — this only adds a ceiling. |
| const ledgerStoreTimeout = 15 * time.Second |
| -// ledgerWriteAllowed is the write budget every ledger WRITE door passes, in |
| -// the same shape and with the same key as promote/rollback/new-account: the |
| -// principal a write budget belongs to is the PERSON, not the terminal they |
| -// happen to be typing on. The two GET doors below are unlimited, matching |
| -// their read-only siblings (channels, apps). |
| -func (s *Server) ledgerWriteAllowed(w http.ResponseWriter, addr string) bool { |
| - if !s.cli.write.allow(addr, time.Now()) { |
| +// ledgerWriteAllowed is the write budget every ledger WRITE door passes, via |
| +// allowWrite (svcprincipal.go): a person is keyed on the ADDRESS, the same |
| +// principal a write budget has always belonged to, not the terminal they |
| +// happen to be typing on; a service is keyed on its NAME, its own budget |
| +// separate from the operator who minted it. The two GET doors below are |
| +// unlimited, matching their read-only siblings (channels, apps). |
| +func (s *Server) ledgerWriteAllowed(w http.ResponseWriter, p Principal) bool { |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return false |
| } |
| @@ -78,7 +79,8 @@ func (s *Server) handleAPILedgerAppend(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.ledgerWriteAllowed(w, addr) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.ledgerWriteAllowed(w, p) { |
| return |
| } |
| name := r.PathValue("ledger") |
| @@ -161,11 +163,12 @@ func (s *Server) handleAPILedgerHead(w http.ResponseWriter, r *http.Request) { |
| // and sharing that two-slot semaphore would let blob traffic starve the |
| // deploy path. |
| func (s *Server) handleAPILedgerBlob(w http.ResponseWriter, r *http.Request) { |
| - addr, acct, app, ok := s.apiAccountApp(w, r) |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| if !ok { |
| return |
| } |
| - if !s.ledgerWriteAllowed(w, addr) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.ledgerWriteAllowed(w, p) { |
| return |
| } |
| name := r.PathValue("ledger") |
| @@ -291,7 +294,8 @@ func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| - if !s.ledgerWriteAllowed(w, addr) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.ledgerWriteAllowed(w, p) { |
| return |
| } |
| var in ledger.Manifest |
diff --git a/internal/console/apiretention.go b/internal/console/apiretention.go| index edd321fb2..e13d064f2 100644 |
| --- a/internal/console/apiretention.go |
| +++ b/internal/console/apiretention.go |
| @@ -94,7 +94,8 @@ func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| index 2581750f1..631d7a81a 100644 |
| --- a/internal/console/apistore.go |
| +++ b/internal/console/apistore.go |
| @@ -182,7 +182,8 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -389,8 +390,8 @@ func (s *Server) storeOperator(addr string) bool { |
| // this request: the member's declaration still stands and the sentence names |
| // the variable that would wire it up, which is the only thing the person |
| // reading it can do about it. |
| -func (s *Server) storeWalkGates(w http.ResponseWriter, addr string) bool { |
| - if !s.cli.write.allow(addr, time.Now()) { |
| +func (s *Server) storeWalkGates(w http.ResponseWriter, p Principal) bool { |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return false |
| } |
| @@ -511,7 +512,8 @@ func (s *Server) handleAPIStoreGrant(w http.ResponseWriter, r *http.Request) { |
| "store grants need a deployment operator (CARLOS_STORE_OPERATORS)") |
| return |
| } |
| - if !s.storeWalkGates(w, addr) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.storeWalkGates(w, p) { |
| return |
| } |
| pub, ok := s.storeSealKey(w) |
| @@ -583,7 +585,8 @@ func (s *Server) handleAPIStoreRotate(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.storeWalkGates(w, addr) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.storeWalkGates(w, p) { |
| return |
| } |
| pub, ok := s.storeSealKey(w) |
| @@ -652,7 +655,8 @@ func (s *Server) handleAPIStoreRotateFinish(w http.ResponseWriter, r *http.Reque |
| if !ok { |
| return |
| } |
| - if !s.storeWalkGates(w, addr) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.storeWalkGates(w, p) { |
| return |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout) |
diff --git a/internal/console/apiversion.go b/internal/console/apiversion.go| index 32038b9ae..a511be848 100644 |
| --- a/internal/console/apiversion.go |
| +++ b/internal/console/apiversion.go |
| @@ -66,10 +66,11 @@ func (s *Server) handleAPIVersionTargetSet(w http.ResponseWriter, r *http.Reques |
| if !ok { |
| return |
| } |
| - // Keyed on the ADDRESS, not the token: the principal a write budget |
| - // belongs to is the PERSON, not the terminal they happen to be typing on |
| - // — the same key handleAPIPromote's budget uses. |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + // allowWrite picks the person or the service write budget from the |
| + // principal (svcprincipal.go) — the same key handleAPIPromote's budget |
| + // uses. |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 9b2180fc8..f367b2fb2 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -107,6 +107,29 @@ const ( |
| // would trip the wholesale map drop and reset every budget. Orders of |
| // magnitude beyond this fleet; recorded, not defended. |
| cliCreateBurst = 10 |
| + |
| + // Service-principal budgets (Task 5, issue #220 consequence 1). A hub is |
| + // not a person, and the numbers above are calibrated for one. These are |
| + // a judgement, not a derivation: 100 repos enabling Pages in a day, and |
| + // a push every 30 seconds sustained. |
| + // |
| + // They are keyed on the principal NAME (principalKey), not the token |
| + // hash: rotating a credential must not hand it a fresh budget, or a |
| + // rotation mid-flood would be the limit doing nothing. |
| + svcCreateBurst = 100 // per day, the cliCreateWindow |
| + svcWriteBurst = 120 // per minute, the cliRateWindow |
| + svcShipBurst = 60 // per hour, the cliShipWindow |
| + |
| + // wildFailBurst bounds FAILED wildcard enables per principal per minute. |
| + // The successful path is exempt (spec §4a): with wildcard DNS a Pages |
| + // hub makes no per-repo hostname call at all, and an enable is |
| + // genuinely once-per-domain. But the enabling gate does a LIVE, |
| + // SEQUENTIAL DNS round trip on the request path, and a caller whose |
| + // _acme-challenge CNAME is not there yet can retry forever — so the |
| + // failures are bounded even though the successes are not. Roomy enough |
| + // for a resolver blip, closed enough that an exempt door is not a way to |
| + // make the console do unbounded outbound DNS. |
| + wildFailBurst = 20 |
| ) |
| // rateLimiter is a fixed-window counter, keyed by whatever the caller thinks |
| @@ -172,6 +195,17 @@ func (l *rateLimiter) allow(key string, now time.Time) bool { |
| // Server so a test gets a fresh set per fixture. |
| type cliLimits struct { |
| start, poll, pollIP, guessAddr, guessIP, write, ship, instances, create *rateLimiter |
| + // svcCreate, svcWrite and svcShip are the SERVICE side of create/write/ |
| + // ship — separate counters, separate keys (principalKey's NAME, not the |
| + // person's ADDRESS or the ship-person's token hash), so a credential's |
| + // budget is never the operator's to spend (issue #220 consequence 1). |
| + // allowCreate/allowWrite/allowShip (svcprincipal.go) pick between the |
| + // person and the service limiter so no call site has to. |
| + svcCreate, svcWrite, svcShip *rateLimiter |
| + // wildFail bounds only FAILED wildcard-enable attempts — see |
| + // wildFailBurst. The domains doors are otherwise exempt from write/ |
| + // instances entirely (spec §4a). |
| + wildFail *rateLimiter |
| } |
| func newCLILimits() *cliLimits { |
| @@ -197,6 +231,11 @@ func newCLILimits() *cliLimits { |
| // Create gets its own budget on its own (day-long) window, keyed by |
| // address — the stolen-token ceiling handleAPICreateApp explains. |
| create: newRateLimiter(cliCreateBurst, cliCreateWindow), |
| + |
| + svcCreate: newRateLimiter(svcCreateBurst, cliCreateWindow), |
| + svcWrite: newRateLimiter(svcWriteBurst, cliRateWindow), |
| + svcShip: newRateLimiter(svcShipBurst, cliShipWindow), |
| + wildFail: newRateLimiter(wildFailBurst, cliRateWindow), |
| } |
| } |
diff --git a/internal/console/authcli_test.go b/internal/console/authcli_test.go| index 248de3b53..f83d5701e 100644 |
| --- a/internal/console/authcli_test.go |
| +++ b/internal/console/authcli_test.go |
| @@ -3,12 +3,15 @@ package console |
| import ( |
| "bytes" |
| "encoding/json" |
| + "fmt" |
| "io" |
| "net/http" |
| "net/url" |
| "strings" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| // postJSON is how the CLI talks to the console: a JSON body, an optional |
| @@ -552,3 +555,163 @@ func TestCLITokensPageRendersAppTokenHonestly(t *testing.T) { |
| t.Errorf("the user token's expiry no longer renders:\n%s", html) |
| } |
| } |
| + |
| +// ---- rate limits keyed on the principal (Task 5, issue #220 consequence 1) - |
| + |
| +// TestServiceBudgetIsNotItsOperatorsBudget is the fix for consequence 1: a |
| +// server holding a credential must not draw down the budget of the human who |
| +// minted it. The service exhausts its OWN create budget (svcCreateBurst); |
| +// its minter's own budget is untouched. |
| +func TestServiceBudgetIsNotItsOperatorsBudget(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| + |
| + for i := 0; i < svcCreateBurst; i++ { |
| + name := fmt.Sprintf("svc-a%d", i) |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, svc); status != http.StatusOK { |
| + t.Fatalf("service create %d = %d %v", i, status, out) |
| + } |
| + } |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "svc-over"}, svc) |
| + if status != http.StatusTooManyRequests { |
| + t.Fatalf("service create over budget = %d %v, want 429", status, out) |
| + } |
| + // The human who minted it has spent nothing. |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "mine"}, cliToken(t, fx, "paul@keymail.dev", "code-svc-budget-human")) |
| + if status != http.StatusOK { |
| + t.Fatalf("human create after service exhausted its own budget = %d %v, want 200", status, out) |
| + } |
| +} |
| + |
| +// TestRotationDoesNotResetTheBudget: the create budget is keyed on the |
| +// service's NAME (principalKey), not its token hash — rotating mid-flood |
| +// must buy the caller nothing. |
| +func TestRotationDoesNotResetTheBudget(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| + for i := 0; i < svcCreateBurst; i++ { |
| + name := fmt.Sprintf("rot-a%d", i) |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, tok); status != http.StatusOK { |
| + t.Fatalf("create %d = %d %v", i, status, out) |
| + } |
| + } |
| + fresh, err := fx.st.RotateServicePrincipal(OpsAccountID, "hub", time.Now()) |
| + if err != nil { |
| + t.Fatalf("rotate: %v", err) |
| + } |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "after-rotate"}, fresh) |
| + if status != http.StatusTooManyRequests { |
| + t.Errorf("create after rotate = %d %v, want 429 — rotation must not reset the budget", status, out) |
| + } |
| +} |
| + |
| +// TestPersonBudgetsAreUnchanged pins that no human's create limit moved: the |
| +// same cliCreateBurst as before this change, keyed on the same address. |
| +func TestPersonBudgetsAreUnchanged(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + tok := cliToken(t, fx, "alice@keymail.dev", "code-person-budget") |
| + for i := 0; i < cliCreateBurst; i++ { |
| + name := fmt.Sprintf("person-a%d", i) |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, tok); status != http.StatusOK { |
| + t.Fatalf("create %d = %d %v", i, status, out) |
| + } |
| + } |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "person-over"}, tok) |
| + if status != http.StatusTooManyRequests { |
| + t.Errorf("create %d+1 = %d %v, want 429 at the unchanged human limit", cliCreateBurst, status, out) |
| + } |
| +} |
| + |
| +// TestServiceWriteBudgetIsOwnAndSeparateFromHuman: the write budget's own |
| +// service picker (svcWriteBurst), exercised through a real write door |
| +// (restart) rather than the picker in isolation — the call site is the part |
| +// that can get the wrong limiter, not the picker itself. |
| +func TestServiceWriteBudgetIsOwnAndSeparateFromHuman(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "operator", "titogo", svcrole.Operate) |
| + for i := 0; i < svcWriteBurst; i++ { |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, svc); status != http.StatusOK { |
| + t.Fatalf("service restart %d = %d %v", i, status, out) |
| + } |
| + } |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, svc); status != http.StatusTooManyRequests { |
| + t.Fatalf("service restart over budget = %d %v, want 429", status, out) |
| + } |
| + human := cliToken(t, fx, "paul@keymail.dev", "code-write-budget-human") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, human); status != http.StatusOK { |
| + t.Fatalf("human restart after service exhausted its own budget = %d %v, want 200", status, out) |
| + } |
| +} |
| + |
| +// TestServiceShipBudgetIsOwnAndKeyedByName: ship's service branch gets its |
| +// own budget (svcShipBurst) keyed the same way every other service limiter |
| +// is (principalKey — the NAME), unlike the person branch below. |
| +func TestServiceShipBudgetIsOwnAndKeyedByName(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "shipper", "titogo", svcrole.Publish) |
| + for i := 0; i < svcShipBurst; i++ { |
| + v := fmt.Sprintf("v%03d", i) |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", svc, |
| + []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("service ship %d = %d %v", i, status, out) |
| + } |
| + } |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", svc, |
| + []shipPart{metaPart(t, "over-budget", "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusTooManyRequests { |
| + t.Fatalf("service ship over budget = %d %v, want 429", status, out) |
| + } |
| + fresh, err := fx.st.RotateServicePrincipal(OpsAccountID, "shipper", time.Now()) |
| + if err != nil { |
| + t.Fatalf("rotate: %v", err) |
| + } |
| + status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", fresh, |
| + []shipPart{metaPart(t, "after-rotate", "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusTooManyRequests { |
| + t.Fatalf("service ship after rotate = %d %v, want 429 — rotation must not reset the budget", status, out) |
| + } |
| +} |
| + |
| +// TestPersonShipBudgetStaysKeyedByToken pins the one branch the brief says |
| +// must NOT move: a person's ship budget stays keyed on the TOKEN HASH, not |
| +// the address (handleAPIShip's own comment) — a second token for the same |
| +// address gets a FRESH ship budget, unlike every other write door here. |
| +func TestPersonShipBudgetStaysKeyedByToken(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + tok1 := cliToken(t, fx, "paul@keymail.dev", "code-ship-key-1") |
| + for i := 0; i < cliShipBurst; i++ { |
| + v := fmt.Sprintf("p%03d", i) |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok1, |
| + []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship %d = %d %v", i, status, out) |
| + } |
| + } |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok1, |
| + []shipPart{metaPart(t, "over", "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusTooManyRequests { |
| + t.Fatalf("ship over budget = %d %v, want 429", status, out) |
| + } |
| + tok2 := cliToken(t, fx, "paul@keymail.dev", "code-ship-key-2") |
| + status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok2, |
| + []shipPart{metaPart(t, "fresh-token", "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("second token same address = %d %v, want 201 (ship stays token-keyed)", status, out) |
| + } |
| +} |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index 5d010fd6f..cafa9cc09 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -157,13 +157,16 @@ func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + p, _ := principalFrom(r.Context()) |
| // The fleets namespace is flat and global with no delete door (see this |
| // file's package doc comment) — the same "a claimed name is a permanent |
| // row, not a pointer the next call overwrites" shape apps' create budget |
| // exists for, so this spends the SAME scarce per-day budget |
| // (handleAPICreateApp), not the ordinary per-minute write budget every |
| - // other fleet door here uses. |
| - if !s.cli.create.allow(addr, time.Now()) { |
| + // other fleet door here uses. Every fleets door is registered with the |
| + // zero role Set, so only a person ever reaches here — allowCreate is |
| + // used anyway to keep one idiom. |
| + if !s.allowCreate(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, |
| "fleet creation limit reached for today — try again tomorrow") |
| return |
| @@ -258,7 +261,8 @@ func (s *Server) handleAPIFleetAddBox(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -318,7 +322,8 @@ func (s *Server) handleAPIFleetRotateBox(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -373,7 +378,8 @@ func (s *Server) handleAPIFleetTagBox(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -418,7 +424,8 @@ func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| @@ -658,7 +665,8 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| index 330aeae02..8bd3d0212 100644 |
| --- a/internal/console/svcprincipal.go |
| +++ b/internal/console/svcprincipal.go |
| @@ -22,6 +22,8 @@ import ( |
| "crypto/subtle" |
| "errors" |
| "fmt" |
| + "net/http" |
| + "strconv" |
| "time" |
| "github.com/carlosframework/platform/internal/svcrole" |
| @@ -323,3 +325,55 @@ WHERE token_hash = ? AND account_id = ? AND revoked_at = 0`, now.Unix(), id, acc |
| } |
| return nil |
| } |
| + |
| +// ---- rate limits keyed on the principal (Task 5, issue #220 consequence 1) |
| + |
| +// principalKey is what a rate limiter counts against. |
| +// |
| +// A person keys on their ADDRESS, byte for byte as before — no human's |
| +// limits move in this change. A service keys on its NAME, not on its token |
| +// hash, so that rotating a credential does not hand it a fresh budget: the |
| +// name is stable across a secret change precisely so budgets and audit rows |
| +// stay continuous. |
| +// |
| +// The account id rather than the sqid because this string is never shown to |
| +// anyone — it is a map key, and the id is what the resolver already has on |
| +// the Principal without a further lookup. |
| +func principalKey(p Principal) string { |
| + if p.Kind == PrincipalUser { |
| + return p.Address |
| + } |
| + return "service:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.Name |
| +} |
| + |
| +// allowCreate, allowWrite and allowShip pick the person or the service |
| +// limiter from the principal, so that no call site has to remember which |
| +// one it is talking to — a call site that got it wrong would be a server |
| +// drawing down an operator's budget, which is the bug this whole change is |
| +// about. |
| +func (s *Server) allowCreate(p Principal, now time.Time) bool { |
| + if p.Kind == PrincipalUser { |
| + return s.cli.create.allow(principalKey(p), now) |
| + } |
| + return s.cli.svcCreate.allow(principalKey(p), now) |
| +} |
| + |
| +func (s *Server) allowWrite(p Principal, now time.Time) bool { |
| + if p.Kind == PrincipalUser { |
| + return s.cli.write.allow(principalKey(p), now) |
| + } |
| + return s.cli.svcWrite.allow(principalKey(p), now) |
| +} |
| + |
| +// allowShip is the one picker that does NOT key a person on principalKey: |
| +// the person branch keeps handleAPIShip's existing key, the TOKEN HASH, not |
| +// the address — a laptop and a CI runner sharing one human's identity share |
| +// one shipping budget between them otherwise, which is the limit doing less |
| +// than it should. That reasoning is unchanged by this task; only the |
| +// service branch is new. |
| +func (s *Server) allowShip(p Principal, r *http.Request, now time.Time) bool { |
| + if p.Kind == PrincipalUser { |
| + return s.cli.ship.allow(hashToken(bearer(r)), now) |
| + } |
| + return s.cli.svcShip.allow(principalKey(p), now) |
| +} |