From 0ccbcd5f5fc028b4b57ce4fd202c1bc153890c1c Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 2 Aug 2026 00:56:20 +0800 Subject: [PATCH 1/5] fix(migrations): preserve published Cloud revision head (#2375) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../versions/0016_space_launch_replay.py | 57 +++++++++++++++++++ .../versions/0018_merge_launch_replay.py | 21 +++++++ .../persistence/test_migrations.py | 12 ++++ 3 files changed, 90 insertions(+) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py diff --git a/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py new file mode 100644 index 000000000..33f44c19c --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py @@ -0,0 +1,57 @@ +"""add durable replay protection for signed Space launch assertions + +Revision ID: 0016_space_launch_replay +Revises: 0015_cloud_core_collab +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0016_space_launch_replay' +down_revision = '0015_cloud_core_collab' +branch_labels = None +depends_on = None + +_TABLE = 'space_launch_assertion_consumptions' +_POLICY = 'langbot_directory_projection' +_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')" + + +def upgrade() -> None: + conn = op.get_bind() + if _TABLE not in set(sa.inspect(conn).get_table_names()): + op.create_table( + _TABLE, + sa.Column('instance_uuid', sa.String(255), nullable=False), + sa.Column('jti', sa.String(255), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('instance_uuid', 'jti'), + ) + op.create_index( + 'ix_space_launch_assertion_consumptions_expiry', + _TABLE, + ['instance_uuid', 'expires_at'], + unique=False, + ) + if conn.dialect.name == 'postgresql': + table = conn.dialect.identifier_preparer.quote(_TABLE) + policy = conn.dialect.identifier_preparer.quote(_POLICY) + expression = f'instance_uuid::text = {_SETTING}' + op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.execute( + sa.text( + f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + ) + + +def downgrade() -> None: + if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()): + op.drop_table(_TABLE) diff --git a/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py new file mode 100644 index 000000000..68f4e1007 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py @@ -0,0 +1,21 @@ +"""merge the published Space launch replay and main migration branches + +Revision ID: 0018_merge_launch_replay +Revises: 0016_space_launch_replay, 0017_oss_workspace_identity +Create Date: 2026-08-01 +""" + +from __future__ import annotations + +revision = '0018_merge_launch_replay' +down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index c1a455fb1..3447c9320 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -95,6 +95,18 @@ class TestSQLiteMigrationBaseline: class TestSQLiteMigrationUpgrade: """Tests for upgrade to head workflow.""" + @pytest.mark.asyncio + async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine): + """A database released at the production-only 0016 head must remain upgradable.""" + async with sqlite_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay') + await run_alembic_upgrade(sqlite_engine, 'head') + + assert await get_alembic_current(sqlite_engine) == _get_script_head() + assert _get_script_head() == '0018_merge_launch_replay' + @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): """ From a7a7218afec40ee85e4d7708a3ef32137c0eafa3 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:08:49 +0000 Subject: [PATCH 2/5] fix(cloud): accept invitations with current account --- .../pkg/api/http/controller/groups/user.py | 4 +- tests/integration/api/test_smoke.py | 24 ++++++ web/src/app/infra/http/BackendClient.ts | 1 + web/src/app/invitations/accept/page.tsx | 20 ++++- web/tests/e2e/invitations.spec.ts | 77 +++++++++++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 560f5988c..49b59906b 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -311,8 +311,10 @@ class UserRouterGroup(group.RouterGroup): return self.success(data={'initialized': False}) capabilities = await self.ap.user_service.get_login_capabilities() - if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': + cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + if cloud_mode: capabilities['password_login_enabled'] = False + capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 9c2927503..dfd5054f2 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -9,6 +9,8 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q from __future__ import annotations +from types import SimpleNamespace + import pytest from unittest.mock import MagicMock, AsyncMock, Mock @@ -304,12 +306,34 @@ class TestUserInitEndpoint: data = await response.get_json() assert data['data'] == { 'initialized': True, + 'authenticated_invitation_acceptance_enabled': False, 'password_login_enabled': True, 'space_login_enabled': False, } fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with() fake_api_app.user_service.get_first_user.assert_not_awaited() + @pytest.mark.asyncio + async def test_account_info_enables_authenticated_invitation_acceptance_in_cloud( + self, quart_test_client, fake_api_app + ): + fake_api_app.deployment = SimpleNamespace(mode='cloud') + fake_api_app.user_service.is_initialized.return_value = True + fake_api_app.user_service.get_login_capabilities = AsyncMock( + return_value={'password_login_enabled': True, 'space_login_enabled': True} + ) + + response = await quart_test_client.get('/api/v1/user/account-info') + + assert response.status_code == 200 + data = await response.get_json() + assert data['data'] == { + 'initialized': True, + 'authenticated_invitation_acceptance_enabled': True, + 'password_login_enabled': False, + 'space_login_enabled': True, + } + @pytest.mark.asyncio async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch): fake_api_app.user_service.is_initialized.return_value = True diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 63d6f37c8..03e0899f6 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1179,6 +1179,7 @@ export class BackendClient extends BaseHttpClient { public getAccountInfo(): Promise<{ initialized: boolean; + authenticated_invitation_acceptance_enabled?: boolean; password_login_enabled?: boolean; space_login_enabled?: boolean; }> { diff --git a/web/src/app/invitations/accept/page.tsx b/web/src/app/invitations/accept/page.tsx index e8d412ecc..0d83331b5 100644 --- a/web/src/app/invitations/accept/page.tsx +++ b/web/src/app/invitations/accept/page.tsx @@ -93,6 +93,10 @@ export default function AcceptInvitationPage() { const [confirmPassword, setConfirmPassword] = useState(''); const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = useState(false); + const [ + authenticatedInvitationAcceptanceEnabled, + setAuthenticatedInvitationAcceptanceEnabled, + ] = useState(false); useEffect(() => { const handleHashChange = () => setInvitationHash(window.location.hash); @@ -113,6 +117,9 @@ export default function AcceptInvitationPage() { .getAccountInfo() .then((info) => { setPasswordRegistrationEnabled(info.password_login_enabled !== false); + setAuthenticatedInvitationAcceptanceEnabled( + info.authenticated_invitation_acceptance_enabled === true, + ); }) .catch(() => setPasswordRegistrationEnabled(false)); if (!invitationToken) { @@ -304,7 +311,18 @@ export default function AcceptInvitationPage() { )} - {hasLoginToken ? ( + {hasLoginToken && authenticatedInvitationAcceptanceEnabled ? ( + + ) : hasLoginToken ? (
{t('workspace.authenticatedInvitationNotice')} diff --git a/web/tests/e2e/invitations.spec.ts b/web/tests/e2e/invitations.spec.ts index 7cd1cc1cc..b036838e2 100644 --- a/web/tests/e2e/invitations.spec.ts +++ b/web/tests/e2e/invitations.spec.ts @@ -166,6 +166,83 @@ test('an authenticated OSS invitation requires logout before registration', asyn }); }); +test('an authenticated Cloud Account can accept its invitation directly', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { + token: 'invited-account-token', + userEmail: 'invited@example.com', + }, + }); + await page.route('**/api/v1/user/account-info', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + initialized: true, + authenticated_invitation_acceptance_enabled: true, + password_login_enabled: false, + space_login_enabled: true, + }, + msg: 'ok', + }), + }); + }); + await page.route('**/api/v1/invitations/inspect', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + invitation: { + uuid: 'cloud-invitation', + workspace_uuid: 'workspace-playwright', + normalized_email: 'invited@example.com', + role: 'viewer', + status: 'pending', + }, + workspace: { + uuid: 'workspace-playwright', + name: 'Playwright Workspace', + }, + }, + msg: 'ok', + }), + }); + }); + + let acceptanceAuthorization = ''; + await page.route('**/api/v1/invitations/accept', async (route) => { + acceptanceAuthorization = route.request().headers().authorization ?? ''; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + token: 'accepted-cloud-account-token', + workspace_uuid: 'workspace-playwright', + }, + msg: 'ok', + }), + }); + }); + + await page.goto('/invitations/accept#token=cloud-invitation'); + + await expect( + page.getByRole('button', { name: 'Accept Invitation' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Accept Invitation' }).click(); + await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/); + expect(acceptanceAuthorization).toBe('Bearer invited-account-token'); +}); + test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({ page, }) => { From 1e6e4c0ca728c564e3755faef896a7e20316398e Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 3 Aug 2026 02:14:43 +0800 Subject: [PATCH 3/5] fix(cloud): show owner model balance and enforce single owner (#2384) (#2385) * fix(cloud): show owner model balance and enforce single owner * fix(migrations): create owner index idempotently --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/api/http/authz.py | 2 - src/langbot/pkg/api/http/controller/group.py | 1 - .../pkg/api/http/controller/groups/user.py | 18 +++- src/langbot/pkg/cloud/model_catalog.py | 7 ++ .../pkg/entity/persistence/workspace.py | 7 ++ .../versions/0019_single_workspace_owner.py | 80 ++++++++++++++ src/langbot/pkg/workspace/collaboration.py | 28 +---- .../api/test_support_admin_launch.py | 1 - .../integration/api/test_user_space_oauth.py | 5 +- tests/integration/api/test_workspaces.py | 9 ++ .../persistence/test_migrations.py | 2 +- .../test_single_owner_migration.py | 100 ++++++++++++++++++ tests/unit_tests/api/http/test_authz.py | 3 +- tests/unit_tests/cloud/test_model_catalog.py | 6 ++ .../workspace/test_workspace_collaboration.py | 29 ++--- .../models-dialog/components/ProviderCard.tsx | 30 +++--- .../WorkspaceSettingsPanel.tsx | 6 -- web/tests/e2e/fixtures/langbot-api.ts | 1 - .../unit/oss-account-space-billing.test.mjs | 10 ++ 19 files changed, 274 insertions(+), 71 deletions(-) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py create mode 100644 tests/integration/persistence/test_single_owner_migration.py diff --git a/src/langbot/pkg/api/http/authz.py b/src/langbot/pkg/api/http/authz.py index 225e30aa8..7bfc8dbe3 100644 --- a/src/langbot/pkg/api/http/authz.py +++ b/src/langbot/pkg/api/http/authz.py @@ -19,7 +19,6 @@ class Permission(enum.StrEnum): WORKSPACE_VIEW = 'workspace.view' WORKSPACE_UPDATE = 'workspace.update' WORKSPACE_DELETE = 'workspace.delete' - OWNER_TRANSFER = 'owner.transfer' MEMBER_VIEW = 'member.view' MEMBER_INVITE = 'member.invite' MEMBER_UPDATE_ROLE = 'member.update_role' @@ -49,7 +48,6 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType( if permission not in { Permission.WORKSPACE_DELETE, - Permission.OWNER_TRANSFER, Permission.BILLING_LINK_MANAGE, } ), diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 0917c6c6d..6459ecec7 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -62,7 +62,6 @@ class AuthType(enum.Enum): _SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset( { - Permission.OWNER_TRANSFER.value, Permission.MEMBER_VIEW.value, Permission.MEMBER_INVITE.value, Permission.MEMBER_UPDATE_ROLE.value, diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 49b59906b..e8e450911 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -291,11 +291,19 @@ class UserRouterGroup(group.RouterGroup): # Workspace owner is already bound even when this Core has no local OAuth # token row (model billing uses the owner's control-plane API key). owner_space_bound = cloud_mode or owner_has_local_space_credentials - credits = ( - await self.ap.space_service.get_credits(owner.user) - if owner is not None and owner.space_account_uuid - else None - ) + if cloud_mode: + catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None) + credits = ( + catalog_service.get_workspace_credits(access.workspace.uuid) + if catalog_service is not None + else None + ) + else: + credits = ( + await self.ap.space_service.get_credits(owner.user) + if owner is not None and owner.space_account_uuid + else None + ) return self.success( data={ 'credits': credits, diff --git a/src/langbot/pkg/cloud/model_catalog.py b/src/langbot/pkg/cloud/model_catalog.py index a76376505..056159e0a 100644 --- a/src/langbot/pkg/cloud/model_catalog.py +++ b/src/langbot/pkg/cloud/model_catalog.py @@ -53,6 +53,7 @@ class CloudWorkspaceModelBilling(BaseModel): workspace_uuid: str = Field(min_length=36, max_length=36) owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36) api_key: SecretStr | None = None + credits: int | None = None @field_validator('workspace_uuid') @classmethod @@ -149,6 +150,11 @@ class CloudModelCatalogSyncService: # convergence marker so a failed runtime reload is retried even when the # following database reconciliation is a no-op. self._runtime_reload_pending = False + self._workspace_credits: dict[str, int | None] = {} + + def get_workspace_credits(self, workspace_uuid: str) -> int | None: + """Return the latest signed owner-credit projection for a Workspace.""" + return self._workspace_credits.get(str(uuid.UUID(workspace_uuid))) async def initialize(self) -> None: await self.sync_once(reload_runtime=False) @@ -198,6 +204,7 @@ class CloudModelCatalogSyncService: self._runtime_reload_pending = True for key in ('created', 'updated', 'deleted'): summary[key] += counts[key] + self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits except Exception as exc: sync_error = exc finally: diff --git a/src/langbot/pkg/entity/persistence/workspace.py b/src/langbot/pkg/entity/persistence/workspace.py index ca3743d4b..822c8e9d4 100644 --- a/src/langbot/pkg/entity/persistence/workspace.py +++ b/src/langbot/pkg/entity/persistence/workspace.py @@ -163,6 +163,13 @@ class WorkspaceMembership(Base): __table_args__ = ( sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'), sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'), + sqlalchemy.Index( + 'uq_workspace_memberships_one_active_owner', + 'workspace_uuid', + unique=True, + sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"), + postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"), + ), sqlalchemy.CheckConstraint( "role IN ('owner', 'admin', 'developer', 'operator', 'viewer')", name='ck_workspace_memberships_role', diff --git a/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py b/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py new file mode 100644 index 000000000..55e4e08f2 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py @@ -0,0 +1,80 @@ +"""enforce one active owner per Workspace + +Revision ID: 0019_single_workspace_owner +Revises: 0018_merge_launch_replay +Create Date: 2026-08-02 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0019_single_workspace_owner' +down_revision = '0018_merge_launch_replay' +branch_labels = None +depends_on = None + +_INDEX_NAME = 'uq_workspace_memberships_one_active_owner' + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'workspace_memberships' not in inspector.get_table_names(): + return + + # Ownership transfer used to promote a second member without demoting the + # original owner. Preserve the Workspace creator where possible and demote + # every historical extra owner before installing the database invariant. + op.execute( + sa.text( + """ + WITH ranked_owners AS ( + SELECT membership.uuid, + ROW_NUMBER() OVER ( + PARTITION BY membership.workspace_uuid + ORDER BY + CASE + WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0 + ELSE 1 + END, + COALESCE(membership.joined_at, membership.created_at), + membership.uuid + ) AS owner_rank + FROM workspace_memberships AS membership + JOIN workspaces AS workspace + ON workspace.uuid = membership.workspace_uuid + WHERE membership.role = 'owner' + AND membership.status = 'active' + ) + UPDATE workspace_memberships + SET role = 'admin' + WHERE uuid IN ( + SELECT uuid + FROM ranked_owners + WHERE owner_rank > 1 + ) + """ + ) + ) + # Fresh installations may already have this index because SQLAlchemy + # metadata is created before Alembic advances the revision marker. + op.execute( + sa.text( + 'CREATE UNIQUE INDEX IF NOT EXISTS ' + 'uq_workspace_memberships_one_active_owner ' + 'ON workspace_memberships (workspace_uuid) ' + "WHERE role = 'owner' AND status = 'active'" + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'workspace_memberships' not in inspector.get_table_names(): + return + index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')} + if _INDEX_NAME in index_names: + op.drop_index(_INDEX_NAME, table_name='workspace_memberships') diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py index bcebe47c4..3ffbb6999 100644 --- a/src/langbot/pkg/workspace/collaboration.py +++ b/src/langbot/pkg/workspace/collaboration.py @@ -606,6 +606,8 @@ class WorkspaceCollaborationService: ) -> WorkspaceMembership: if role not in {item.value for item in MembershipRole}: raise MembershipPermissionError('Unknown Workspace role') + if role == MembershipRole.OWNER.value: + raise MembershipPermissionError('Workspace ownership cannot be transferred') async def operation(active_session: AsyncSession) -> WorkspaceMembership: await self._require_active_workspace(active_session, workspace_uuid) @@ -617,8 +619,8 @@ class WorkspaceCollaborationService: target_account_uuid, ) self._require_can_manage_target(persisted_actor, target, new_role=role) - if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value: - await self._require_another_owner(active_session, workspace_uuid, target.account_uuid) + if target.role == MembershipRole.OWNER.value: + raise LastOwnerError('The Workspace owner cannot be removed or demoted') target.role = role await active_session.flush() return target @@ -644,7 +646,7 @@ class WorkspaceCollaborationService: ) self._require_can_manage_target(persisted_actor, target) if target.role == MembershipRole.OWNER.value: - await self._require_another_owner(active_session, workspace_uuid, target.account_uuid) + raise LastOwnerError('The Workspace owner cannot be removed or demoted') target.status = MembershipStatus.REMOVED.value await active_session.flush() return target @@ -751,26 +753,6 @@ class WorkspaceCollaborationService: raise WorkspaceNotFoundError('Workspace not found') return persisted_actor - async def _require_another_owner( - self, - session: AsyncSession, - workspace_uuid: str, - excluded_account_uuid: str, - ) -> None: - owners = ( - await session.scalars( - sqlalchemy.select(WorkspaceMembership) - .where( - WorkspaceMembership.workspace_uuid == workspace_uuid, - WorkspaceMembership.status == MembershipStatus.ACTIVE.value, - WorkspaceMembership.role == MembershipRole.OWNER.value, - ) - .with_for_update() - ) - ).all() - if not any(owner.account_uuid != excluded_account_uuid for owner in owners): - raise LastOwnerError('The last Workspace owner cannot be removed or demoted') - def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None: if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value: raise WorkspaceNotFoundError('Workspace not found') diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py index 94647a7f1..e71cb4374 100644 --- a/tests/integration/api/test_support_admin_launch.py +++ b/tests/integration/api/test_support_admin_launch.py @@ -333,7 +333,6 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s assert Permission.RESOURCE_MANAGE.value in permissions assert not permissions.intersection( { - Permission.OWNER_TRANSFER.value, Permission.MEMBER_VIEW.value, Permission.MEMBER_INVITE.value, Permission.MEMBER_UPDATE_ROLE.value, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 35e048053..16c86156d 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -289,6 +289,9 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau application.deployment.mode = 'cloud' application.user_service.get_workspace_owner = AsyncMock(return_value=None) application.space_service.get_credits = AsyncMock() + application.cloud_model_catalog_service = SimpleNamespace( + get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None + ) response = await client.get( '/api/v1/user/space-credits', @@ -298,7 +301,7 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau assert response.status_code == 200 assert payload['data'] == { - 'credits': None, + 'credits': 25000, 'owner_space_bound': True, 'is_workspace_owner': True, } diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 7547f4800..d3a70a56d 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -188,6 +188,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac workspace_uuid = current['workspace']['uuid'] assert current['membership']['role'] == 'owner' assert 'member.invite' in current['permissions'] + assert 'owner.transfer' not in current['permissions'] invite_response = await client.post( f'/api/v1/workspaces/{workspace_uuid}/invitations', @@ -263,6 +264,14 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac assert member_current['membership']['role'] == 'viewer' assert 'member.invite' not in member_current['permissions'] + transfer_response = await client.patch( + f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}', + headers=_auth(owner_token, workspace_uuid), + json={'role': 'owner'}, + ) + assert transfer_response.status_code == 403 + assert (await transfer_response.get_json())['code'] == 'permission_denied' + forbidden_invite = await client.post( f'/api/v1/workspaces/{workspace_uuid}/invitations', headers=_auth(member_token, workspace_uuid), diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 3447c9320..933d6fcd4 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -105,7 +105,7 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() - assert _get_script_head() == '0018_merge_launch_replay' + assert _get_script_head() == '0019_single_workspace_owner' @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): diff --git a/tests/integration/persistence/test_single_owner_migration.py b/tests/integration/persistence/test_single_owner_migration.py new file mode 100644 index 000000000..249759d72 --- /dev/null +++ b/tests/integration/persistence/test_single_owner_migration.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership +from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade + + +@pytest.mark.asyncio +async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}') + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner')) + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + workspace_uuid = '00000000-0000-4000-8000-000000000001' + creator_uuid = '00000000-0000-4000-8000-000000000010' + promoted_uuid = '00000000-0000-4000-8000-000000000020' + async with session_factory() as session: + session.add_all( + [ + User( + uuid=creator_uuid, + user='creator@example.test', + normalized_email='creator@example.test', + password='hash', + account_type='local', + ), + User( + uuid=promoted_uuid, + user='promoted@example.test', + normalized_email='promoted@example.test', + password='hash', + account_type='local', + ), + Workspace( + uuid=workspace_uuid, + instance_uuid='instance-test', + name='Workspace', + slug='workspace', + type='team', + status='active', + source='local', + created_by_account_uuid=creator_uuid, + ), + WorkspaceMembership( + uuid='00000000-0000-4000-8000-000000000100', + workspace_uuid=workspace_uuid, + account_uuid=creator_uuid, + role='owner', + status='active', + ), + WorkspaceMembership( + uuid='00000000-0000-4000-8000-000000000200', + workspace_uuid=workspace_uuid, + account_uuid=promoted_uuid, + role='owner', + status='active', + ), + ] + ) + await session.commit() + + await run_alembic_stamp(engine, '0018_merge_launch_replay') + await run_alembic_upgrade(engine, 'head') + + async with engine.connect() as connection: + roles = dict( + ( + await connection.execute( + sa.text( + 'SELECT account_uuid, role FROM workspace_memberships ' + 'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid' + ), + {'workspace_uuid': workspace_uuid}, + ) + ).all() + ) + assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'} + indexes = await connection.run_sync( + lambda sync_connection: { + index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships') + } + ) + assert 'uq_workspace_memberships_one_active_owner' in indexes + + with pytest.raises(sa.exc.IntegrityError): + async with engine.begin() as connection: + await connection.execute( + sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"), + {'account_uuid': promoted_uuid}, + ) + finally: + await engine.dispose() diff --git a/tests/unit_tests/api/http/test_authz.py b/tests/unit_tests/api/http/test_authz.py index 705891a16..620700bc5 100644 --- a/tests/unit_tests/api/http/test_authz.py +++ b/tests/unit_tests/api/http/test_authz.py @@ -27,10 +27,9 @@ def test_owner_has_every_fixed_permission(): assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission) -def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing(): +def test_admin_cannot_delete_workspace_or_link_billing(): ctx = _context(authz.WorkspaceRole.ADMIN) - assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER) assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE) assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE) assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE) diff --git a/tests/unit_tests/cloud/test_model_catalog.py b/tests/unit_tests/cloud/test_model_catalog.py index 6f4b0a275..5d1de001f 100644 --- a/tests/unit_tests/cloud/test_model_catalog.py +++ b/tests/unit_tests/cloud/test_model_catalog.py @@ -73,11 +73,13 @@ def _snapshot( 'workspace_uuid': WORKSPACE_A, 'owner_account_uuid': OWNER_A, 'api_key': key_a, + 'credits': 25000, }, { 'workspace_uuid': WORKSPACE_B, 'owner_account_uuid': OWNER_B, 'api_key': 'owner-b-key', + 'credits': 5000, }, ], } @@ -160,6 +162,8 @@ async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_ first = await service.sync_once() assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0} assert reload_counter.calls == 1 + assert service.get_workspace_credits(WORKSPACE_A) == 25000 + assert service.get_workspace_credits(WORKSPACE_B) == 5000 async with engine.connect() as connection: providers = ( @@ -306,6 +310,8 @@ async def test_partial_workspace_failure_reloads_already_committed_changes() -> with pytest.raises(RuntimeError, match='second Workspace failed'): await service.sync_once() + assert service.get_workspace_credits(WORKSPACE_A) == 25000 + assert service.get_workspace_credits(WORKSPACE_B) is None assert reload_counter.calls == 1 diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py index a9a79c42b..77974f4ff 100644 --- a/tests/unit_tests/workspace/test_workspace_collaboration.py +++ b/tests/unit_tests/workspace/test_workspace_collaboration.py @@ -203,20 +203,21 @@ async def test_last_owner_cannot_be_demoted(collaboration_context): second_membership, ) - promoted = await service.update_member_role( - workspace.uuid, - second.uuid, - 'owner', - owner_membership, - ) - assert promoted.role == 'owner' - demoted = await service.update_member_role( - workspace.uuid, - owner_membership.account_uuid, - 'admin', - owner_membership, - ) - assert demoted.role == 'admin' + with pytest.raises(MembershipPermissionError, match='cannot be transferred'): + await service.update_member_role( + workspace.uuid, + second.uuid, + 'owner', + owner_membership, + ) + + with pytest.raises(LastOwnerError): + await service.update_member_role( + workspace.uuid, + owner_membership.account_uuid, + 'admin', + owner_membership, + ) async def test_workspace_selector_requires_membership(collaboration_context): diff --git a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx index b771d77bc..b4dd71866 100644 --- a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx +++ b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx @@ -218,20 +218,22 @@ export default function ProviderCard({ {(spaceCredits / 5000).toFixed(2)} {t('models.credits')} - + {isWorkspaceOwner && ( + + )}
)} {isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && ( diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx index ebe184040..2469c5194 100644 --- a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx +++ b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx @@ -79,7 +79,6 @@ export default function WorkspaceSettingsPanel({ 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` : ''; @@ -342,11 +341,6 @@ export default function WorkspaceSettingsPanel({ {t(`workspace.roles.${role}`)} ))} - {canTransferOwner && ( - - {t('workspace.transferOwnership')} - - )} )} diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 23e2c0a8f..98ad9f25a 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -169,7 +169,6 @@ export function makeWorkspaceEntry( 'member.remove', 'member.update_role', 'member.view', - 'owner.transfer', 'provider_secret.manage', 'resource.manage', 'resource.view', diff --git a/web/tests/unit/oss-account-space-billing.test.mjs b/web/tests/unit/oss-account-space-billing.test.mjs index 963b78ba8..e49cf43cb 100644 --- a/web/tests/unit/oss-account-space-billing.test.mjs +++ b/web/tests/unit/oss-account-space-billing.test.mjs @@ -46,4 +46,14 @@ test('provider card represents owner and member owner-bound states explicitly', assert.match(source, /ownerSpaceBound/); assert.match(source, /models\.ownerMustBindSpace/); assert.match(source, /models\.usesOwnerSpaceBilling/); + assert.match(source, /isWorkspaceOwner && \(\s*