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:
RockChinQ
2026-07-30 21:43:35 +08:00
committed by GitHub
parent 463b120923
commit e1ac5e0fc8
468 changed files with 78320 additions and 13137 deletions
@@ -0,0 +1,413 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Copy,
ExternalLink,
Loader2,
Trash2,
UserPlus,
Users,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@/components/ui/item';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {
CurrentWorkspace,
WorkspaceInvitation,
WorkspaceMembership,
WorkspaceRole,
} from '@/app/infra/entities/workspace';
import { backendClient, systemInfo } from '@/app/infra/http';
import {
PanelBody,
PanelToolbar,
} from '@/app/home/components/settings-dialog/panel-layout';
interface WorkspaceSettingsPanelProps {
active: boolean;
}
const ASSIGNABLE_ROLES: Exclude<WorkspaceRole, 'owner'>[] = [
'admin',
'developer',
'operator',
'viewer',
];
export default function WorkspaceSettingsPanel({
active,
}: WorkspaceSettingsPanelProps) {
const { t } = useTranslation();
const [workspaceInfo, setWorkspaceInfo] = useState<CurrentWorkspace | null>(
null,
);
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
const [loading, setLoading] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] =
useState<Exclude<WorkspaceRole, 'owner'>>('viewer');
const [inviteLoading, setInviteLoading] = useState(false);
const [oneTimeInviteLink, setOneTimeInviteLink] = useState<string | null>(
null,
);
const permissions = useMemo(
() => new Set(workspaceInfo?.permissions ?? []),
[workspaceInfo],
);
const isCloudProjection =
workspaceInfo?.workspace.source === 'cloud_projection';
const canViewMembers = permissions.has('member.view');
const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove');
const canTransferOwner = permissions.has('owner.transfer');
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
const loadWorkspace = useCallback(async () => {
setLoading(true);
try {
const current = await backendClient.getCurrentWorkspace();
setWorkspaceInfo(current);
const [memberResponse, invitationResponse] = await Promise.all([
current.permissions.includes('member.view')
? backendClient.getWorkspaceMembers(current.workspace.uuid)
: Promise.resolve({ members: [] }),
current.permissions.includes('member.invite')
? backendClient.getWorkspaceInvitations(current.workspace.uuid)
: Promise.resolve({ invitations: [] }),
]);
setMembers(memberResponse.members);
setInvitations(invitationResponse.invitations);
} catch {
toast.error(t('workspace.loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
if (active) void loadWorkspace();
}, [active, loadWorkspace]);
async function createInvitation() {
if (!workspaceInfo || !inviteEmail.trim()) return;
setInviteLoading(true);
try {
const response = await backendClient.createWorkspaceInvitation(
workspaceInfo.workspace.uuid,
inviteEmail.trim(),
inviteRole,
);
setOneTimeInviteLink(response.link);
setInviteEmail('');
await loadWorkspace();
toast.success(t(`workspace.delivery.${response.delivery.status}`));
} catch {
toast.error(t('workspace.invitationCreateFailed'));
} finally {
setInviteLoading(false);
}
}
async function copyInvitationLink() {
if (!oneTimeInviteLink) return;
await navigator.clipboard.writeText(oneTimeInviteLink);
toast.success(t('workspace.invitationCopied'));
}
async function updateMemberRole(
member: WorkspaceMembership,
role: WorkspaceRole,
) {
if (!workspaceInfo || member.role === role) return;
try {
await backendClient.updateWorkspaceMemberRole(
workspaceInfo.workspace.uuid,
member.account_uuid,
role,
);
await loadWorkspace();
toast.success(t('workspace.memberUpdated'));
} catch {
toast.error(t('workspace.memberUpdateFailed'));
}
}
async function removeMember(member: WorkspaceMembership) {
if (!workspaceInfo) return;
if (!window.confirm(t('workspace.removeMemberConfirm'))) return;
try {
await backendClient.removeWorkspaceMember(
workspaceInfo.workspace.uuid,
member.account_uuid,
);
await loadWorkspace();
toast.success(t('workspace.memberRemoved'));
} catch {
toast.error(t('workspace.memberRemoveFailed'));
}
}
async function revokeInvitation(invitation: WorkspaceInvitation) {
if (!workspaceInfo) return;
try {
await backendClient.revokeWorkspaceInvitation(
workspaceInfo.workspace.uuid,
invitation.uuid,
);
await loadWorkspace();
toast.success(t('workspace.invitationRevoked'));
} catch {
toast.error(t('workspace.invitationRevokeFailed'));
}
}
if (loading && !workspaceInfo) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<>
<PanelToolbar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{workspaceInfo?.workspace.name ?? t('workspace.title')}
</p>
<p className="text-xs text-muted-foreground">
{t(
isCloudProjection
? 'workspace.cloudManagedDescription'
: 'workspace.ossSingletonDescription',
)}
</p>
</div>
{workspaceInfo && (
<Badge variant="secondary">
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
</Badge>
)}
{isCloudProjection && workspaceInfo && (
<Button asChild size="sm">
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
{t('workspace.upgradePlan')}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</PanelToolbar>
<PanelBody className="space-y-6">
{canInvite && (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">
{t('workspace.inviteMember')}
</h3>
<p className="text-xs text-muted-foreground">
{t('workspace.inviteDescription')}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
type="email"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
placeholder={t('workspace.emailPlaceholder')}
className="flex-1"
/>
<Select
value={inviteRole}
onValueChange={(value) =>
setInviteRole(value as Exclude<WorkspaceRole, 'owner'>)
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={createInvitation}
disabled={inviteLoading || !inviteEmail.trim()}
>
{inviteLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
{t('workspace.createInvitation')}
</Button>
</div>
{oneTimeInviteLink && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
<p className="mb-2 text-xs text-muted-foreground">
{t('workspace.oneTimeLinkWarning')}
</p>
<div className="flex gap-2">
<Input value={oneTimeInviteLink} readOnly />
<Button
variant="outline"
size="icon"
onClick={copyInvitationLink}
aria-label={t('workspace.copyInvitation')}
>
<Copy className="size-4" />
</Button>
</div>
</div>
)}
</section>
)}
{canViewMembers && (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">
{t('workspace.members')}
</h3>
</div>
<div className="space-y-2">
{members.map((member) => {
const isSelf =
member.account_uuid ===
workspaceInfo?.membership.account_uuid;
return (
<Item
key={member.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Users className="size-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>
{member.email}
{isSelf && (
<Badge variant="outline">{t('workspace.you')}</Badge>
)}
</ItemTitle>
<ItemDescription>
{t(`workspace.roles.${member.role}`)}
</ItemDescription>
</ItemContent>
<ItemActions>
{canUpdateMembers && member.role !== 'owner' && (
<Select
value={member.role}
onValueChange={(role) =>
void updateMemberRole(member, role as WorkspaceRole)
}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
{canTransferOwner && (
<SelectItem value="owner">
{t('workspace.transferOwnership')}
</SelectItem>
)}
</SelectContent>
</Select>
)}
{canRemoveMembers &&
!isSelf &&
member.role !== 'owner' && (
<Button
variant="ghost"
size="icon"
onClick={() => void removeMember(member)}
aria-label={t('workspace.removeMember')}
>
<Trash2 className="size-4" />
</Button>
)}
</ItemActions>
</Item>
);
})}
</div>
</section>
)}
{canInvite && invitations.length > 0 && (
<section className="space-y-3">
<h3 className="text-sm font-semibold">
{t('workspace.pendingInvitations')}
</h3>
<div className="space-y-2">
{invitations.map((invitation) => (
<Item
key={invitation.uuid}
size="sm"
variant="outline"
className="rounded-lg"
>
<ItemContent>
<ItemTitle>{invitation.normalized_email}</ItemTitle>
<ItemDescription>
{t(`workspace.roles.${invitation.role}`)} ·{' '}
{t('workspace.expiresAt', {
date: new Date(invitation.expires_at).toLocaleString(),
})}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
onClick={() => void revokeInvitation(invitation)}
aria-label={t('workspace.revokeInvitation')}
>
<Trash2 className="size-4" />
</Button>
</ItemActions>
</Item>
))}
</div>
</section>
)}
</PanelBody>
</>
);
}