mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +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,404 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Loader2, Lock, Mail } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
} from '@/app/infra/entities/workspace';
|
||||
import {
|
||||
backendClient,
|
||||
bootstrapWorkspaceSession,
|
||||
clearPendingInvitationToken,
|
||||
clearUserInfo,
|
||||
getPendingInvitationToken,
|
||||
setPendingInvitationToken,
|
||||
} from '@/app/infra/http';
|
||||
|
||||
type InvitationView = {
|
||||
invitation: WorkspaceInvitation;
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const TERMINAL_INVITATION_ERROR_CODES = new Set([
|
||||
'invitation_invalid',
|
||||
'invitation_expired',
|
||||
'invitation_revoked',
|
||||
'invitation_used',
|
||||
'invitation_email_mismatch',
|
||||
]);
|
||||
|
||||
function invitationErrorKey(
|
||||
code: string | null | undefined,
|
||||
fallback: 'workspace.invitationInvalid' | 'workspace.invitationAcceptFailed',
|
||||
) {
|
||||
switch (code) {
|
||||
case 'invitation_invalid':
|
||||
return 'workspace.invitationInvalid';
|
||||
case 'invitation_expired':
|
||||
return 'workspace.invitationExpired';
|
||||
case 'invitation_revoked':
|
||||
return 'workspace.invitationAlreadyRevoked';
|
||||
case 'invitation_used':
|
||||
return 'workspace.invitationAlreadyUsed';
|
||||
case 'invitation_email_mismatch':
|
||||
return 'workspace.invitationEmailMismatch';
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function captureInvitationTokenFromFragment(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
||||
const token = fragment.get('token')?.trim();
|
||||
if (!token) return getPendingInvitationToken();
|
||||
|
||||
setPendingInvitationToken(token);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
document.title,
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export default function AcceptInvitationPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [invitationHash, setInvitationHash] = useState(() =>
|
||||
typeof window === 'undefined' ? '' : window.location.hash,
|
||||
);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [view, setView] = useState<InvitationView | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
'loading' | 'ready' | 'submitting' | 'success' | 'error'
|
||||
>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => setInvitationHash(window.location.hash);
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus('loading');
|
||||
setView(null);
|
||||
setErrorMessage('');
|
||||
const invitationToken = captureInvitationTokenFromFragment();
|
||||
const deferredErrorCode = new URLSearchParams(window.location.search).get(
|
||||
'error',
|
||||
);
|
||||
setToken(invitationToken);
|
||||
backendClient
|
||||
.getAccountInfo()
|
||||
.then((info) => {
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||
})
|
||||
.catch(() => setPasswordRegistrationEnabled(false));
|
||||
if (!invitationToken) {
|
||||
setErrorMessage(t('workspace.invitationMissing'));
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
backendClient
|
||||
.inspectWorkspaceInvitation(invitationToken)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setView(response);
|
||||
if (deferredErrorCode) {
|
||||
setErrorMessage(
|
||||
t(
|
||||
invitationErrorKey(
|
||||
deferredErrorCode,
|
||||
'workspace.invitationAcceptFailed',
|
||||
),
|
||||
),
|
||||
);
|
||||
setStatus('error');
|
||||
} else {
|
||||
setStatus('ready');
|
||||
}
|
||||
})
|
||||
.catch((error: { code?: string; msg?: string }) => {
|
||||
if (cancelled) return;
|
||||
clearPendingInvitationToken();
|
||||
setErrorMessage(
|
||||
t(invitationErrorKey(error.code, 'workspace.invitationInvalid')),
|
||||
);
|
||||
setStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [invitationHash, t]);
|
||||
|
||||
async function finishAcceptance(registration?: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
if (!token) return;
|
||||
setStatus('submitting');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await backendClient.acceptWorkspaceInvitation(
|
||||
token,
|
||||
registration,
|
||||
);
|
||||
if (registration) {
|
||||
clearPendingInvitationToken();
|
||||
toast.success(t('workspace.invitationAccepted'));
|
||||
navigate('/login?invitation=1', { replace: true });
|
||||
return;
|
||||
}
|
||||
clearPendingInvitationToken();
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid: response.workspace_uuid,
|
||||
});
|
||||
if (workspaceResult.status !== 'ready') {
|
||||
throw new Error('Accepted Workspace could not be initialized');
|
||||
}
|
||||
setStatus('success');
|
||||
toast.success(t('workspace.invitationAccepted'));
|
||||
window.setTimeout(() => navigate('/home', { replace: true }), 600);
|
||||
} catch (error) {
|
||||
const apiError = error as { code?: string; msg?: string };
|
||||
if (apiError.code === 'account_exists_login_required') {
|
||||
setStatus('ready');
|
||||
setErrorMessage(t('workspace.existingAccountLoginRequired'));
|
||||
return;
|
||||
}
|
||||
setErrorMessage(
|
||||
t(
|
||||
invitationErrorKey(apiError.code, 'workspace.invitationAcceptFailed'),
|
||||
),
|
||||
);
|
||||
setStatus(
|
||||
apiError.code && TERMINAL_INVITATION_ERROR_CODES.has(apiError.code)
|
||||
? 'error'
|
||||
: 'ready',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function registerAndAccept() {
|
||||
if (!view) return;
|
||||
if (password.length < 8) {
|
||||
setErrorMessage(t('workspace.passwordMinimum'));
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setErrorMessage(t('workspace.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
void finishAcceptance({
|
||||
email: view.invitation.normalized_email,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
function logoutAndReturn() {
|
||||
if (token) setPendingInvitationToken(token);
|
||||
clearUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
}
|
||||
navigate('/login?invitation=1', { replace: true });
|
||||
}
|
||||
|
||||
function returnToLogin() {
|
||||
clearUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
}
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
const hasLoginToken =
|
||||
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
|
||||
<Card className="w-full max-w-md shadow-lg dark:shadow-white/10">
|
||||
<CardHeader>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<ThemeToggle />
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<img
|
||||
src={langbotIcon}
|
||||
alt="LangBot"
|
||||
className="mx-auto mb-3 size-14"
|
||||
/>
|
||||
<CardTitle className="text-center">
|
||||
{t('workspace.acceptInvitation')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{view
|
||||
? t('workspace.invitedToWorkspace', {
|
||||
workspace: view.workspace.name,
|
||||
})
|
||||
: t('workspace.checkingInvitation')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status === 'loading' && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<CheckCircle2 className="size-9 text-green-600" />
|
||||
<p>{t('workspace.invitationAccepted')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={returnToLogin}
|
||||
>
|
||||
{t('workspace.backToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'ready' || status === 'submitting') && view && (
|
||||
<div className="space-y-4">
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLoginToken ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
{t('workspace.authenticatedInvitationNotice')}
|
||||
</div>
|
||||
<Button className="w-full" onClick={logoutAndReturn}>
|
||||
{t('workspace.logoutAndReturn')}
|
||||
</Button>
|
||||
</div>
|
||||
) : passwordRegistrationEnabled ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="invite-email"
|
||||
>
|
||||
{t('common.email')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="invite-email"
|
||||
value={view.invitation.normalized_email}
|
||||
readOnly
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="invite-password"
|
||||
>
|
||||
{t('common.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="invite-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="pl-10"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="invite-password-confirm"
|
||||
>
|
||||
{t('workspace.confirmPassword')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-password-confirm"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) =>
|
||||
setConfirmPassword(event.target.value)
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={registerAndAccept}
|
||||
>
|
||||
{status === 'submitting' && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('workspace.registerAndAccept')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={() => navigate('/login?invitation=1')}
|
||||
>
|
||||
{t('workspace.alreadyHaveAccount')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => navigate('/login?invitation=1&auto=space')}
|
||||
>
|
||||
{t('common.loginWithSpace')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user