mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
* 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>
145 lines
4.5 KiB
Python
145 lines
4.5 KiB
Python
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
|
|
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):
|
|
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,
|
|
)
|