From b749c8e0d10f38f6501176b251bb4b59d89a8e97 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Wed, 8 Apr 2026 20:01:52 +0800 Subject: [PATCH] feat: unified URL resolution with 5-level fallback chain and multi-tenant subdomain support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../alembic/versions/add_subdomain_prefix.py | 24 +++++ .../alembic/versions/add_tenant_is_default.py | 27 ++++++ backend/app/api/sso.py | 15 ++- backend/app/api/tenants.py | 91 +++++++++++++++++-- backend/app/core/domain.py | 77 ++++++++++++++++ backend/app/models/tenant.py | 5 + backend/app/services/platform_service.py | 24 ++++- frontend/src/i18n/en.json | 6 +- frontend/src/i18n/zh.json | 6 +- frontend/src/pages/AdminCompanies.tsx | 55 +++++++++++ 10 files changed, 306 insertions(+), 24 deletions(-) create mode 100644 backend/alembic/versions/add_subdomain_prefix.py create mode 100644 backend/alembic/versions/add_tenant_is_default.py create mode 100644 backend/app/core/domain.py diff --git a/backend/alembic/versions/add_subdomain_prefix.py b/backend/alembic/versions/add_subdomain_prefix.py new file mode 100644 index 000000000..3062efdff --- /dev/null +++ b/backend/alembic/versions/add_subdomain_prefix.py @@ -0,0 +1,24 @@ +"""Add subdomain_prefix to tenants table.""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision: str = "add_subdomain_prefix" +down_revision = "f1a2b3c4d5e6" +branch_labels = None +depends_on = None + +def upgrade() -> None: + conn = op.get_bind() + inspector = inspect(conn) + columns = [c["name"] for c in inspector.get_columns("tenants")] + if "subdomain_prefix" not in columns: + op.add_column("tenants", sa.Column("subdomain_prefix", sa.String(50), nullable=True)) + indexes = [i["name"] for i in inspector.get_indexes("tenants")] + if "ix_tenants_subdomain_prefix" not in indexes: + op.create_index("ix_tenants_subdomain_prefix", "tenants", ["subdomain_prefix"], unique=True) + +def downgrade() -> None: + op.drop_index("ix_tenants_subdomain_prefix", "tenants") + op.drop_column("tenants", "subdomain_prefix") diff --git a/backend/alembic/versions/add_tenant_is_default.py b/backend/alembic/versions/add_tenant_is_default.py new file mode 100644 index 000000000..00e164615 --- /dev/null +++ b/backend/alembic/versions/add_tenant_is_default.py @@ -0,0 +1,27 @@ +"""Add is_default field to tenants table.""" + +from alembic import op +import sqlalchemy as sa + +revision = "add_tenant_is_default" +down_revision = "add_subdomain_prefix" +branch_labels = None +depends_on = None + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + cols = [c['name'] for c in inspector.get_columns('tenants')] + if 'is_default' not in cols: + op.add_column('tenants', sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false')) + conn.execute(sa.text(""" + UPDATE tenants + SET is_default = true + WHERE id = ( + SELECT id FROM tenants WHERE is_active = true ORDER BY created_at ASC LIMIT 1 + ) + AND NOT EXISTS (SELECT 1 FROM tenants WHERE is_default = true) + """)) + +def downgrade() -> None: + op.drop_column('tenants', 'is_default') diff --git a/backend/app/api/sso.py b/backend/app/api/sso.py index 1c5210247..940b5d7b4 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -104,15 +104,12 @@ async def get_sso_config(sid: uuid.UUID, request: Request, db: AsyncSession = De result = await db.execute(query) providers = result.scalars().all() - # Determine the base URL for OAuth callbacks using centralized platform service: - from app.services.platform_service import platform_service - if session.tenant_id: - from app.models.tenant import Tenant - tenant_result = await db.execute(select(Tenant).where(Tenant.id == session.tenant_id)) - tenant_obj = tenant_result.scalar_one_or_none() - public_base = await platform_service.get_tenant_sso_base_url(db, tenant_obj, request) - else: - public_base = await platform_service.get_public_base_url(db, request) + # Determine the base URL for OAuth callbacks using unified domain resolution: + from app.core.domain import resolve_base_url + public_base = await resolve_base_url( + db, request=request, + tenant_id=str(session.tenant_id) if session.tenant_id else None + ) auth_urls = [] for p in providers: diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index c55f479c0..be01aba04 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -38,6 +38,9 @@ class TenantOut(BaseModel): sso_enabled: bool = False sso_domain: str | None = None a2a_async_enabled: bool = False + is_default: bool = False + subdomain_prefix: str | None = None + effective_base_url: str | None = None created_at: datetime | None = None model_config = {"from_attributes": True} @@ -51,6 +54,8 @@ class TenantUpdate(BaseModel): sso_enabled: bool | None = None sso_domain: str | None = None a2a_async_enabled: bool | None = None + subdomain_prefix: str | None = None + is_default: bool | None = None # ─── Helpers ──────────────────────────────────────────── @@ -340,6 +345,23 @@ async def get_registration_config(db: AsyncSession = Depends(get_db)): return {"allow_self_create_company": allowed} +# ─── Public: Check Subdomain Prefix Availability ───────── + +@router.get("/check-prefix") +async def check_prefix(prefix: str, db: AsyncSession = Depends(get_db)): + """Check if a subdomain prefix is available.""" + import re as _re + if not _re.match(r'^[a-z0-9]([a-z0-9\-]{0,48}[a-z0-9])?$', prefix): + return {"available": False, "reason": "Invalid format"} + reserved = {"www", "api", "app", "admin", "mail", "smtp", "ftp", "ns1", "ns2", "cdn", "static", "assets"} + if prefix in reserved: + return {"available": False, "reason": "Reserved"} + result = await db.execute(select(Tenant).where(Tenant.subdomain_prefix == prefix)) + if result.scalar_one_or_none(): + return {"available": False, "reason": "Already taken"} + return {"available": True} + + # ─── Public: Resolve Tenant by Domain ─────────────────── @router.get("/resolve-by-domain") @@ -347,15 +369,21 @@ async def resolve_tenant_by_domain( domain: str, db: AsyncSession = Depends(get_db), ): - """Resolve a tenant by its sso_domain or subdomain slug. + """Resolve a tenant by its sso_domain, subdomain_prefix, or clawith.ai slug. sso_domain is stored as a full URL (e.g. "https://acme.clawith.ai" or "http://1.2.3.4:3009"). The incoming `domain` parameter is the host (without protocol). - Lookup precedence: - 1. Exact match on tenant.sso_domain ending with the host (strips protocol) - 2. Extract slug from "{slug}.clawith.ai" and match tenant.slug + Lookup precedence (each step matches only an explicitly configured value — + no wildcard slug fallback, no default-tenant fallback): + 1. Exact match on tenant.sso_domain with protocol prepended + 2. Port-stripped match on tenant.sso_domain + 3. subdomain_prefix match when host = "{prefix}.{platform_base_host}" + 4. Legacy slug match against "{slug}.clawith.ai" """ + from app.core.domain import _get_global_base_url + from urllib.parse import urlparse + tenant = None # 1. Match by stripping protocol from stored sso_domain @@ -379,7 +407,24 @@ async def resolve_tenant_by_domain( if tenant: break - # 3. Fallback: extract slug from subdomain pattern + # 3. subdomain_prefix: host must equal "{prefix}.{platform_base_host}" + # platform_base_host is read from system_settings / env, so this only + # triggers on the explicitly configured platform domain. + if not tenant: + hostname = domain.split(":")[0].lower() + global_url = await _get_global_base_url(db) + if global_url: + parsed = urlparse(global_url) + global_host = (parsed.hostname or "").lower() + if global_host and hostname.endswith(f".{global_host}"): + prefix = hostname[: -(len(global_host) + 1)] + if prefix and "." not in prefix: + result = await db.execute( + select(Tenant).where(Tenant.subdomain_prefix == prefix) + ) + tenant = result.scalar_one_or_none() + + # 4. Legacy fallback: extract slug from clawith.ai subdomain pattern if not tenant: import re m = re.match(r"^([a-z0-9][a-z0-9\-]*[a-z0-9])\.clawith\.ai$", domain.lower()) @@ -389,7 +434,10 @@ async def resolve_tenant_by_domain( tenant = result.scalar_one_or_none() if not tenant or not tenant.is_active or not tenant.sso_enabled: - raise HTTPException(status_code=404, detail="Tenant not found or not active or SSO not enabled") + raise HTTPException( + status_code=404, + detail="Tenant not found or not active or SSO not enabled", + ) return { "id": tenant.id, @@ -397,6 +445,7 @@ async def resolve_tenant_by_domain( "slug": tenant.slug, "sso_enabled": tenant.sso_enabled, "sso_domain": tenant.sso_domain, + "subdomain_prefix": tenant.subdomain_prefix, "is_active": tenant.is_active, } @@ -427,7 +476,10 @@ async def get_tenant( tenant = result.scalar_one_or_none() if not tenant: raise HTTPException(status_code=404, detail="Tenant not found") - return TenantOut.model_validate(tenant) + from app.core.domain import resolve_base_url + out = TenantOut.model_validate(tenant) + out.effective_base_url = await resolve_base_url(db, tenant_id=str(tenant.id)) + return out @router.put("/{tenant_id}", response_model=TenantOut) @@ -446,13 +498,36 @@ async def update_tenant( raise HTTPException(status_code=404, detail="Tenant not found") update_data = data.model_dump(exclude_unset=True) - + # SSO configuration is managed exclusively by the company's own org_admin # via the Enterprise Settings page. Platform admins should not override it here. if current_user.role == "platform_admin": update_data.pop("sso_enabled", None) update_data.pop("sso_domain", None) + # Validate subdomain_prefix format if provided + if "subdomain_prefix" in update_data and update_data["subdomain_prefix"] is not None: + import re as _re + prefix = update_data["subdomain_prefix"] + if not _re.match(r'^[a-z0-9]([a-z0-9\-]{0,48}[a-z0-9])?$', prefix): + raise HTTPException(status_code=400, detail="Invalid subdomain prefix format") + reserved = {"www", "api", "app", "admin", "mail", "smtp", "ftp", "ns1", "ns2", "cdn", "static", "assets"} + if prefix in reserved: + raise HTTPException(status_code=400, detail="Subdomain prefix is reserved") + # Check uniqueness + existing = await db.execute( + select(Tenant).where(Tenant.subdomain_prefix == prefix, Tenant.id != tenant_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Subdomain prefix already taken") + + # If setting is_default=True, clear other defaults + if update_data.get("is_default") is True: + from sqlalchemy import update as sql_update + await db.execute( + sql_update(Tenant).where(Tenant.id != tenant_id).values(is_default=False) + ) + for field, value in update_data.items(): setattr(tenant, field, value) await db.flush() diff --git a/backend/app/core/domain.py b/backend/app/core/domain.py new file mode 100644 index 000000000..6e2a166bb --- /dev/null +++ b/backend/app/core/domain.py @@ -0,0 +1,77 @@ +"""Domain resolution with fallback chain.""" + +import os + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import Request + +from app.models.system_settings import SystemSetting + + +async def _get_global_base_url(db: AsyncSession): + """Helper: read platform public_base_url from system_settings.""" + # Try DB first + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == "platform") + ) + setting = result.scalar_one_or_none() + if setting and setting.value.get("public_base_url"): + return setting.value["public_base_url"].rstrip("/") + # Fallback to ENV + env_url = os.environ.get("PUBLIC_BASE_URL") + if env_url: + return env_url.rstrip("/") + return None + + +async def resolve_base_url( + db: AsyncSession, + request: Request | None = None, + tenant_id: str | None = None, +) -> str: + """Resolve the effective base URL using the fallback chain: + + 1. Tenant-specific sso_domain (if tenant_id provided and tenant has sso_domain) + 2. Tenant subdomain_prefix + global hostname + 3. Platform global public_base_url (from system_settings) + 4. Request origin (from request.base_url) + 5. Hardcoded fallback + + Returns a full URL like "https://acme.example.com" or "http://localhost:3008" + """ + # Level 1 & 2: Tenant-specific + if tenant_id: + from app.models.tenant import Tenant + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if tenant: + # Level 1: complete custom domain + if tenant.sso_domain: + domain = tenant.sso_domain.rstrip("/") + if domain.startswith("http://") or domain.startswith("https://"): + return domain + return f"https://{domain}" + + # Level 2: subdomain prefix + global hostname (skip for default tenant) + if tenant.subdomain_prefix and not getattr(tenant, 'is_default', False): + global_url = await _get_global_base_url(db) + if global_url: + from urllib.parse import urlparse + parsed = urlparse(global_url) + host = f"{tenant.subdomain_prefix}.{parsed.hostname}" + if parsed.port and parsed.port not in (80, 443): + host = f"{host}:{parsed.port}" + return f"{parsed.scheme}://{host}" + + # Level 3: Platform global setting + global_url = await _get_global_base_url(db) + if global_url: + return global_url + + # Level 4: Request origin + if request: + return str(request.base_url).rstrip("/") + + # Level 5: Hardcoded fallback + return "http://localhost:8000" diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index ada852476..113a75e16 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -44,6 +44,11 @@ class Tenant(Base): sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False) sso_domain: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True) + # Subdomain prefix for auto-generated tenant URLs (e.g. "acme" → acme.clawith.com) + subdomain_prefix: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, nullable=True) + # Whether this is the platform's default tenant + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + # Trigger limits — defaults for new agents & floor values default_max_triggers: Mapped[int] = mapped_column(Integer, default=20) min_poll_interval_floor: Mapped[int] = mapped_column(Integer, default=5) diff --git a/backend/app/services/platform_service.py b/backend/app/services/platform_service.py index 7bdc61faf..3f73aa8d7 100644 --- a/backend/app/services/platform_service.py +++ b/backend/app/services/platform_service.py @@ -20,23 +20,37 @@ def is_ip_address(self, host: str) -> bool: async def get_public_base_url(self, db: AsyncSession | None = None, request: Request | None = None) -> str: """Resolve the platform's public base URL with priority lookup. - + Priority: 1. Environment variable (PUBLIC_BASE_URL) - from .env or docker - 2. Incoming request's base URL (browser address) - 3. Hardcoded fallback (https://try.clawith.ai) + 2. Database system_settings (platform.public_base_url) + 3. Incoming request's base URL (browser address) + 4. Hardcoded fallback (https://try.clawith.ai) """ # 1. Try environment variable env_url = os.environ.get("PUBLIC_BASE_URL") if env_url: return env_url.rstrip("/") - # 2. Fallback to request (browser address) + # 2. Try database system_settings + if db: + try: + from app.models.system_settings import SystemSetting + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == "platform") + ) + setting = result.scalar_one_or_none() + if setting and setting.value and setting.value.get("public_base_url"): + return setting.value["public_base_url"].rstrip("/") + except Exception: + pass + + # 3. Fallback to request (browser address) if request: # Note: request.base_url might include trailing slash return str(request.base_url).rstrip("/") - # 3. Absolute fallback + # 4. Absolute fallback return "https://try.clawith.ai" diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index adcb5f37f..3cc0d4d37 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -1733,6 +1733,10 @@ "ssoConfigDesc": "Configure SSO and custom domain for this company.", "ssoEnabled": "Enable SSO", "ssoDomain": "Custom Access Domain", - "ssoDomainPlaceholder": "e.g. acme.clawith.com" + "ssoDomainPlaceholder": "e.g. acme.clawith.com", + "publicUrl": { + "title": "Public URL", + "desc": "The external URL used for SSO callbacks, subdomain generation, and email links. Include the protocol (e.g. https://example.com)." + } } } diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index d3fd7c8f0..d7240bc3f 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -1628,7 +1628,11 @@ "all": "全部", "filterStatus": "按状态筛选", "enable": "启用", - "noFilterResults": "没有符合当前筛选条件的公司。" + "noFilterResults": "没有符合当前筛选条件的公司。", + "publicUrl": { + "title": "公开访问地址", + "desc": "用于 SSO 回调、子域名生成和邮件链接的外部 URL。请包含协议(如 https://example.com)。" + } }, "companySetup": { "title": "设置你的公司", diff --git a/frontend/src/pages/AdminCompanies.tsx b/frontend/src/pages/AdminCompanies.tsx index 884b0f62d..bd0b66713 100644 --- a/frontend/src/pages/AdminCompanies.tsx +++ b/frontend/src/pages/AdminCompanies.tsx @@ -109,6 +109,10 @@ function PlatformTab() { const [nbSaving, setNbSaving] = useState(false); const [nbSaved, setNbSaved] = useState(false); + // Public Base URL + const [publicBaseUrl, setPublicBaseUrl] = useState(''); + const [publicBaseUrlSaving, setPublicBaseUrlSaving] = useState(false); + const [publicBaseUrlSaved, setPublicBaseUrlSaved] = useState(false); // System email configuration const [systemEmailConfig, setSystemEmailConfig] = useState({ @@ -163,6 +167,16 @@ function PlatformTab() { } }).catch(() => { }); + // Load Public Base URL + const token2 = localStorage.getItem('token'); + fetch('/api/enterprise/system-settings/platform', { + headers: { 'Content-Type': 'application/json', ...(token2 ? { Authorization: `Bearer ${token2}` } : {}) }, + }).then(r => r.json()).then(d => { + if (d?.value?.public_base_url) { + setPublicBaseUrl(d.value.public_base_url); + } + }).catch(() => { }); + // Load System Email fetchJson('/enterprise/system-settings/system_email_platform') .then(d => { @@ -219,6 +233,24 @@ function PlatformTab() { }; + const savePublicBaseUrl = async () => { + setPublicBaseUrlSaving(true); + try { + const token = localStorage.getItem('token'); + await fetch('/api/enterprise/system-settings/platform', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ value: { public_base_url: publicBaseUrl.trim() || null } }), + }); + setPublicBaseUrlSaved(true); + setTimeout(() => setPublicBaseUrlSaved(false), 2000); + showToast(t('enterprise.config.saved', 'Saved')); + } catch (e: any) { + showToast(e.message || t('common.error', 'Failed to save'), 'error'); + } + setPublicBaseUrlSaving(false); + }; + const saveEmailConfig = async () => { setEmailConfigSaving(true); try { @@ -334,6 +366,29 @@ function PlatformTab() { + {/* Public Base URL */} +
+
+ {t('admin.publicUrl.title', 'Public URL')} +
+
+ {t('admin.publicUrl.desc', 'The external URL used for webhook callbacks and published page links. Include the protocol (e.g. https://example.com).')} +
+
+ setPublicBaseUrl(e.target.value)} + placeholder="https://clawith.example.com" + style={{ fontSize: '13px', flex: 1, maxWidth: '400px' }} + /> + + {publicBaseUrlSaved && {t('enterprise.config.saved', 'Saved')}} +
+
+ {/* Notification Bar */}