mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-24 19:17:14 +00:00
feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
import typing
|
||||
from email.message import EmailMessage
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from ..utils import httpclient
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core.app import Application
|
||||
|
||||
|
||||
DeliveryStatus = typing.Literal['sent', 'link_only', 'failed']
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class InvitationDeliveryResult:
|
||||
status: DeliveryStatus
|
||||
provider: str | None
|
||||
|
||||
def to_public_dict(self) -> dict[str, str | None]:
|
||||
return {'status': self.status, 'provider': self.provider}
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class _EmailConfig:
|
||||
provider: typing.Literal['resend', 'smtp'] | None
|
||||
sender: str
|
||||
resend_api_key: str
|
||||
resend_api_url: str
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_username: str
|
||||
smtp_password: str
|
||||
smtp_starttls: bool
|
||||
smtp_ssl: bool
|
||||
timeout: float
|
||||
|
||||
|
||||
class InvitationDeliveryService:
|
||||
"""Optional Workspace invitation email delivery.
|
||||
|
||||
The invitation link is always returned to the caller. Email failures are
|
||||
reported as non-secret status and never invalidate the persisted invite.
|
||||
"""
|
||||
|
||||
def __init__(self, ap: Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
def capability(self) -> dict[str, str | bool | None]:
|
||||
config = self._email_config()
|
||||
return {'enabled': config.provider is not None, 'provider': config.provider}
|
||||
|
||||
def build_invitation_link(self, token: str) -> str:
|
||||
base_url = self._public_web_url().rstrip('/')
|
||||
return f'{base_url}/invitations/accept#token={quote(token, safe="")}'
|
||||
|
||||
async def deliver_invitation(
|
||||
self,
|
||||
*,
|
||||
recipient_email: str,
|
||||
workspace_name: str,
|
||||
invitation_link: str,
|
||||
) -> InvitationDeliveryResult:
|
||||
config = self._email_config()
|
||||
if config.provider is None:
|
||||
return InvitationDeliveryResult(status='link_only', provider=None)
|
||||
|
||||
try:
|
||||
if config.provider == 'resend':
|
||||
sent = await self._send_resend(config, recipient_email, workspace_name, invitation_link)
|
||||
else:
|
||||
sent = await self._send_smtp(config, recipient_email, workspace_name, invitation_link)
|
||||
except Exception as exc:
|
||||
self._log_delivery_failure(config.provider, exc)
|
||||
sent = False
|
||||
|
||||
return InvitationDeliveryResult(
|
||||
status='sent' if sent else 'failed',
|
||||
provider=config.provider,
|
||||
)
|
||||
|
||||
async def _send_resend(
|
||||
self,
|
||||
config: _EmailConfig,
|
||||
recipient_email: str,
|
||||
workspace_name: str,
|
||||
invitation_link: str,
|
||||
) -> bool:
|
||||
payload = {
|
||||
'from': config.sender,
|
||||
'to': [recipient_email],
|
||||
'subject': f'You were invited to {workspace_name}',
|
||||
'text': self._plain_text(workspace_name, invitation_link),
|
||||
'html': self._html(workspace_name, invitation_link),
|
||||
}
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(config.timeout),
|
||||
trust_env=True,
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||
) as client:
|
||||
response = await client.post(
|
||||
config.resend_api_url,
|
||||
headers={'Authorization': f'Bearer {config.resend_api_key}'},
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
self._log_delivery_failure(
|
||||
config.provider or 'resend', RuntimeError(f'Resend returned {response.status_code}')
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _send_smtp(
|
||||
self,
|
||||
config: _EmailConfig,
|
||||
recipient_email: str,
|
||||
workspace_name: str,
|
||||
invitation_link: str,
|
||||
) -> bool:
|
||||
message = EmailMessage()
|
||||
message['From'] = config.sender
|
||||
message['To'] = recipient_email
|
||||
message['Subject'] = f'You were invited to {workspace_name}'
|
||||
message.set_content(self._plain_text(workspace_name, invitation_link))
|
||||
message.add_alternative(self._html(workspace_name, invitation_link), subtype='html')
|
||||
|
||||
return await asyncio.to_thread(self._send_smtp_sync, config, message)
|
||||
|
||||
@staticmethod
|
||||
def _send_smtp_sync(config: _EmailConfig, message: EmailMessage) -> bool:
|
||||
smtp_cls = smtplib.SMTP_SSL if config.smtp_ssl else smtplib.SMTP
|
||||
context = ssl.create_default_context()
|
||||
with smtp_cls(config.smtp_host, config.smtp_port, timeout=config.timeout) as smtp:
|
||||
if config.smtp_starttls and not config.smtp_ssl:
|
||||
smtp.starttls(context=context)
|
||||
if config.smtp_username:
|
||||
smtp.login(config.smtp_username, config.smtp_password)
|
||||
smtp.send_message(message)
|
||||
return True
|
||||
|
||||
def _email_config(self) -> _EmailConfig:
|
||||
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
|
||||
email = data.get('workspace', {}).get('invitations', {}).get('email', {})
|
||||
if not isinstance(email, dict):
|
||||
email = {}
|
||||
raw_provider = self._env('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', email.get('provider', ''))
|
||||
raw_provider = str(raw_provider or '').strip().casefold()
|
||||
provider: typing.Literal['resend', 'smtp'] | None
|
||||
provider = raw_provider if raw_provider in {'resend', 'smtp'} else None
|
||||
sender = str(self._env('WORKSPACE__INVITATIONS__EMAIL__FROM', email.get('from', '')) or '').strip()
|
||||
timeout = self._number(
|
||||
self._env('WORKSPACE__INVITATIONS__EMAIL__TIMEOUT_SECONDS', email.get('timeout_seconds', 10)),
|
||||
10.0,
|
||||
)
|
||||
|
||||
resend = email.get('resend', {})
|
||||
if not isinstance(resend, dict):
|
||||
resend = {}
|
||||
smtp_config = email.get('smtp', {})
|
||||
if not isinstance(smtp_config, dict):
|
||||
smtp_config = {}
|
||||
|
||||
resend_api_key = str(
|
||||
self._env('WORKSPACE__INVITATIONS__EMAIL__RESEND__API_KEY', resend.get('api_key', '')) or ''
|
||||
).strip()
|
||||
resend_api_url = str(
|
||||
self._env(
|
||||
'WORKSPACE__INVITATIONS__EMAIL__RESEND__API_URL',
|
||||
resend.get('api_url', 'https://api.resend.com/emails'),
|
||||
)
|
||||
or ''
|
||||
).strip()
|
||||
smtp_host = str(
|
||||
self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', smtp_config.get('host', '')) or ''
|
||||
).strip()
|
||||
smtp_port = int(
|
||||
self._number(self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__PORT', smtp_config.get('port', 587)), 587)
|
||||
)
|
||||
smtp_username = str(
|
||||
self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__USERNAME', smtp_config.get('username', '')) or ''
|
||||
).strip()
|
||||
smtp_password = str(
|
||||
self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', smtp_config.get('password', '')) or ''
|
||||
)
|
||||
smtp_starttls = self._bool(
|
||||
self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__STARTTLS', smtp_config.get('starttls', True))
|
||||
)
|
||||
smtp_ssl = self._bool(self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__SSL', smtp_config.get('ssl', False)))
|
||||
|
||||
if provider == 'resend' and not (sender and resend_api_key and resend_api_url):
|
||||
provider = None
|
||||
elif provider == 'smtp' and not (sender and smtp_host):
|
||||
provider = None
|
||||
|
||||
return _EmailConfig(
|
||||
provider=provider,
|
||||
sender=sender,
|
||||
resend_api_key=resend_api_key,
|
||||
resend_api_url=resend_api_url,
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=smtp_port,
|
||||
smtp_username=smtp_username,
|
||||
smtp_password=smtp_password,
|
||||
smtp_starttls=smtp_starttls,
|
||||
smtp_ssl=smtp_ssl,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def _public_web_url(self) -> str:
|
||||
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
|
||||
invitations = data.get('workspace', {}).get('invitations', {})
|
||||
configured = ''
|
||||
if isinstance(invitations, dict):
|
||||
configured = str(
|
||||
self._env('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', invitations.get('public_web_url', '')) or ''
|
||||
).strip()
|
||||
if configured:
|
||||
return configured
|
||||
api = data.get('api', {})
|
||||
if isinstance(api, dict):
|
||||
webui_url = str(api.get('webui_url', '') or '').strip()
|
||||
if webui_url:
|
||||
return webui_url
|
||||
webhook_prefix = str(api.get('webhook_prefix', '') or '').strip()
|
||||
if webhook_prefix:
|
||||
return webhook_prefix
|
||||
port = api.get('port', 5300)
|
||||
else:
|
||||
port = 5300
|
||||
return f'http://127.0.0.1:{port}'
|
||||
|
||||
@staticmethod
|
||||
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
||||
return (
|
||||
'You have been invited to LangBot Cloud\n\n'
|
||||
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
||||
f'Accept invitation: {invitation_link}\n\n'
|
||||
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
||||
'it was sent to. If you were not expecting it, you can safely ignore this email.\n'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _html(workspace_name: str, invitation_link: str) -> str:
|
||||
import html
|
||||
|
||||
escaped_workspace = html.escape(workspace_name, quote=True)
|
||||
escaped_link = html.escape(invitation_link, quote=True)
|
||||
return f'''<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Join {escaped_workspace} on LangBot Cloud</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
|
||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:36px;">
|
||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
|
||||
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
|
||||
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
|
||||
</td></tr></table>
|
||||
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
|
||||
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
|
||||
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
@staticmethod
|
||||
def _number(value: typing.Any, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _bool(value: typing.Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {'true', '1', 'yes', 'on'}
|
||||
return bool(value)
|
||||
|
||||
@staticmethod
|
||||
def _env(name: str, fallback: typing.Any) -> typing.Any:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return fallback
|
||||
return value
|
||||
|
||||
def _log_delivery_failure(self, provider: str, exc: Exception) -> None:
|
||||
logger = getattr(self.ap, 'logger', None)
|
||||
if logger is not None:
|
||||
logger.warning(f'Workspace invitation email delivery via {provider} failed: {exc.__class__.__name__}')
|
||||
Reference in New Issue
Block a user