Merge remote-tracking branch 'origin/main' into feature/mailbox-fair-use-allowance

This commit is contained in:
Matthew Meszaros
2026-09-04 21:25:53 -07:00
70 changed files with 11208 additions and 18 deletions
+31
View File
@@ -171,6 +171,37 @@ func (h *Handler) RevokeAPIKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// RevokeOwnAPIKey revokes the key the request was made with.
//
// Deliberately outside the API_KEYS scope gate: a credential must always be
// able to end itself. Requiring a privilege to sign out means a read-only key
// on a laptop someone is handing back stays live, which is the opposite of
// what a `warmbly auth logout` promises.
func (h *Handler) RevokeOwnAPIKey(c *gin.Context) {
keyID := middleware.GetAPIKeyID(c)
if keyID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "this endpoint revokes the API key it is called with, and this request did not use one"))
return
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
reason := c.Query("reason")
if reason == "" {
reason = "Revoked by the credential itself"
}
if xerr := h.APIKeyService.Revoke(c.Request.Context(), *orgID, *keyID, reason); xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionRevoke, models.AuditEntityAPIKey, keyID, nil, map[string]string{"self": "true"})
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// ListAPIPermissions lists all available API permissions
// GET /api-keys/permissions
func (h *Handler) ListAPIPermissions(c *gin.Context) {
+14
View File
@@ -72,6 +72,18 @@ type DeploymentAuthConfig struct {
// DocsURL is where to send someone whose signup was refused by deployment
// policy rather than by anything they did wrong.
DocsURL string `json:"docs_url"`
// WebsocketURL is the realtime gateway. Served here because a developer
// client (the CLI's event stream, an SDK) has no other way to find the
// socket on a self-hosted instance. Empty when the instance runs no
// realtime service.
WebsocketURL string `json:"websocket_url,omitempty"`
// AppURL is the dashboard origin, the same one every emailed link is built
// from. A client that wants to send someone to a page (the CLI's `browse`,
// a chat integration) cannot derive it: on a self-hosted instance the host
// layout is whatever the operator chose.
AppURL string `json:"app_url,omitempty"`
}
// accountsDocsURL is the page every registration refusal points at.
@@ -104,5 +116,7 @@ func (h *Handler) AuthConfig(c *gin.Context) {
SetupRequired: h.BootstrapService != nil && h.BootstrapService.Required(c.Request.Context()),
InvitesRequired: registration == config.RegistrationInviteOnly,
DocsURL: accountsDocsURL,
WebsocketURL: config.WebsocketURL(),
AppURL: config.AppBaseURL(),
})
}
+126
View File
@@ -0,0 +1,126 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// Device-code sign-in for the `warmbly` CLI. The public half (start, poll) is
// what the CLI calls; the session half is the browser approval screen.
func (h *Handler) cliAuthReady(c *gin.Context) bool {
if h.CLIAuthService == nil {
errx.JSON(c, errx.New(errx.NotImplemented, "CLI sign-in is not enabled on this instance"))
return false
}
return true
}
// CLIAuthStart opens a handshake for a CLI that holds no key yet.
func (h *Handler) CLIAuthStart(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
var req models.CLIAuthStartRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
res, xerr := h.CLIAuthService.StartCode(c.Request.Context(), req)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusCreated, res)
}
// CLIAuthPoll is polled by the CLI until a member approves the code. The key is
// handed out exactly once, on the poll that follows approval.
func (h *Handler) CLIAuthPoll(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
var req struct {
DeviceCode string `json:"device_code"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeviceCode == "" {
errx.JSON(c, errx.New(errx.BadRequest, "device_code is required"))
return
}
res, xerr := h.CLIAuthService.PollCode(c.Request.Context(), req.DeviceCode)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, res)
}
// CLIAuthDescribeCode shows the approving member what they are authorizing.
func (h *Handler) CLIAuthDescribeCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
code, xerr := h.CLIAuthService.DescribeCode(c.Request.Context(), c.Param("code"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, code)
}
// CLIAuthApproveCode mints the key into the workspace named in the body, not
// the session's, because a member with several workspaces picks on the screen.
func (h *Handler) CLIAuthApproveCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
var req models.CLIAuthApproveRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
orgID, perr := uuid.Parse(req.OrganizationID)
if perr != nil {
if sessionOrg := middleware.GetOrganizationID(c); sessionOrg != nil {
orgID = *sessionOrg
} else {
errx.JSON(c, errx.ErrNoOrganization)
return
}
}
code, xerr := h.CLIAuthService.ApproveCode(c.Request.Context(), c.Param("code"), orgID, userID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
// Logged against the org that was picked on screen, which is not always the
// session's, so this cannot go through auditOrg.
h.AuditService.LogAction(c.Request.Context(), orgID, userID, models.AuditActionCreate, models.AuditEntityAPIKey, code.APIKeyID,
c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{"source": "cli", "client": code.ClientName, "hostname": code.Hostname})
c.JSON(http.StatusOK, code)
}
// CLIAuthDenyCode declines the request. Deliberately not audited: nothing was
// created, and a denial is not a change to the workspace.
func (h *Handler) CLIAuthDenyCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
if xerr := h.CLIAuthService.DenyCode(c.Request.Context(), c.Param("code")); xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+4
View File
@@ -14,6 +14,7 @@ import (
"github.com/warmbly/warmbly/internal/app/behavior"
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -312,6 +313,9 @@ type Handler struct {
PoolLinkService poollink.Service
CloudLinkService cloudlink.Service
// Device-code sign-in for the `warmbly` CLI. Nil-safe: routes answer 501.
CLIAuthService cliauth.Service
// Infrastructure liveness probes for the admin System Status page.
// Wired in cmd/backend/main.go where the concrete clients live.
SystemChecker *sysstatus.Checker
+49 -6
View File
@@ -16,8 +16,27 @@ const (
// password, and far below what credential stuffing needs.
authIPWindow = 15 * time.Minute
authIPDefaultLimit = 60
// The CLI sign-in handshake gets its own budget, on its own key.
//
// It cannot share the auth one: `warmbly auth login` polls every
// CLIAuthPollIntervalSeconds for up to CLIAuthCodeTTLMinutes, which is
// around 200 requests for a single sign-in. On the shared budget that
// exhausts the allowance in three minutes, and then blocks the person's
// actual login from the same address for the rest of the window. The
// allowance below covers two concurrent sign-ins from one NAT with slack.
cliAuthIPWindow = 15 * time.Minute
cliAuthIPDefaultLimit = 500
)
// CLIAuthIPRateLimitMiddleware throttles the public CLI sign-in handshake per
// source IP, on a key of its own so a long poll cannot lock the same address
// out of signing in through the browser.
func (h *Handler) CLIAuthIPRateLimitMiddleware() gin.HandlerFunc {
return h.ipRateLimiter("cli_auth_ip:", cliAuthIPDefaultLimit, "CLI_AUTH_IP_RATE_LIMIT", cliAuthIPWindow,
"Too many CLI sign-in requests from this address. Try again later.")
}
// AuthIPRateLimitMiddleware throttles the public /auth group per source IP.
//
// This is the only limiter those routes have. RateLimitMiddleware keys on the
@@ -29,8 +48,15 @@ const (
// Fails open on a cache error, deliberately: a Redis blip must not lock every
// user out of their own instance.
func (h *Handler) AuthIPRateLimitMiddleware() gin.HandlerFunc {
limit := authIPDefaultLimit
if v := os.Getenv("AUTH_IP_RATE_LIMIT"); v != "" {
return h.ipRateLimiter("auth_ip:", authIPDefaultLimit, "AUTH_IP_RATE_LIMIT", authIPWindow,
"Too many authentication attempts from this address. Try again later.")
}
// ipRateLimiter is the shared fixed-window limiter behind both. Each caller
// brings its own Redis key prefix, so budgets never bleed into each other.
func (h *Handler) ipRateLimiter(prefix string, defaultLimit int, env string, window time.Duration, message string) gin.HandlerFunc {
limit := defaultLimit
if v := os.Getenv(env); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
@@ -53,21 +79,38 @@ func (h *Handler) AuthIPRateLimitMiddleware() gin.HandlerFunc {
return
}
key := "auth_ip:" + ip
key := prefix + ip
n, err := h.Cache.Incr(c.Request.Context(), key).Result()
if err != nil {
c.Next()
return
}
// A counter with no TTL never resets, so the address it belongs to
// stays blocked forever once it passes the limit. That is a worse
// outcome than not counting at all, so a failed EXPIRE drops the key
// and lets the request through, matching how the rest of this
// middleware handles a cache it cannot trust.
if n == 1 {
_ = h.Cache.Expire(c.Request.Context(), key, authIPWindow).Err()
if err := h.Cache.Expire(c.Request.Context(), key, window).Err(); err != nil {
_ = h.Cache.Del(c.Request.Context(), key).Err()
c.Next()
return
}
} else if n > int64(limit) {
// Repair a key that lost its expiry some other way (an older
// build, a restore, an eviction between the INCR and the EXPIRE
// above). Only on the reject path, which is rare, so it costs a
// round trip nobody feels.
if ttl, terr := h.Cache.TTL(c.Request.Context(), key).Result(); terr == nil && ttl < 0 {
_ = h.Cache.Expire(c.Request.Context(), key, window).Err()
}
}
if n > int64(limit) {
c.Header("Retry-After", fmt.Sprintf("%d", int(authIPWindow.Seconds())))
c.Header("Retry-After", fmt.Sprintf("%d", int(window.Seconds())))
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "rate_limit_exceeded",
"message": "Too many authentication attempts from this address. Try again later.",
"message": message,
"code": "rate_limit_exceeded",
})
c.Abort()
+29
View File
@@ -240,6 +240,18 @@ func Run(
poolLinkPublic.POST("/poll", h.PoolLinkPoll)
}
// `warmbly auth login`. Unauthenticated by nature (the CLI has no key yet),
// so it is throttled per source IP, but on its OWN budget: one sign-in
// polls around 200 times, which would exhaust the auth allowance and then
// lock the same address out of the browser login for the rest of the
// window.
cliAuthPublic := v1.Group("/auth/cli")
cliAuthPublic.Use(m.CLIAuthIPRateLimitMiddleware())
{
cliAuthPublic.POST("/code", h.CLIAuthStart)
cliAuthPublic.POST("/poll", h.CLIAuthPoll)
}
auth := v1.Group("/auth")
// Every unauthenticated auth route shares one per-IP budget. Nothing
// throttled these before: RateLimitMiddleware is keyed on the user id and
@@ -754,6 +766,11 @@ func Run(
// API key management. JWT users need PermManageAPIKeys; API keys
// need the APIPermAPIKeys self-service bit. This lets an integration
// rotate its own keys without going through the dashboard.
// Self-revocation, outside the API_KEYS gate below on purpose: any
// valid key may end itself, which is what makes signing a machine
// out actually end its access.
protected.DELETE("/api-keys/self", m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite), h.RevokeOwnAPIKey)
apiKeys := protected.Group("/api-keys")
apiKeys.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys))
apiKeys.Use(m.RateLimitMiddleware(models.RateLimitWrite))
@@ -1223,6 +1240,18 @@ func Run(
poolLink.GET("/instances", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkListInstances)
poolLink.DELETE("/instances/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkRevokeInstance)
}
// Browser half of `warmbly auth login`: a member reviews the code
// and approves it into one of their workspaces. Session-only, like
// the pool link approval, because approving mints a credential and
// an API key must not be able to mint another CLI's key.
cliAuth := jwtOnly.Group("/auth/cli")
cliAuth.Use(m.RateLimitMiddleware(models.RateLimitWrite))
{
cliAuth.GET("/codes/:code", h.CLIAuthDescribeCode)
cliAuth.POST("/codes/:code/approve", h.CLIAuthApproveCode)
cliAuth.POST("/codes/:code/deny", h.CLIAuthDenyCode)
}
// The linked instance's own surface, authenticated by its token.
poolLinkInstance := base.Group("/pool-link/instance")
poolLinkInstance.Use(m.PoolLinkAuthMiddleware())