Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/alembic/versions/add_subdomain_prefix.py
Original file line number Diff line number Diff line change
@@ -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")
27 changes: 27 additions & 0 deletions backend/alembic/versions/add_tenant_is_default.py
Original file line number Diff line number Diff line change
@@ -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')
15 changes: 6 additions & 9 deletions backend/app/api/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
91 changes: 83 additions & 8 deletions backend/app/api/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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 ────────────────────────────────────────────
Expand Down Expand Up @@ -340,22 +345,45 @@ 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")
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
Expand All @@ -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())
Expand All @@ -389,14 +434,18 @@ 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,
"name": tenant.name,
"slug": tenant.slug,
"sso_enabled": tenant.sso_enabled,
"sso_domain": tenant.sso_domain,
"subdomain_prefix": tenant.subdomain_prefix,
"is_active": tenant.is_active,
}

Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
77 changes: 77 additions & 0 deletions backend/app/core/domain.py
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions backend/app/models/tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 19 additions & 5 deletions backend/app/services/platform_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
6 changes: 5 additions & 1 deletion frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
}
6 changes: 5 additions & 1 deletion frontend/src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1628,7 +1628,11 @@
"all": "全部",
"filterStatus": "按状态筛选",
"enable": "启用",
"noFilterResults": "没有符合当前筛选条件的公司。"
"noFilterResults": "没有符合当前筛选条件的公司。",
"publicUrl": {
"title": "公开访问地址",
"desc": "用于 SSO 回调、子域名生成和邮件链接的外部 URL。请包含协议(如 https://example.com)。"
}
},
"companySetup": {
"title": "设置你的公司",
Expand Down
Loading