control: bound writes per account in the dispatcher !283

merged merged by cmc on 2026-09-06 18:10 UTC · krz/gitbay:feat-148-write-rate-limit into main

Discussion

cmc

internal/sshd/ratelimit.go throttles authentication failures only, so once a key authenticated nothing bounded how many commands it ran. The same issue create was limited through the JSON API and unlimited over SSH — the primary interface.

The limiter goes in Dispatch, not in a surface, for the reason the registry itself exists: SSH, the API and the web spend one per-account budget and a caller cannot refresh it by changing how it connects. limits.api_rate stays in front of it, bounding a network source rather than an account.

limits.write_rate defaults to 60 a minute, burst 60; a negative value disables it. A command is one token whatever it writes, so account import-bundle replays a whole bundle for one, and only a loop of separate commands spends the budget.

Exempt, each for a reason: read-only commands; the runner protocol, since a build streams its log in many small runner log calls and throttling those would throttle CI; and the host CLI on the server, which has no account to key on and is already root-equivalent. Pushes are unaffected — the hook path runs as the host.

Refusals exit 4 and name both the rate and the wait.

TestWriteLimiter* cover the burst, the refill and per-account isolation. TestWriteRateLimit spends a budget of four over SSH and checks the fifth write is refused while reads still work and another account is unaffected; TestWriteRateLimitSparesTheRunner runs a real build under a budget of two.

On the issue's open question — whether a pending account should write at all — it already cannot: pendingAllowed (control.go:168) confines an unverified account to email verify, email add, whoami and help. Confirmed rather than assumed; no change needed.

Documented in the wiki's Admin page beside ssh_auth_rate.

Closes #148