feat(workspace): add in-product collaboration and direct Cloud launch

This commit is contained in:
dadachann
2026-07-26 10:40:36 +08:00
parent 276791e7af
commit 8a445bfb22
29 changed files with 1258 additions and 124 deletions
+81 -1
View File
@@ -46,10 +46,17 @@ async def space_oauth_api():
local_account if (state, purpose) == ('opaque-bind-state', 'bind') else None
)
)
application.user_service.consume_space_oauth_state_details = AsyncMock(
return_value=SimpleNamespace(launch_workspace_uuid=None)
)
application.user_service.bind_space_account = AsyncMock(return_value=bound_account)
application.user_service.generate_jwt_token = AsyncMock(return_value='rotated-account-token')
application.user_service.get_user_by_uuid = AsyncMock(return_value=bound_account)
application.user_service.authenticate_space_user = AsyncMock(return_value=('space-login-token', bound_account))
application.user_service.verify_jwt_token = AsyncMock()
application.space_launch_service.consume_assertion = AsyncMock(
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
@@ -87,6 +94,29 @@ async def test_public_login_state_is_server_issued(space_oauth_api):
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
@pytest.mark.asyncio
async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oauth_api):
application, client = space_oauth_api
application.deployment.multi_workspace_enabled = True
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={
'redirect_uri': 'http://localhost/auth/space/callback',
'launch_workspace_uuid': WORKSPACE_UUID,
},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
authorize_url = (await response.get_json())['data']['authorize_url']
assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state']
application.user_service.issue_space_oauth_state.assert_awaited_once_with(
'login',
launch_workspace_uuid=WORKSPACE_UUID,
)
@pytest.mark.asyncio
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
application, client = space_oauth_api
@@ -203,10 +233,33 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
assert (await missing.get_json())['code'] == 1
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'space-login-token'
application.user_service.consume_space_oauth_state.assert_awaited_once_with('opaque-login-state', 'login')
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
@pytest.mark.asyncio
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state_details.reset_mock()
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
data = (await response.get_json())['data']
assert data['token'] == 'space-login-token'
assert data['workspace_uuid'] == WORKSPACE_UUID
application.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with(
'account-a',
WORKSPACE_UUID,
)
@pytest.mark.asyncio
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
application, client = space_oauth_api
@@ -234,3 +287,30 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
application.user_service.verify_jwt_token.assert_not_awaited()
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
@pytest.mark.asyncio
async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock()
application.space_service.exchange_oauth_code.reset_mock()
response = await client.post(
'/api/v1/user/space/callback',
json={
'state': 'space-generated-state-is-not-oauth-state',
'workspace_uuid': WORKSPACE_UUID,
'launch_assertion': 'signed-launch-token',
},
)
assert response.status_code == 200
data = (await response.get_json())['data']
assert data['token'] == 'rotated-account-token'
assert data['workspace_uuid'] == WORKSPACE_UUID
application.space_launch_service.consume_assertion.assert_awaited_once_with(
'signed-launch-token',
expected_workspace_uuid=WORKSPACE_UUID,
)
application.user_service.consume_space_oauth_state.assert_not_awaited()
application.space_service.exchange_oauth_code.assert_not_awaited()
+40 -4
View File
@@ -53,7 +53,7 @@ async def workspace_api(tmp_path):
'jwt': {'secret': 'workspace-api-secret', 'expire': 3600},
'allow_modify_login_info': True,
},
'api': {'global_api_key': ''},
'api': {'global_api_key': '', 'webui_url': 'https://langbot.example'},
}
)
application.logger = logging.getLogger('workspace-api-test')
@@ -198,6 +198,8 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
invite_data = (await invite_response.get_json())['data']
invitation_token = invite_data['token']
assert invitation_token.startswith('lbi_')
assert invite_data['link'] == f'https://langbot.example/invitations/accept#token={invitation_token}'
assert invite_data['delivery'] == {'status': 'link_only', 'provider': None}
assert 'token_hash' not in invite_data['invitation']
async with engine.connect() as connection:
@@ -376,7 +378,7 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac
assert (await forbidden.get_json())['code'] == 'permission_denied'
async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_control_plane(
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
workspace_api,
):
application, client, engine, owner_token = workspace_api
@@ -514,8 +516,42 @@ async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_
headers=_auth(owner_token, cloud_workspace_uuid),
json={'email': 'member@example.com', 'role': 'viewer'},
)
assert create_invitation.status_code == 409
assert (await create_invitation.get_json())['code'] == 'control_plane_required'
assert create_invitation.status_code == 200
created_invitation = (await create_invitation.get_json())['data']
assert created_invitation['invitation']['workspace_uuid'] == cloud_workspace_uuid
assert created_invitation['link'].startswith('https://langbot.example/invitations/accept#token=lbi_')
assert created_invitation['delivery'] == {'status': 'link_only', 'provider': None}
accept_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': created_invitation['token'],
'registration': {'email': 'member@example.com', 'password': 'member-password'},
},
)
assert accept_response.status_code == 200
member_token = (await accept_response.get_json())['data']['token']
member_current = await client.get(
'/api/v1/workspaces/current',
headers=_auth(member_token, cloud_workspace_uuid),
)
assert member_current.status_code == 200
member_account_uuid = (await member_current.get_json())['data']['membership']['account_uuid']
update_member = await client.patch(
f'/api/v1/workspaces/{cloud_workspace_uuid}/members/{member_account_uuid}',
headers=_auth(owner_token, cloud_workspace_uuid),
json={'role': 'developer'},
)
assert update_member.status_code == 200
assert (await update_member.get_json())['data']['member']['role'] == 'developer'
remove_member = await client.delete(
f'/api/v1/workspaces/{cloud_workspace_uuid}/members/{member_account_uuid}',
headers=_auth(owner_token, cloud_workspace_uuid),
)
assert remove_member.status_code == 200
async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api):
@@ -973,7 +973,7 @@ class TestPostgreSQLTenantRuntime:
.values(write_fenced=True)
)
assert workspace_update.rowcount == 0
assert membership_update.rowcount == 0
assert membership_update.rowcount == 1
assert execution_update.rowcount == 0
async with cloud_manager.tenant_uow(workspace_local) as tenant:
@@ -64,12 +64,29 @@ class TestSpaceOAuthState:
service = UserService(SimpleNamespace())
state = await service.issue_space_oauth_state('login')
digest = service._space_oauth_state_digest(state)
purpose, account_uuid, _ = service._space_oauth_states[digest]
service._space_oauth_states[digest] = (purpose, account_uuid, 0)
purpose, account_uuid, _, launch_workspace_uuid = service._space_oauth_states[digest]
service._space_oauth_states[digest] = (purpose, account_uuid, 0, launch_workspace_uuid)
with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
await service.consume_space_oauth_state(state, 'login')
async def test_login_state_can_carry_launch_workspace_without_changing_normal_return(self):
service = UserService(SimpleNamespace())
state = await service.issue_space_oauth_state(
'login',
launch_workspace_uuid='workspace-a',
)
assert await service.consume_space_oauth_state(state, 'login') is None
state = await service.issue_space_oauth_state(
'login',
launch_workspace_uuid='workspace-a',
)
consumed = await service.consume_space_oauth_state_details(state, 'login')
assert consumed.account is None
assert consumed.launch_workspace_uuid == 'workspace-a'
def _create_mock_user(
email: str = 'test@example.com',
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import base64
import json
import time
import uuid
from types import SimpleNamespace
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
pytestmark = pytest.mark.asyncio
INSTANCE_UUID = 'instance-test'
ACCOUNT_UUID = '11111111-1111-4111-8111-111111111111'
WORKSPACE_UUID = '22222222-2222-4222-8222-222222222222'
KEY_ID = 'space-key-1'
def _base64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _sign(private_key: Ed25519PrivateKey, claims: dict, *, key_id: str = KEY_ID) -> str:
header = {'alg': 'EdDSA', 'kid': key_id, 'typ': 'langbot-control-plane+jwt'}
encoded_header = _base64url(json.dumps(header, separators=(',', ':')).encode('utf-8'))
encoded_claims = _base64url(json.dumps(claims, separators=(',', ':')).encode('utf-8'))
signing_input = f'{encoded_header}.{encoded_claims}'
return f'{signing_input}.{_base64url(private_key.sign(signing_input.encode("ascii")))}'
def _claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE_UUID) -> dict:
return {
'iss': 'langbot-space',
'aud': 'langbot-cloud-runtime',
'sub': f'langbot-instance:{INSTANCE_UUID}',
'jti': jti or str(uuid.uuid4()),
'iat': now,
'nbf': now - 5,
'exp': now + 90,
'instance_uuid': INSTANCE_UUID,
'kind': 'workspace.launch',
'payload': {
'account_uuid': ACCOUNT_UUID,
'workspace_uuid': workspace_uuid,
},
}
def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
app = SimpleNamespace(
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
instance_config=SimpleNamespace(
data={
'space': {
'launch': {
'control_plane_public_key': _base64url(public_key),
}
}
}
),
)
return SpaceLaunchService(app, wall_time=lambda: now)
async def test_consumes_valid_workspace_launch_assertion_once():
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
service = _service(private_key, now=now)
token = _sign(private_key, _claims(now=now))
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
assert launch == {'account_uuid': ACCOUNT_UUID, 'workspace_uuid': WORKSPACE_UUID}
with pytest.raises(SpaceLaunchError, match='already been consumed'):
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
async def test_rejects_expired_wrong_workspace_and_wrong_instance_assertions():
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
service = _service(private_key, now=now)
expired = _claims(now=now)
expired['exp'] = now - 60
with pytest.raises(SpaceLaunchError, match='expired'):
await service.consume_assertion(_sign(private_key, expired), expected_workspace_uuid=WORKSPACE_UUID)
wrong_workspace = _sign(private_key, _claims(now=now, workspace_uuid='33333333-3333-4333-8333-333333333333'))
with pytest.raises(SpaceLaunchError, match='another Workspace'):
await service.consume_assertion(wrong_workspace, expected_workspace_uuid=WORKSPACE_UUID)
wrong_instance = _claims(now=now)
wrong_instance['instance_uuid'] = 'other-instance'
with pytest.raises(SpaceLaunchError, match='instance UUID'):
await service.consume_assertion(_sign(private_key, wrong_instance), expected_workspace_uuid=WORKSPACE_UUID)
async def test_rejects_invalid_signature_and_non_cloud_mode():
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
token = _sign(private_key, _claims(now=now))
service = _service(Ed25519PrivateKey.generate(), now=now)
with pytest.raises(SpaceLaunchError, match='signature'):
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
oss_service = _service(private_key, now=now)
oss_service.ap.deployment.multi_workspace_enabled = False
with pytest.raises(SpaceLaunchError, match='verified Cloud mode'):
await oss_service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
@@ -0,0 +1,88 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.workspace.invitation_delivery import (
InvitationDeliveryResult,
InvitationDeliveryService,
)
pytestmark = pytest.mark.asyncio
def _app(config: dict) -> SimpleNamespace:
return SimpleNamespace(
instance_config=SimpleNamespace(data=config),
logger=SimpleNamespace(warning=lambda *args, **kwargs: None),
)
async def test_link_only_delivery_builds_public_web_link_without_secret_capability():
service = InvitationDeliveryService(
_app(
{
'api': {
'webui_url': 'https://public.langbot.example/',
'webhook_prefix': 'http://internal:5300',
}
}
)
)
assert service.build_invitation_link('lbi_secret') == (
'https://public.langbot.example/invitations/accept#token=lbi_secret'
)
assert service.capability() == {'enabled': False, 'provider': None}
result = await service.deliver_invitation(
recipient_email='member@example.com',
workspace_name='Workspace',
invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret',
)
assert result == InvitationDeliveryResult(status='link_only', provider=None)
async def test_configured_provider_failure_returns_failed_without_raising():
service = InvitationDeliveryService(
_app(
{
'workspace': {
'invitations': {
'email': {
'provider': 'resend',
'from': 'LangBot <noreply@example.com>',
'resend': {'api_key': 'resend-secret'},
}
}
}
}
)
)
service._send_resend = AsyncMock(return_value=False)
assert service.capability() == {'enabled': True, 'provider': 'resend'}
result = await service.deliver_invitation(
recipient_email='member@example.com',
workspace_name='Workspace',
invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret',
)
assert result == InvitationDeliveryResult(status='failed', provider='resend')
service._send_resend.assert_awaited_once()
async def test_environment_mapping_enables_provider_without_leaking_secret(monkeypatch):
monkeypatch.setenv('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', 'https://env.langbot.example/')
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', 'smtp')
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__FROM', 'LangBot <noreply@example.com>')
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', 'smtp.example.com')
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', 'smtp-secret')
service = InvitationDeliveryService(_app({'api': {'webui_url': 'https://config.example'}}))
assert service.build_invitation_link('lbi_secret') == (
'https://env.langbot.example/invitations/accept#token=lbi_secret'
)
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
@@ -240,7 +240,7 @@ async def test_cloud_member_listing_opens_workspace_uow_when_request_scope_has_c
]
async def test_cloud_directory_requires_explicit_selector_and_rejects_local_mutation(
async def test_cloud_directory_requires_explicit_selector_and_allows_core_membership_management(
collaboration_context,
):
_service, workspace_service, session_factory, owner, _workspace, _membership = collaboration_context
@@ -292,13 +292,34 @@ async def test_cloud_directory_requires_explicit_selector_and_rejects_local_muta
assert access.workspace.uuid == cloud_workspace_uuid
assert access.execution.placement_generation == 8
with pytest.raises(MembershipPermissionError, match='control plane'):
await cloud_service.create_invitation(
cloud_workspace_uuid,
cloud_membership,
'member@example.com',
'viewer',
)
created = await cloud_service.create_invitation(
cloud_workspace_uuid,
cloud_membership,
'member@example.com',
'viewer',
)
assert created.invitation.workspace_uuid == cloud_workspace_uuid
assert created.token.startswith('lbi_')
member = await _add_account(session_factory, 'member@example.com')
membership = await cloud_service.accept_invitation(created.token, member.uuid)
assert membership.workspace_uuid == cloud_workspace_uuid
assert membership.role == 'viewer'
updated = await cloud_service.update_member_role(
cloud_workspace_uuid,
member.uuid,
'developer',
cloud_membership,
)
assert updated.role == 'developer'
removed = await cloud_service.remove_member(
cloud_workspace_uuid,
member.uuid,
cloud_membership,
)
assert removed.status == 'removed'
async def test_invitation_lock_registry_does_not_retain_sequential_tokens(collaboration_context):