mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(workspace): add in-product collaboration and direct Cloud launch
This commit is contained in:
@@ -9,6 +9,7 @@ from .....entity.persistence.metadata import WorkspaceMetadata
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
||||
from .....workspace.invitation_delivery import InvitationDeliveryService
|
||||
|
||||
|
||||
@group.group_class('system', '/api/v1/system')
|
||||
@@ -75,6 +76,10 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
else:
|
||||
outbound_ips = []
|
||||
|
||||
invitation_delivery_service = getattr(self.ap, 'invitation_delivery_service', None)
|
||||
if invitation_delivery_service is None:
|
||||
invitation_delivery_service = InvitationDeliveryService(self.ap)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'version': constants.semantic_version,
|
||||
@@ -97,6 +102,7 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'mcp_stdio_enabled': stdio_mcp_enabled(self.ap),
|
||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||
'outbound_ips': outbound_ips,
|
||||
'invitation_delivery': invitation_delivery_service.capability(),
|
||||
'wizard_status': wizard_status,
|
||||
'wizard_progress': wizard_progress,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from .. import group
|
||||
from .....entity.errors import account as account_errors
|
||||
from ...context import RequestContext
|
||||
from .....cloud.launch import SpaceLaunchError
|
||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
||||
|
||||
|
||||
@@ -153,7 +155,20 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
|
||||
state = await self.ap.user_service.issue_space_oauth_state('login')
|
||||
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
|
||||
if launch_workspace_uuid:
|
||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||
return self.fail(1, 'Space launch requires Cloud mode')
|
||||
try:
|
||||
uuid.UUID(launch_workspace_uuid)
|
||||
except ValueError:
|
||||
return self.fail(1, 'Invalid launch Workspace')
|
||||
state = await self.ap.user_service.issue_space_oauth_state(
|
||||
'login',
|
||||
launch_workspace_uuid=launch_workspace_uuid,
|
||||
)
|
||||
else:
|
||||
state = await self.ap.user_service.issue_space_oauth_state('login')
|
||||
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
|
||||
return self.success(data={'authorize_url': authorize_url})
|
||||
except ValueError as e:
|
||||
@@ -184,6 +199,14 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
launch_assertion = json_data.get('launch_assertion')
|
||||
workspace_uuid = json_data.get('workspace_uuid')
|
||||
|
||||
if launch_assertion:
|
||||
return await self._handle_space_direct_launch(
|
||||
str(launch_assertion),
|
||||
str(workspace_uuid or '') or None,
|
||||
)
|
||||
|
||||
if not code:
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
@@ -191,7 +214,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.fail(1, 'Missing state parameter')
|
||||
|
||||
try:
|
||||
await self.ap.user_service.consume_space_oauth_state(state, 'login')
|
||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||
# Exchange code for tokens
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
access_token = token_data.get('access_token')
|
||||
@@ -206,6 +229,24 @@ class UserRouterGroup(group.RouterGroup):
|
||||
access_token, refresh_token, expires_in
|
||||
)
|
||||
|
||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||
if launch_workspace_uuid:
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
user_obj.uuid,
|
||||
launch_workspace_uuid,
|
||||
)
|
||||
except Exception:
|
||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||
return self.fail(1, 'Space OAuth failed')
|
||||
return self.success(
|
||||
data={
|
||||
'token': jwt_token,
|
||||
'user': user_obj.user,
|
||||
'workspace_uuid': access.workspace.uuid,
|
||||
}
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'token': jwt_token,
|
||||
@@ -331,3 +372,36 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Space account binding failed')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
async def _handle_space_direct_launch(
|
||||
self,
|
||||
launch_assertion: str,
|
||||
workspace_uuid: str | None,
|
||||
) -> str:
|
||||
try:
|
||||
launch = await self.ap.space_launch_service.consume_assertion(
|
||||
launch_assertion,
|
||||
expected_workspace_uuid=workspace_uuid,
|
||||
)
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
if account is None:
|
||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||
self.ap.user_service._require_active_account(account)
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
token = await self.ap.user_service.generate_jwt_token(account)
|
||||
return self.success(
|
||||
data={
|
||||
'token': token,
|
||||
'user': account.user,
|
||||
'workspace_uuid': access.workspace.uuid,
|
||||
}
|
||||
)
|
||||
except SpaceLaunchError:
|
||||
self.ap.logger.warning('Rejected Space direct-launch assertion')
|
||||
return self.fail(1, 'Space launch failed')
|
||||
except Exception:
|
||||
self.ap.logger.exception('Space direct launch failed')
|
||||
return self.fail(1, 'Space launch failed')
|
||||
|
||||
@@ -11,6 +11,7 @@ from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, Wo
|
||||
from .....entity.persistence.workspace import WorkspaceSource
|
||||
from .....workspace.collaboration import WorkspaceMemberView
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from .....workspace.invitation_delivery import InvitationDeliveryService
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -153,8 +154,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
if await self._requires_control_plane(workspace_uuid):
|
||||
return self._control_plane_required()
|
||||
if quart.request.method == 'GET':
|
||||
invitations = await self.ap.workspace_collaboration_service.list_invitations(
|
||||
workspace_uuid,
|
||||
@@ -169,10 +168,20 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
str(data.get('email', '')),
|
||||
str(data.get('role', 'viewer')),
|
||||
)
|
||||
delivery_service = self._invitation_delivery_service()
|
||||
link = delivery_service.build_invitation_link(created.token)
|
||||
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
|
||||
delivery = await delivery_service.deliver_invitation(
|
||||
recipient_email=created.invitation.normalized_email,
|
||||
workspace_name=workspace.name,
|
||||
invitation_link=link,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'invitation': _invitation_payload(created.invitation),
|
||||
'token': created.token,
|
||||
'link': link,
|
||||
'delivery': delivery.to_public_dict(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -187,8 +196,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
request_context: RequestContext,
|
||||
) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
if await self._requires_control_plane(workspace_uuid):
|
||||
return self._control_plane_required()
|
||||
invitation = await self.ap.workspace_collaboration_service.revoke_invitation(
|
||||
workspace_uuid,
|
||||
invitation_uuid,
|
||||
@@ -207,8 +214,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
request_context: RequestContext,
|
||||
) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
if await self._requires_control_plane(workspace_uuid):
|
||||
return self._control_plane_required()
|
||||
if quart.request.method == 'DELETE':
|
||||
if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
|
||||
return self.http_status(403, 'permission_denied', 'Member removal permission is required')
|
||||
@@ -241,16 +246,12 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
if workspace_uuid != request_context.workspace_uuid:
|
||||
raise WorkspaceNotFoundError('Workspace not found')
|
||||
|
||||
async def _requires_control_plane(self, workspace_uuid: str) -> bool:
|
||||
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
|
||||
return workspace.source == WorkspaceSource.CLOUD_PROJECTION.value
|
||||
|
||||
def _control_plane_required(self) -> typing.Any:
|
||||
return self.http_status(
|
||||
409,
|
||||
'control_plane_required',
|
||||
'Cloud Workspace membership and invitations are managed by the SaaS control plane',
|
||||
)
|
||||
def _invitation_delivery_service(self) -> InvitationDeliveryService:
|
||||
service = getattr(self.ap, 'invitation_delivery_service', None)
|
||||
if service is None:
|
||||
service = InvitationDeliveryService(self.ap)
|
||||
self.ap.invitation_delivery_service = service
|
||||
return service
|
||||
|
||||
@staticmethod
|
||||
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
|
||||
|
||||
Reference in New Issue
Block a user