diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 8b63af7b1..b5392f195 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -929,6 +929,7 @@ class UserService: async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User: """Bind Space account to existing local account""" + self._require_local_directory() local_account = await self.get_user_by_email(user_email) if local_account is None: raise ValueError('User not found') @@ -973,19 +974,20 @@ class UserService: if not space_account_uuid or not space_email: raise ValueError('Invalid Space user info') - if normalize_email(space_email) != normalize_email(user_email): + if normalize_email(space_email) != normalize_email(user_email) and not await self._can_replace_local_email( + local_account + ): raise account_errors.AccountEmailMismatchError() # Check if this Space account is already bound to another user existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid) - if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email): + if existing_space_user and existing_space_user.uuid != local_account.uuid: raise ValueError('This LangBot Account is already bound to another user') # Update local account to Space account - normalized_email = normalize_email(user_email) await self._identity_execute( sqlalchemy.update(user.User) - .where(user.User.normalized_email == normalized_email) + .where(user.User.uuid == local_account.uuid) .values( user=normalize_email(space_email), # Update email to Space email normalized_email=normalize_email(space_email), @@ -996,13 +998,32 @@ class UserService: space_api_key=api_key, space_access_token_expires_at=expires_at, ), - f'email:{normalized_email}', + f'uuid:{local_account.uuid}', ) # Update Space model provider API keys await self._update_space_provider_for_account(local_account, api_key) - return await self.get_user_by_email(space_email) + return await self.get_user_by_uuid(local_account.uuid) + + async def _can_replace_local_email(self, account: user.User) -> bool: + """Allow only the sole, unbound OSS local Account to adopt its Space email.""" + workspace_service = getattr(self.ap, 'workspace_service', None) + if ( + workspace_service is None + or workspace_service.policy.multi_workspace_enabled + or account.source != user.AccountSource.LOCAL.value + or account.account_type != 'local' + or account.space_account_uuid is not None + ): + return False + # Invited members must retain the email check. The explicit bind flow + # already proves ownership of both accounts in a single-account instance. + other_account = await self._identity_scalar( + sqlalchemy.select(user.User).where(user.User.uuid != account.uuid).limit(1), + f'instance:{self._jwt_identity()[1]}', + ) + return other_account is None def _prune_passkey_challenges(self, now: float) -> None: while self._passkey_challenge_expiry_heap: diff --git a/tests/unit_tests/api/service/test_space_binding.py b/tests/unit_tests/api/service/test_space_binding.py new file mode 100644 index 000000000..7f4da837e --- /dev/null +++ b/tests/unit_tests/api/service/test_space_binding.py @@ -0,0 +1,141 @@ +"""Database-backed regressions for explicit OSS Space account binding.""" + +import datetime +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService +from langbot.pkg.entity.errors.account import AccountEmailMismatchError +from langbot.pkg.entity.persistence.user import User + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def binding_context(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "binding.db"}') + async with engine.begin() as connection: + await connection.run_sync(User.__table__.create) + sessions = async_sessionmaker(engine, expire_on_commit=False) + + @asynccontextmanager + async def identity_uow(digest): + async with sessions() as session, session.begin(): + yield SimpleNamespace(session=session) + + async with sessions() as session, session.begin(): + session.add( + User(uuid='local-owner', user='local@example.com', normalized_email='local@example.com', password='hash') + ) + + application = SimpleNamespace( + persistence_mgr=SimpleNamespace(identity_discovery_uow=identity_uow), + workspace_service=SimpleNamespace( + instance_uuid='instance', + policy=SimpleNamespace(multi_workspace_enabled=False), + get_execution_binding=AsyncMock( + return_value=SimpleNamespace( + workspace_uuid='workspace', workspace_created_at=datetime.datetime(2026, 1, 1) + ) + ), + ), + space_service=SimpleNamespace( + exchange_oauth_code=AsyncMock(return_value={'access_token': 'access', 'refresh_token': 'refresh'}), + get_user_info_raw=AsyncMock( + return_value={'account': {'uuid': 'space-owner', 'email': ' SPACE@Example.com '}, 'api_key': 'key'} + ), + ), + instance_config=SimpleNamespace( + data={'system': {'jwt': {'secret': 'test-secret-long-enough-for-jwt-32', 'expire': 3600}}} + ), + ) + service = UserService(application) + service._update_space_provider_for_account = AsyncMock() + yield service, sessions + await engine.dispose() + + +async def test_sole_local_account_adopts_space_email_and_preserves_identity(binding_context): + service, _ = binding_context + old_token = await service.generate_jwt_token(await service.get_user_by_uuid('local-owner')) + account = await service.bind_space_account('local@example.com', 'code') + assert account.uuid == 'local-owner' + assert account.user == account.normalized_email == 'space@example.com' + assert account.password == 'hash' + assert account.account_type == 'space' + assert account.space_account_uuid == 'space-owner' + assert account.space_access_token == 'access' + assert account.space_refresh_token == 'refresh' + assert account.space_api_key == 'key' + assert await service.get_user_by_email('local@example.com') is None + assert (await service.get_authenticated_account(old_token)).uuid == account.uuid + new_token = await service.generate_jwt_token(account) + assert (await service.get_authenticated_account(new_token)).user == 'space@example.com' + service._update_space_provider_for_account.assert_awaited_once() + + +@pytest.mark.parametrize('status', ['active', 'disabled', 'deleted']) +async def test_multiple_accounts_keep_email_check(binding_context, status): + service, sessions = binding_context + async with sessions() as session, session.begin(): + session.add( + User( + uuid='other', user='other@example.com', normalized_email='other@example.com', password='', status=status + ) + ) + with pytest.raises(AccountEmailMismatchError): + await service.bind_space_account('local@example.com', 'code') + account = await service.get_user_by_uuid('local-owner') + assert account.user == 'local@example.com' + assert account.space_account_uuid is None + + +async def test_same_email_binding_still_works_with_multiple_accounts(binding_context): + service, sessions = binding_context + async with sessions() as session, session.begin(): + session.add(User(uuid='other', user='other@example.com', normalized_email='other@example.com', password='')) + service.ap.space_service.get_user_info_raw.return_value['account']['email'] = 'LOCAL@example.com' + account = await service.bind_space_account('local@example.com', 'code') + assert account.space_account_uuid == 'space-owner' + + +async def test_cloud_binding_is_rejected_before_oauth(binding_context): + service, _ = binding_context + service.ap.workspace_service.policy.multi_workspace_enabled = True + with pytest.raises(ControlPlaneDirectoryRequiredError): + await service.bind_space_account('local@example.com', 'code') + service.ap.space_service.exchange_oauth_code.assert_not_awaited() + + +@pytest.mark.parametrize( + 'field,value', [('source', 'cloud_projection'), ('account_type', 'space'), ('space_account_uuid', 'already-bound')] +) +async def test_only_unbound_local_identity_can_replace_email(binding_context, field, value): + service, sessions = binding_context + async with sessions() as session, session.begin(): + account = await session.get(User, 1) + setattr(account, field, value) + with pytest.raises(AccountEmailMismatchError): + await service.bind_space_account('local@example.com', 'code') + + +async def test_space_subject_bound_to_another_account_is_rejected(binding_context): + service, sessions = binding_context + async with sessions() as session, session.begin(): + session.add( + User( + uuid='other', + user='other@example.com', + normalized_email='other@example.com', + password='', + space_account_uuid='space-owner', + ) + ) + service.ap.space_service.get_user_info_raw.return_value['account']['email'] = 'local@example.com' + with pytest.raises(ValueError, match='already bound'): + await service.bind_space_account('local@example.com', 'code') + assert (await service.get_user_by_uuid('local-owner')).space_account_uuid is None