mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 14:26:06 +00:00
fix(cloud): preserve Core-owned collaboration state
This commit is contained in:
@@ -234,6 +234,10 @@ class UserService:
|
|||||||
"""Create an invited Account and accept its Membership in one transaction."""
|
"""Create an invited Account and accept its Membership in one transaction."""
|
||||||
|
|
||||||
normalized_email = normalize_email(user_email)
|
normalized_email = normalize_email(user_email)
|
||||||
|
if self._uses_control_plane_directory():
|
||||||
|
raise ControlPlaneDirectoryRequiredError(
|
||||||
|
'Cloud invitation registration must use a Space account to preserve control-plane identity'
|
||||||
|
)
|
||||||
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
|
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
|
||||||
if invitation.normalized_email != normalized_email:
|
if invitation.normalized_email != normalized_email:
|
||||||
from ....workspace.collaboration import InvitationEmailMismatchError
|
from ....workspace.collaboration import InvitationEmailMismatchError
|
||||||
|
|||||||
@@ -650,6 +650,11 @@ class DirectoryProjectionService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if membership.projection_revision == 0:
|
||||||
|
# Revision zero is Core-owned collaboration state. Directory
|
||||||
|
# projection seeds memberships, but must not overwrite later
|
||||||
|
# invitation, role, or removal decisions made by Core.
|
||||||
|
continue
|
||||||
if membership.uuid != member.membership_uuid:
|
if membership.uuid != member.membership_uuid:
|
||||||
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
|
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
|
||||||
if membership.projection_revision > member.projection_revision:
|
if membership.projection_revision > member.projection_revision:
|
||||||
@@ -664,7 +669,7 @@ class DirectoryProjectionService:
|
|||||||
membership.projection_revision = member.projection_revision
|
membership.projection_revision = member.projection_revision
|
||||||
|
|
||||||
for account_uuid, membership in existing.items():
|
for account_uuid, membership in existing.items():
|
||||||
if account_uuid not in included_accounts:
|
if account_uuid not in included_accounts and membership.projection_revision != 0:
|
||||||
membership.status = MembershipStatus.REMOVED.value
|
membership.status = MembershipStatus.REMOVED.value
|
||||||
membership.projection_revision = max(
|
membership.projection_revision = max(
|
||||||
int(membership.projection_revision),
|
int(membership.projection_revision),
|
||||||
|
|||||||
@@ -639,6 +639,15 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
|||||||
|
|
||||||
persistence.execute_async.assert_not_awaited()
|
persistence.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_cloud_invitation_registration_requires_space_identity(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=True)),
|
||||||
|
)
|
||||||
|
service = UserService(ap)
|
||||||
|
|
||||||
|
with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
|
||||||
|
await service.register_invited_account('invite-token', 'member@example.com', 'password')
|
||||||
|
|
||||||
async def test_create_or_update_new_space_user_first_init(self):
|
async def test_create_or_update_new_space_user_first_init(self):
|
||||||
"""Creates new Space user on first initialization."""
|
"""Creates new Space user on first initialization."""
|
||||||
# Setup
|
# Setup
|
||||||
|
|||||||
@@ -815,3 +815,42 @@ async def test_snapshot_for_another_instance_is_rejected(projection_context):
|
|||||||
|
|
||||||
with pytest.raises(DirectoryProjectionUnavailableError, match='another LangBot instance'):
|
with pytest.raises(DirectoryProjectionUnavailableError, match='another LangBot instance'):
|
||||||
await service.initialize()
|
await service.initialize()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_core_owned_membership_survives_directory_updates_and_omission(projection_context):
|
||||||
|
application, session_factory = projection_context
|
||||||
|
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
||||||
|
membership.role = 'viewer'
|
||||||
|
membership.status = 'active'
|
||||||
|
membership.projection_revision = 0
|
||||||
|
session.add(
|
||||||
|
WorkspaceMembership(
|
||||||
|
uuid=SECOND_MEMBERSHIP_UUID,
|
||||||
|
workspace_uuid=WORKSPACE_UUID,
|
||||||
|
account_uuid='20000000-0000-0000-0000-000000000099',
|
||||||
|
role='viewer',
|
||||||
|
status='active',
|
||||||
|
joined_at=membership.joined_at,
|
||||||
|
projection_revision=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
|
||||||
|
projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
|
||||||
|
await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
memberships = {
|
||||||
|
membership.uuid: membership
|
||||||
|
for membership in (await session.scalars(sqlalchemy.select(WorkspaceMembership))).all()
|
||||||
|
}
|
||||||
|
assert memberships[MEMBERSHIP_UUID].role == 'viewer'
|
||||||
|
assert memberships[MEMBERSHIP_UUID].status == 'active'
|
||||||
|
assert memberships[MEMBERSHIP_UUID].projection_revision == 0
|
||||||
|
assert memberships[SECOND_MEMBERSHIP_UUID].status == 'active'
|
||||||
|
assert memberships[SECOND_MEMBERSHIP_UUID].projection_revision == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user