From e127c62b0f35883f4fe92c450f8bac82e1c77c7c Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 16:22:26 +0200 Subject: [PATCH] fix(auth): close bootstrap gate hole between sign-up and user mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-ups through the Better Auth proxy write to auth_users, but the bootstrap gate counted users — which only mirrors on the first authenticated call. In that window a second public registration was accepted on both the proxied sign-up and the native register endpoint. countLocalUsers now unions users with auth_users (guarded by to_regclass so backend-only installs still work), and handleRegister uses it instead of its own inline count. e2e-verified on a live stack: users=0/auth_users=1 already reports mode=login and 403s both public paths; internal-token bypass works, wrong token refused, authed POST /users creates users (201), and the users mirror still populates on first authenticated call. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 15 ++++++++++++--- app/backend/internal/api/auth.go | 22 ++++++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 72c2aa3..fe43718 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -237,9 +237,18 @@ pieces exist; this phase closes the gaps that block real usage. ever conflicts, Docker's bind error lands in `deployments.error` and shows in the failed-deploy log tail — not silent. Revisit if a service-level host-port config is ever added. -- [ ] Auth hardening: e2e verify bootstrap gate (first user registers, second - is refused, manual creation works), OAuth happy paths for GitHub + - Google, session expiry redirect. +- [x] Auth hardening: e2e-verified against a live stack (scratch Postgres + + Redis + real Better Auth sidecar). Fixed a real gate hole: sign-ups write + `auth_users` but the gate counted `users`, which only mirrors on the + first authenticated call — a second registration slipped through the + pre-mirror window. `countLocalUsers` now unions both tables (verified: + `users=0, auth_users=1` already returns `mode: login` and 403s on both + public register paths; internal-token manual creation works, wrong token + refused). Session expiry redirect confirmed in `App.tsx` + (401 → null → `/auth/sign-in?redirect=…`). OAuth happy paths remain + unverifiable without real GitHub/Google credentials — providers are + env-gated and `disableImplicitSignUp` means OAuth cannot bypass the + gate by creating accounts. **Gate**: a fresh instance can register → create project → connect GitHub repo → push → see build → see deploy → view logs → roll back, with zero manual API diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 4d9f5f9..8ee42d2 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -264,8 +264,7 @@ func handleRegister(c *gin.Context) { db := c.MustGet("db").(*database.DB) jwtSecret := c.MustGet("jwt_secret").(string) - var count int - err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + count, err := countLocalUsers(db) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return @@ -432,6 +431,25 @@ func countLocalUsers(db *database.DB) (int, error) { if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { return 0, err } + // Better Auth writes to auth_users; a row there only mirrors into users on + // the first authenticated call. Count it too or a second public sign-up + // slips through the bootstrap window. UNION by email avoids double-counting + // mirrored users. + var authTable *string + if err := db.QueryRow("SELECT to_regclass('auth_users')").Scan(&authTable); err != nil { + return 0, err + } + if authTable != nil && *authTable != "" { + if err := db.QueryRow(` + SELECT COUNT(*) FROM ( + SELECT email FROM users + UNION + SELECT email FROM auth_users + ) u + `).Scan(&count); err != nil { + return 0, err + } + } return count, nil }