From e7494e9c21b66530e6a22f25c148cba6b8950448 Mon Sep 17 00:00:00 2001 From: prabhatlepton Date: Mon, 13 Jul 2026 14:08:25 +0530 Subject: [PATCH] feat(auth): gate OSS signup behind ENABLE_SIGNUP flag (#514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * resolve signup flag server-side to avoid signup link flicker Co-Authored-By: Claude Fable 5 --------- Co-authored-by: prabhat pankaj Co-authored-by: Abhishek Kumar Co-authored-by: Claude Fable 5 --- api/.env.example | 3 + api/constants.py | 1 + api/routes/auth.py | 4 + api/routes/main.py | 3 + api/tests/test_auth_routes.py | 18 ++++ .../templates/ari-manager-deployment.yaml | 7 +- .../templates/arq-worker-deployment.yaml | 7 +- .../campaign-orchestrator-deployment.yaml | 7 +- deploy/helm/dograh/templates/configmap.yaml | 1 + .../helm/dograh/templates/web-deployment.yaml | 9 +- deploy/helm/dograh/values.yaml | 1 + docker-compose.yaml | 3 + docs/developer/environment-variables.mdx | 1 + ui/src/app/api/config/auth/route.ts | 4 +- ui/src/app/auth/login/LoginForm.tsx | 96 +++++++++++++++++ ui/src/app/auth/login/page.tsx | 100 ++---------------- ui/src/client/types.gen.ts | 4 + ui/src/lib/auth/config.ts | 16 ++- 18 files changed, 184 insertions(+), 101 deletions(-) create mode 100644 ui/src/app/auth/login/LoginForm.tsx diff --git a/api/.env.example b/api/.env.example index e316b78f..8d2b72d1 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/constants.py b/api/constants.py index 319e581d..5db832e7 100644 --- a/api/constants.py +++ b/api/constants.py @@ -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 diff --git a/api/routes/auth.py b/api/routes/auth.py index c67978e0..281ad217 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -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: diff --git a/api/routes/main.py b/api/routes/main.py index b4df2a5c..9a8bb11c 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -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 diff --git a/api/tests/test_auth_routes.py b/api/tests/test_auth_routes.py index 143266b1..b44a362d 100644 --- a/api/tests/test_auth_routes.py +++ b/api/tests/test_auth_routes.py @@ -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() diff --git a/deploy/helm/dograh/templates/ari-manager-deployment.yaml b/deploy/helm/dograh/templates/ari-manager-deployment.yaml index 9bc83252..87705725 100644 --- a/deploy/helm/dograh/templates/ari-manager-deployment.yaml +++ b/deploy/helm/dograh/templates/ari-manager-deployment.yaml @@ -25,10 +25,13 @@ spec: labels: {{- include "dograh.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: ari-manager - {{- with .Values.ariManager.podAnnotations }} annotations: + # Roll pods when the ConfigMap changes. See web-deployment.yaml for + # the full rationale. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.ariManager.podAnnotations }} {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} spec: serviceAccountName: {{ include "dograh.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} diff --git a/deploy/helm/dograh/templates/arq-worker-deployment.yaml b/deploy/helm/dograh/templates/arq-worker-deployment.yaml index d52f1100..d63c78cc 100644 --- a/deploy/helm/dograh/templates/arq-worker-deployment.yaml +++ b/deploy/helm/dograh/templates/arq-worker-deployment.yaml @@ -22,10 +22,13 @@ spec: labels: {{- include "dograh.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: arq-worker - {{- with .Values.workers.podAnnotations }} annotations: + # Roll pods when the ConfigMap changes. See web-deployment.yaml for + # the full rationale. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.workers.podAnnotations }} {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} spec: serviceAccountName: {{ include "dograh.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} diff --git a/deploy/helm/dograh/templates/campaign-orchestrator-deployment.yaml b/deploy/helm/dograh/templates/campaign-orchestrator-deployment.yaml index 8e1ef2c9..ee6b6f46 100644 --- a/deploy/helm/dograh/templates/campaign-orchestrator-deployment.yaml +++ b/deploy/helm/dograh/templates/campaign-orchestrator-deployment.yaml @@ -25,10 +25,13 @@ spec: labels: {{- include "dograh.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: campaign-orchestrator - {{- with .Values.campaignOrchestrator.podAnnotations }} annotations: + # Roll pods when the ConfigMap changes. See web-deployment.yaml for + # the full rationale. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.campaignOrchestrator.podAnnotations }} {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} spec: serviceAccountName: {{ include "dograh.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} diff --git a/deploy/helm/dograh/templates/configmap.yaml b/deploy/helm/dograh/templates/configmap.yaml index 6502d61b..27316ba1 100644 --- a/deploy/helm/dograh/templates/configmap.yaml +++ b/deploy/helm/dograh/templates/configmap.yaml @@ -18,6 +18,7 @@ data: FORCE_TURN_RELAY: {{ .Values.config.forceTurnRelay | quote }} TURN_HOST: {{ .Values.config.turnHost | quote }} FASTAPI_WORKERS: {{ .Values.config.fastapiWorkers | quote }} + ENABLE_SIGNUP: {{ .Values.config.enableSignup | quote }} {{- /* MinIO endpoints derived from storage mode. */ -}} {{- if eq .Values.storage.mode "internalMinio" }} MINIO_ENDPOINT: {{ printf "%s:9000" (include "dograh.minioHost" .) | quote }} diff --git a/deploy/helm/dograh/templates/web-deployment.yaml b/deploy/helm/dograh/templates/web-deployment.yaml index 91b5d78e..459baa3b 100644 --- a/deploy/helm/dograh/templates/web-deployment.yaml +++ b/deploy/helm/dograh/templates/web-deployment.yaml @@ -24,10 +24,15 @@ spec: labels: {{- include "dograh.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: web - {{- with .Values.web.podAnnotations }} annotations: + # Roll pods when the ConfigMap changes (e.g. `helm upgrade --set + # config.enableSignup=false`). envFrom values are otherwise only read + # at pod startup, so a ConfigMap-only upgrade would leave running pods + # on the stale value until an unrelated restart. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.web.podAnnotations }} {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} spec: serviceAccountName: {{ include "dograh.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} diff --git a/deploy/helm/dograh/values.yaml b/deploy/helm/dograh/values.yaml index ed9c1c33..9a66a7cb 100644 --- a/deploy/helm/dograh/values.yaml +++ b/deploy/helm/dograh/values.yaml @@ -117,6 +117,7 @@ config: forceTurnRelay: false turnHost: "" # public hostname/IP of coturn (the LoadBalancer address) fastapiWorkers: 1 # informational only; web tier scales by pod, not in-pod workers + enableSignup: true # set false to 403 the /api/v1/auth/signup endpoint (invite-only lockdown) # ----------------------------------------------------------------------------- # Secrets — rendered into a Kubernetes Secret unless secrets.existingSecret is diff --git a/docker-compose.yaml b/docker-compose.yaml index 9a6d7f7a..f66cb51d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -147,6 +147,9 @@ services: ENVIRONMENT: "${ENVIRONMENT:-local}" LOG_LEVEL: "INFO" + # Set to "false" in .env to disable public signup (invite-only installs). + ENABLE_SIGNUP: "${ENABLE_SIGNUP:-true}" + # Public origin for this deployment. The API derives BACKEND_API_ENDPOINT, # MINIO_PUBLIC_ENDPOINT and TURN_HOST from PUBLIC_BASE_URL / PUBLIC_HOST when # they are not set explicitly (see api/constants.py), so a standard remote diff --git a/docs/developer/environment-variables.mdx b/docs/developer/environment-variables.mdx index 577e7502..371c211d 100644 --- a/docs/developer/environment-variables.mdx +++ b/docs/developer/environment-variables.mdx @@ -41,6 +41,7 @@ The relevant required variables for each mode are noted in the descriptions belo |---|---|---| | `OSS_JWT_SECRET` | N/A | **Required for OSS deployments.** Secret used to sign JWT tokens. Must be set to a strong random value in production | | `OSS_JWT_EXPIRY_HOURS` | `720` | JWT token lifetime in hours (default: 30 days) | +| `ENABLE_SIGNUP` | `true` | Set to `false` to disable public signup on invite-only installs — `POST /api/v1/auth/signup` returns 403 and the login page hides the Sign up link | Never use the placeholder `OSS_JWT_SECRET` in a production deployment. Generate a strong random secret and store it securely. diff --git a/ui/src/app/api/config/auth/route.ts b/ui/src/app/api/config/auth/route.ts index cf6553f3..640c3dac 100644 --- a/ui/src/app/api/config/auth/route.ts +++ b/ui/src/app/api/config/auth/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import { getAuthProvider, getStackConfig } from '@/lib/auth/config'; +import { getAuthProvider, getSignupEnabled, getStackConfig } from '@/lib/auth/config'; import logger from '@/lib/logger'; export async function GET() { @@ -8,10 +8,12 @@ export async function GET() { // When using Stack, hand the public client config to the browser so it can // initialize the Stack SDK at runtime (no build-time NEXT_PUBLIC_* needed). const stackConfig = provider === 'stack' ? await getStackConfig() : null; + const signupEnabled = await getSignupEnabled(); logger.debug(`Got provider ${provider} from getAuthProvider`) return NextResponse.json({ provider, stackProjectId: stackConfig?.projectId ?? null, stackPublishableClientKey: stackConfig?.publishableClientKey ?? null, + signupEnabled, }); } diff --git a/ui/src/app/auth/login/LoginForm.tsx b/ui/src/app/auth/login/LoginForm.tsx new file mode 100644 index 00000000..64f0787d --- /dev/null +++ b/ui/src/app/auth/login/LoginForm.tsx @@ -0,0 +1,96 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { loginApiV1AuthLoginPost } from "@/client/sdk.gen"; +import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA"; +import { AuthShell } from "@/components/auth/AuthShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function LoginForm({ signupEnabled }: { signupEnabled: boolean }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + const res = await loginApiV1AuthLoginPost({ + body: { email, password }, + }); + + if (res.error || !res.data) { + const detail = (res.error as { detail?: string })?.detail; + toast.error(detail || "Login failed"); + return; + } + + // Set httpOnly cookies via server route + await fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: res.data.token, user: res.data.user }), + }); + + window.location.href = "/after-sign-in"; + } catch { + toast.error("An error occurred. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( + }> +
+

Sign in

+

+ Enter your email and password to continue +

+
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+ + {signupEnabled && ( +

+ Don't have an account?{" "} + + Sign up + +

+ )} +
+ ); +} diff --git a/ui/src/app/auth/login/page.tsx b/ui/src/app/auth/login/page.tsx index a1fef886..6f8ced0a 100644 --- a/ui/src/app/auth/login/page.tsx +++ b/ui/src/app/auth/login/page.tsx @@ -1,94 +1,14 @@ -"use client"; +import { getSignupEnabled } from "@/lib/auth/config"; -import Link from "next/link"; -import { useState } from "react"; -import { toast } from "sonner"; +import { LoginForm } from "./LoginForm"; -import { loginApiV1AuthLoginPost } from "@/client/sdk.gen"; -import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA"; -import { AuthShell } from "@/components/auth/AuthShell"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +// Resolve the backend health check before rendering so the "Sign up" link is +// correct on first paint — no client-side fetch, no flicker on locked-down +// installs. force-dynamic keeps the page off the build-time prerender, which +// would bake in the flag's build-environment value. +export const dynamic = "force-dynamic"; -export default function LoginPage() { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - - try { - const res = await loginApiV1AuthLoginPost({ - body: { email, password }, - }); - - if (res.error || !res.data) { - const detail = (res.error as { detail?: string })?.detail; - toast.error(detail || "Login failed"); - return; - } - - // Set httpOnly cookies via server route - await fetch("/api/auth/session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: res.data.token, user: res.data.user }), - }); - - window.location.href = "/after-sign-in"; - } catch { - toast.error("An error occurred. Please try again."); - } finally { - setLoading(false); - } - }; - - return ( - }> -
-

