feat(auth): gate OSS signup behind ENABLE_SIGNUP flag (#514)

* feat(auth): gate OSS signup behind ENABLE_SIGNUP flag

## Problem

The `POST /api/v1/auth/signup` endpoint is unconditionally exposed on
every OSS install. Operators running an invite-only deployment (private
customer instances, staging environments, internal-only tenants) have
no way to disable public account creation without patching the codebase.
The UI also shows the "Sign up" link on `/auth/login` regardless of
whether signup is available, so a locked-down deployment leaves broken
navigation on the login page.

## Fix

Introduce a single `ENABLE_SIGNUP` env var (default `true` — no behavior
change for existing installs) that controls signup end-to-end:

- **Backend** — `api/constants.ENABLE_SIGNUP` is read at module load.
  The signup handler returns 403 when it's false. Also exposed on
  `GET /api/v1/health` as `signup_enabled: bool` so the UI can mirror
  the operator's choice at runtime instead of at bundle-build time.

- **UI** — `getSignupEnabled()` in `lib/auth/config.ts` proxies the
  health field, `/api/config/auth` surfaces it to the browser, the
  login page conditionally renders the "Sign up" link via a one-shot
  `fetch("/api/config/auth")` in `useEffect`, and the middleware
  redirects `/auth/signup` → `/auth/login` when disabled (fires before
  Next.js can serve the statically-prerendered signup page).

- **Helm** — `config.enableSignup` (default `true`) is rendered into
  the ConfigMap as `ENABLE_SIGNUP` so operators can flip it via
  `--set config.enableSignup=false` at install/upgrade time.

Fallbacks default to `signupEnabled: true` in every layer so a fresh
install "just works" and matches the backend default.

* address review: rollout on ConfigMap change, cache TTL, no signup-link flash

Four review points on #514:

**P1 — ConfigMap Change Skips Rollout** (`configmap.yaml`). `helm upgrade
--set config.enableSignup=false` updated the ConfigMap but did NOT roll
the api pods, so running processes kept the ENABLE_SIGNUP env from
startup and continued serving the old signup behavior — including
divergence between replicas mid-upgrade.

Fix: add the standard `checksum/config` pod-template annotation on the
four backend Deployments that `envFrom` the ConfigMap (`web`,
`arq-worker`, `ari-manager`, `campaign-orchestrator`). Verified with
`helm template`: all four Deployments share the same checksum on any
given render, and flipping `config.enableSignup` changes the checksum
uniformly so kubectl sees a pod-template diff and rolls all four.

**P1 — Signup Flag Stays Cached (server)** (`ui/src/lib/auth/config.ts`).
Module-scoped cache had no TTL. `revalidate: 300` was passed on the
underlying `fetch()` but the in-memory short-circuit above ran first, so
the value never refreshed until the UI pod restarted.

Fix: add `AUTH_CONFIG_TTL_MS = 5 * 60 * 1000` (matching the fetch
revalidate hint) so the module cache and the Next fetch cache stay in
sync. Backend flag flips propagate within 5 minutes without a pod
restart.

**P1 — Middleware Redirect Uses Stale State** (`ui/src/middleware.ts`).
Same shape as above — a separate module cache with no expiry could keep
redirecting `/auth/signup → /auth/login` after signup was re-enabled, or
keep serving the statically-prerendered signup page after lockdown.

Fix: same `SERVER_CONFIG_TTL_MS = 5 * 60 * 1000` TTL on the middleware
cache.

**P2 — Signup link flash on login page** (`ui/src/app/auth/login/page.tsx`).
Initial `signupEnabled` state was `null`, so `{signupEnabled && ...}`
hid the link on first paint and it popped in after the fetch resolved
— a CLS on every login-page load on stock installs where signup is
enabled.

Fix: initialise the state to `true` (matches the backend default). The
fetch still overrides to `false` when the operator has actually
disabled signup, so the lockdown UI behavior is unchanged; only the
happy-path flash is gone.

* simplify signup flag: drop TTL caches and middleware redirect

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* resolve signup flag server-side to avoid signup link flicker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: prabhat pankaj <prabhatiitbhu@gmail.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
prabhatlepton 2026-07-13 14:08:25 +05:30 committed by GitHub
parent 2c803bbea9
commit e7494e9c21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 184 additions and 101 deletions

View file

@ -2,6 +2,9 @@
ENVIRONMENT="local"
LOG_LEVEL="DEBUG"
# Set to "false" to disable public signup (invite-only installs)
ENABLE_SIGNUP="true"
# Change these values if you deploy the backend and frontend
# on any hosting provider with some DNS. Please ensure to
# provide the URL with scheme like http or https

View file

@ -46,6 +46,7 @@ CORS_ALLOWED_ORIGINS = [
o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()
]
AUTH_PROVIDER = os.getenv("AUTH_PROVIDER", "local")
ENABLE_SIGNUP = os.getenv("ENABLE_SIGNUP", "true").lower() == "true"
# Stack Auth public client config. These are safe to expose to the browser (the
# publishable client key is public by design, and the project id is non-sensitive),
# and are served to the UI at runtime via /api/v1/health so the frontend no longer

View file

@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from api.constants import ENABLE_SIGNUP
from api.db import db_client
from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey, PostHogEvent
@ -28,6 +29,9 @@ router = APIRouter(
dependencies=[Depends(require_local_auth)],
)
async def signup(request: SignupRequest):
if not ENABLE_SIGNUP:
raise HTTPException(status_code=403, detail="Signup is disabled")
# Check if email is already taken
existing_user = await db_client.get_user_by_email(request.email)
if existing_user:

View file

@ -79,6 +79,7 @@ class HealthResponse(BaseModel):
auth_provider: str
turn_enabled: bool
force_turn_relay: bool
signup_enabled: bool
# Public Stack Auth client config — only populated when auth_provider == "stack".
# The UI reads these at runtime to initialize Stack, so they no longer need to
# be baked into the browser bundle at build time. Both are public values.
@ -93,6 +94,7 @@ async def health() -> HealthResponse:
AUTH_PROVIDER,
BACKEND_API_ENDPOINT,
DEPLOYMENT_MODE,
ENABLE_SIGNUP,
FORCE_TURN_RELAY,
STACK_AUTH_PROJECT_ID,
STACK_PUBLISHABLE_CLIENT_KEY,
@ -123,6 +125,7 @@ async def health() -> HealthResponse:
auth_provider=AUTH_PROVIDER,
turn_enabled=bool(TURN_SECRET),
force_turn_relay=FORCE_TURN_RELAY,
signup_enabled=ENABLE_SIGNUP,
stack_project_id=STACK_AUTH_PROJECT_ID if is_stack else None,
stack_publishable_client_key=(
STACK_PUBLISHABLE_CLIENT_KEY if is_stack else None

View file

@ -3,6 +3,7 @@ from types import SimpleNamespace
from fastapi import FastAPI
from fastapi.testclient import TestClient
import api.routes.auth as auth_routes
from api.routes.auth import router
from api.services.auth import depends as auth_depends
from api.services.auth.depends import get_user
@ -40,6 +41,23 @@ def test_stack_mode_hides_email_password_auth_routes(monkeypatch):
assert login_response.json() == {"detail": "Not found"}
def test_signup_disabled_returns_403(monkeypatch):
monkeypatch.setattr(auth_routes, "ENABLE_SIGNUP", False)
client = TestClient(_make_test_app())
response = client.post(
"/auth/signup",
json={
"email": "user@example.com",
"password": "password123",
"name": "User",
},
)
assert response.status_code == 403
assert response.json() == {"detail": "Signup is disabled"}
def test_stack_mode_keeps_current_user_route_available(monkeypatch):
monkeypatch.setattr(auth_depends, "AUTH_PROVIDER", "stack")
app = _make_test_app()