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>
181 lines
7.1 KiB
Python
181 lines
7.1 KiB
Python
import secrets
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Header, HTTPException, status
|
|
from loguru import logger
|
|
from pydantic import BaseModel
|
|
|
|
from api.routes.agent_stream import router as agent_stream_router
|
|
from api.routes.auth import router as auth_router
|
|
from api.routes.campaign import router as campaign_router
|
|
from api.routes.credentials import router as credentials_router
|
|
from api.routes.folder import router as folder_router
|
|
from api.routes.knowledge_base import router as knowledge_base_router
|
|
from api.routes.node_types import router as node_types_router
|
|
from api.routes.organization import router as organization_router
|
|
from api.routes.organization_usage import router as organization_usage_router
|
|
from api.routes.public_agent import router as public_agent_router
|
|
from api.routes.public_download import router as public_download_router
|
|
from api.routes.public_embed import router as public_embed_router
|
|
from api.routes.reports import router as reports_router
|
|
from api.routes.s3_signed_url import router as s3_router
|
|
from api.routes.service_keys import router as service_keys_router
|
|
from api.routes.superuser import router as superuser_router
|
|
from api.routes.telephony import router as telephony_router
|
|
from api.routes.tool import router as tool_router
|
|
from api.routes.turn_credentials import router as turn_credentials_router
|
|
from api.routes.user import router as user_router
|
|
from api.routes.webrtc_signaling import router as webrtc_signaling_router
|
|
from api.routes.workflow import router as workflow_router
|
|
from api.routes.workflow_embed import router as workflow_embed_router
|
|
from api.routes.workflow_recording import router as workflow_recording_router
|
|
from api.routes.workflow_text_chat import router as workflow_text_chat_router
|
|
from api.services.integrations import all_routers
|
|
|
|
router = APIRouter(
|
|
tags=["main"],
|
|
responses={404: {"description": "Not found"}},
|
|
)
|
|
|
|
router.include_router(telephony_router)
|
|
router.include_router(superuser_router)
|
|
router.include_router(workflow_router)
|
|
router.include_router(workflow_text_chat_router)
|
|
router.include_router(user_router)
|
|
router.include_router(campaign_router)
|
|
router.include_router(credentials_router)
|
|
router.include_router(tool_router)
|
|
router.include_router(organization_router)
|
|
router.include_router(s3_router)
|
|
router.include_router(service_keys_router)
|
|
router.include_router(organization_usage_router)
|
|
router.include_router(reports_router)
|
|
router.include_router(webrtc_signaling_router)
|
|
router.include_router(turn_credentials_router)
|
|
router.include_router(public_embed_router)
|
|
router.include_router(public_agent_router)
|
|
router.include_router(public_download_router)
|
|
router.include_router(workflow_embed_router)
|
|
router.include_router(knowledge_base_router)
|
|
router.include_router(workflow_recording_router)
|
|
router.include_router(folder_router)
|
|
router.include_router(auth_router)
|
|
router.include_router(node_types_router)
|
|
router.include_router(agent_stream_router)
|
|
|
|
for _integration_router in all_routers():
|
|
router.include_router(_integration_router)
|
|
|
|
|
|
class HealthResponse(BaseModel):
|
|
status: str
|
|
version: str
|
|
backend_api_endpoint: str
|
|
# Public URL the deployment is reachable at when it sits behind a Cloudflare
|
|
# tunnel (the host has no public IP). null for a directly-reachable deployment.
|
|
# The UI shows this so operators know the URL telephony providers should call.
|
|
tunnel_url: str | None = None
|
|
deployment_mode: str
|
|
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.
|
|
stack_project_id: str | None = None
|
|
stack_publishable_client_key: str | None = None
|
|
|
|
|
|
@router.get("/health", response_model=HealthResponse)
|
|
async def health() -> HealthResponse:
|
|
from api.constants import (
|
|
APP_VERSION,
|
|
AUTH_PROVIDER,
|
|
BACKEND_API_ENDPOINT,
|
|
DEPLOYMENT_MODE,
|
|
ENABLE_SIGNUP,
|
|
FORCE_TURN_RELAY,
|
|
STACK_AUTH_PROJECT_ID,
|
|
STACK_PUBLISHABLE_CLIENT_KEY,
|
|
TURN_SECRET,
|
|
)
|
|
from api.utils.common import get_backend_endpoints, is_local_or_private_url
|
|
|
|
logger.debug("Health endpoint called")
|
|
backend_endpoint, _ = await get_backend_endpoints()
|
|
# tunnel_url is set only when a Cloudflare tunnel was actually resolved: the
|
|
# configured address isn't publicly reachable, but get_backend_endpoints found
|
|
# a public tunnel URL for it. This is the URL the UI shows for inbound webhooks.
|
|
# It stays null for a directly-reachable (public IP / domain) deployment, where
|
|
# backend_api_endpoint itself is the public URL.
|
|
tunnel_url = (
|
|
backend_endpoint
|
|
if is_local_or_private_url(BACKEND_API_ENDPOINT)
|
|
and not is_local_or_private_url(backend_endpoint)
|
|
else None
|
|
)
|
|
is_stack = AUTH_PROVIDER == "stack"
|
|
return HealthResponse(
|
|
status="ok",
|
|
version=APP_VERSION,
|
|
backend_api_endpoint=BACKEND_API_ENDPOINT,
|
|
tunnel_url=tunnel_url,
|
|
deployment_mode=DEPLOYMENT_MODE,
|
|
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
|
|
),
|
|
)
|
|
|
|
|
|
class ActiveCallsResponse(BaseModel):
|
|
active_calls: int
|
|
|
|
|
|
DOGRAH_DEVOPS_SECRET_HEADER = "X-Dograh-Devops-Secret"
|
|
|
|
|
|
def _verify_devops_secret(
|
|
configured_secret: str | None,
|
|
provided_secret: str | None,
|
|
) -> None:
|
|
if not configured_secret:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Devops secret is not configured",
|
|
)
|
|
if not provided_secret or not secrets.compare_digest(
|
|
provided_secret,
|
|
configured_secret,
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Forbidden",
|
|
)
|
|
|
|
|
|
@router.get("/health/active-calls", response_model=ActiveCallsResponse)
|
|
async def active_calls(
|
|
x_dograh_devops_secret: Annotated[
|
|
str | None,
|
|
Header(alias=DOGRAH_DEVOPS_SECRET_HEADER),
|
|
] = None,
|
|
) -> ActiveCallsResponse:
|
|
"""In-flight call count for THIS worker — the drain signal for deploys.
|
|
|
|
A deploy orchestrator polls this per worker and waits for zero before
|
|
sending SIGTERM, because uvicorn force-closes live call WebSockets (close
|
|
code 1012) on SIGTERM and would cut calls mid-conversation otherwise. The
|
|
count is per-process: one uvicorn per VM port (scripts/rolling_update.sh)
|
|
or per Kubernetes pod (preStop hook). See api/services/pipecat/active_calls.py.
|
|
"""
|
|
from api.constants import DOGRAH_DEVOPS_SECRET
|
|
from api.services.pipecat.active_calls import active_call_count
|
|
|
|
_verify_devops_secret(DOGRAH_DEVOPS_SECRET, x_dograh_devops_secret)
|
|
return ActiveCallsResponse(active_calls=active_call_count())
|