Sign in

-

- Enter your email and password to continue -

-
- -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
- -
- -

- Don't have an account?{" "} - - Sign up - -

-
- ); +export default async function LoginPage() { + const signupEnabled = await getSignupEnabled(); + return ; } diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index 78408549..c7b49e74 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -2884,6 +2884,10 @@ export type HealthResponse = { * Force Turn Relay */ force_turn_relay: boolean; + /** + * Signup Enabled + */ + signup_enabled: boolean; /** * Stack Project Id */ diff --git a/ui/src/lib/auth/config.ts b/ui/src/lib/auth/config.ts index 30cb44d1..77ce3e3b 100644 --- a/ui/src/lib/auth/config.ts +++ b/ui/src/lib/auth/config.ts @@ -10,6 +10,7 @@ export interface StackConfig { interface ResolvedAuthConfig { authProvider: string; stackConfig: StackConfig | null; + signupEnabled: boolean; } let cachedConfig: ResolvedAuthConfig | null = null; @@ -45,7 +46,10 @@ async function resolveAuthConfig(): Promise { data.stack_publishable_client_key as string, } : null; - cachedConfig = { authProvider, stackConfig }; + // Default to signup-enabled when the backend omits the field (older api + // versions before the flag existed) — matches the backend's own default. + const signupEnabled = data.signup_enabled !== false; + cachedConfig = { authProvider, stackConfig, signupEnabled }; return cachedConfig; } } catch { @@ -56,7 +60,7 @@ async function resolveAuthConfig(): Promise { // do NOT cache it: caching here would pin the entire UI to local auth until a // container restart if the first resolution loses the startup race with the api // service. Leaving it uncached means the next request retries and self-heals. - return { authProvider: "local", stackConfig: null }; + return { authProvider: "local", stackConfig: null, signupEnabled: true }; } /** @@ -73,3 +77,11 @@ export async function getAuthProvider(): Promise { export async function getStackConfig(): Promise { return (await resolveAuthConfig()).stackConfig; } + +/** + * Returns true when the backend allows signup (`ENABLE_SIGNUP`, default true). + * The login page uses this to hide the signup link on locked-down installs. + */ +export async function getSignupEnabled(): Promise { + return (await resolveAuthConfig()).signupEnabled; +}