dograh/api/routes/auth.py

146 lines
4.5 KiB
Python
Raw Normal View History

from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
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>
2026-07-13 14:08:25 +05:30
from api.constants import ENABLE_SIGNUP
from api.db import db_client
from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey, PostHogEvent
from api.schemas.auth import AuthResponse, LoginRequest, SignupRequest, UserResponse
from api.services.auth.depends import (
create_user_configuration_with_mps_key,
get_user,
require_local_auth,
)
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
)
from api.services.posthog_client import capture_event
from api.utils.auth import create_jwt_token, hash_password, verify_password
router = APIRouter(
prefix="/auth",
tags=["auth"],
)
@router.post(
"/signup",
response_model=AuthResponse,
dependencies=[Depends(require_local_auth)],
)
async def signup(request: SignupRequest):
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>
2026-07-13 14:08:25 +05:30
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:
raise HTTPException(status_code=409, detail="Email already registered")
# Hash password and create user
hashed = hash_password(request.password)
user = await db_client.create_user_with_email(
email=request.email,
password_hash=hashed,
name=request.name,
)
# Create organization for the user
org_provider_id = f"org_{user.provider_id}"
organization, _ = await db_client.get_or_create_organization_by_provider_id(
org_provider_id=org_provider_id, user_id=user.id
)
# Link user to organization
await db_client.add_user_to_organization(user.id, organization.id)
await db_client.update_user_selected_organization(user.id, organization.id)
# Create default service configuration
try:
mps_config = await create_user_configuration_with_mps_key(
user.id, organization.id, user.provider_id
)
if mps_config:
await db_client.update_user_configuration(user.id, mps_config)
model_config_v2 = convert_legacy_ai_model_configuration_to_v2(mps_config)
await db_client.upsert_configuration(
organization.id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
model_config_v2.model_dump(mode="json", exclude_none=True),
)
except Exception:
logger.warning(
"Failed to create default configuration for OSS user", exc_info=True
)
# Create JWT token
token = create_jwt_token(user.id, request.email)
capture_event(
distinct_id=str(user.provider_id),
event=PostHogEvent.SIGNED_UP,
properties={
"organization_id": organization.id,
"auth_provider": "local",
},
)
return AuthResponse(
token=token,
user=UserResponse(
id=user.id,
email=user.email,
name=request.name,
organization_id=organization.id,
provider_id=user.provider_id,
),
)
@router.post(
"/login",
response_model=AuthResponse,
dependencies=[Depends(require_local_auth)],
)
async def login(request: LoginRequest):
# Look up user by email
user = await db_client.get_user_by_email(request.email)
if not user or not user.password_hash:
raise HTTPException(status_code=401, detail="Invalid email or password")
# Verify password
if not verify_password(request.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid email or password")
# Create JWT token
token = create_jwt_token(user.id, user.email)
capture_event(
distinct_id=str(user.provider_id),
event=PostHogEvent.SIGNED_IN,
properties={
"organization_id": user.selected_organization_id,
"auth_provider": "local",
},
)
return AuthResponse(
token=token,
user=UserResponse(
id=user.id,
email=user.email,
organization_id=user.selected_organization_id,
provider_id=user.provider_id,
),
)
@router.get("/me", response_model=UserResponse)
async def get_current_user(user: UserModel = Depends(get_user)):
return UserResponse(
id=user.id,
email=user.email,
organization_id=user.selected_organization_id,
provider_id=user.provider_id,
)