mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 20:50:58 +00:00
* 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>
This commit is contained in:
@@ -19,7 +19,6 @@ class Permission(enum.StrEnum):
|
|||||||
WORKSPACE_VIEW = 'workspace.view'
|
WORKSPACE_VIEW = 'workspace.view'
|
||||||
WORKSPACE_UPDATE = 'workspace.update'
|
WORKSPACE_UPDATE = 'workspace.update'
|
||||||
WORKSPACE_DELETE = 'workspace.delete'
|
WORKSPACE_DELETE = 'workspace.delete'
|
||||||
OWNER_TRANSFER = 'owner.transfer'
|
|
||||||
MEMBER_VIEW = 'member.view'
|
MEMBER_VIEW = 'member.view'
|
||||||
MEMBER_INVITE = 'member.invite'
|
MEMBER_INVITE = 'member.invite'
|
||||||
MEMBER_UPDATE_ROLE = 'member.update_role'
|
MEMBER_UPDATE_ROLE = 'member.update_role'
|
||||||
@@ -49,7 +48,6 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
|
|||||||
if permission
|
if permission
|
||||||
not in {
|
not in {
|
||||||
Permission.WORKSPACE_DELETE,
|
Permission.WORKSPACE_DELETE,
|
||||||
Permission.OWNER_TRANSFER,
|
|
||||||
Permission.BILLING_LINK_MANAGE,
|
Permission.BILLING_LINK_MANAGE,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ class AuthType(enum.Enum):
|
|||||||
|
|
||||||
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
||||||
{
|
{
|
||||||
Permission.OWNER_TRANSFER.value,
|
|
||||||
Permission.MEMBER_VIEW.value,
|
Permission.MEMBER_VIEW.value,
|
||||||
Permission.MEMBER_INVITE.value,
|
Permission.MEMBER_INVITE.value,
|
||||||
Permission.MEMBER_UPDATE_ROLE.value,
|
Permission.MEMBER_UPDATE_ROLE.value,
|
||||||
|
|||||||
@@ -291,11 +291,19 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
# Workspace owner is already bound even when this Core has no local OAuth
|
# 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).
|
# token row (model billing uses the owner's control-plane API key).
|
||||||
owner_space_bound = cloud_mode or owner_has_local_space_credentials
|
owner_space_bound = cloud_mode or owner_has_local_space_credentials
|
||||||
credits = (
|
if cloud_mode:
|
||||||
await self.ap.space_service.get_credits(owner.user)
|
catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
||||||
if owner is not None and owner.space_account_uuid
|
credits = (
|
||||||
else None
|
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(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'credits': credits,
|
'credits': credits,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class CloudWorkspaceModelBilling(BaseModel):
|
|||||||
workspace_uuid: str = Field(min_length=36, max_length=36)
|
workspace_uuid: str = Field(min_length=36, max_length=36)
|
||||||
owner_account_uuid: str | None = Field(default=None, 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
|
api_key: SecretStr | None = None
|
||||||
|
credits: int | None = None
|
||||||
|
|
||||||
@field_validator('workspace_uuid')
|
@field_validator('workspace_uuid')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -149,6 +150,11 @@ class CloudModelCatalogSyncService:
|
|||||||
# convergence marker so a failed runtime reload is retried even when the
|
# convergence marker so a failed runtime reload is retried even when the
|
||||||
# following database reconciliation is a no-op.
|
# following database reconciliation is a no-op.
|
||||||
self._runtime_reload_pending = False
|
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:
|
async def initialize(self) -> None:
|
||||||
await self.sync_once(reload_runtime=False)
|
await self.sync_once(reload_runtime=False)
|
||||||
@@ -198,6 +204,7 @@ class CloudModelCatalogSyncService:
|
|||||||
self._runtime_reload_pending = True
|
self._runtime_reload_pending = True
|
||||||
for key in ('created', 'updated', 'deleted'):
|
for key in ('created', 'updated', 'deleted'):
|
||||||
summary[key] += counts[key]
|
summary[key] += counts[key]
|
||||||
|
self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
sync_error = exc
|
sync_error = exc
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -163,6 +163,13 @@ class WorkspaceMembership(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
||||||
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
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(
|
sqlalchemy.CheckConstraint(
|
||||||
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
||||||
name='ck_workspace_memberships_role',
|
name='ck_workspace_memberships_role',
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -606,6 +606,8 @@ class WorkspaceCollaborationService:
|
|||||||
) -> WorkspaceMembership:
|
) -> WorkspaceMembership:
|
||||||
if role not in {item.value for item in MembershipRole}:
|
if role not in {item.value for item in MembershipRole}:
|
||||||
raise MembershipPermissionError('Unknown Workspace role')
|
raise MembershipPermissionError('Unknown Workspace role')
|
||||||
|
if role == MembershipRole.OWNER.value:
|
||||||
|
raise MembershipPermissionError('Workspace ownership cannot be transferred')
|
||||||
|
|
||||||
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
|
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
|
||||||
await self._require_active_workspace(active_session, workspace_uuid)
|
await self._require_active_workspace(active_session, workspace_uuid)
|
||||||
@@ -617,8 +619,8 @@ class WorkspaceCollaborationService:
|
|||||||
target_account_uuid,
|
target_account_uuid,
|
||||||
)
|
)
|
||||||
self._require_can_manage_target(persisted_actor, target, new_role=role)
|
self._require_can_manage_target(persisted_actor, target, new_role=role)
|
||||||
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
|
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.role = role
|
target.role = role
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -644,7 +646,7 @@ class WorkspaceCollaborationService:
|
|||||||
)
|
)
|
||||||
self._require_can_manage_target(persisted_actor, target)
|
self._require_can_manage_target(persisted_actor, target)
|
||||||
if target.role == MembershipRole.OWNER.value:
|
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
|
target.status = MembershipStatus.REMOVED.value
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -751,26 +753,6 @@ class WorkspaceCollaborationService:
|
|||||||
raise WorkspaceNotFoundError('Workspace not found')
|
raise WorkspaceNotFoundError('Workspace not found')
|
||||||
return persisted_actor
|
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:
|
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
|
||||||
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
|
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
|
||||||
raise WorkspaceNotFoundError('Workspace not found')
|
raise WorkspaceNotFoundError('Workspace not found')
|
||||||
|
|||||||
@@ -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 Permission.RESOURCE_MANAGE.value in permissions
|
||||||
assert not permissions.intersection(
|
assert not permissions.intersection(
|
||||||
{
|
{
|
||||||
Permission.OWNER_TRANSFER.value,
|
|
||||||
Permission.MEMBER_VIEW.value,
|
Permission.MEMBER_VIEW.value,
|
||||||
Permission.MEMBER_INVITE.value,
|
Permission.MEMBER_INVITE.value,
|
||||||
Permission.MEMBER_UPDATE_ROLE.value,
|
Permission.MEMBER_UPDATE_ROLE.value,
|
||||||
|
|||||||
@@ -289,6 +289,9 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau
|
|||||||
application.deployment.mode = 'cloud'
|
application.deployment.mode = 'cloud'
|
||||||
application.user_service.get_workspace_owner = AsyncMock(return_value=None)
|
application.user_service.get_workspace_owner = AsyncMock(return_value=None)
|
||||||
application.space_service.get_credits = AsyncMock()
|
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(
|
response = await client.get(
|
||||||
'/api/v1/user/space-credits',
|
'/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 response.status_code == 200
|
||||||
assert payload['data'] == {
|
assert payload['data'] == {
|
||||||
'credits': None,
|
'credits': 25000,
|
||||||
'owner_space_bound': True,
|
'owner_space_bound': True,
|
||||||
'is_workspace_owner': True,
|
'is_workspace_owner': True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
workspace_uuid = current['workspace']['uuid']
|
workspace_uuid = current['workspace']['uuid']
|
||||||
assert current['membership']['role'] == 'owner'
|
assert current['membership']['role'] == 'owner'
|
||||||
assert 'member.invite' in current['permissions']
|
assert 'member.invite' in current['permissions']
|
||||||
|
assert 'owner.transfer' not in current['permissions']
|
||||||
|
|
||||||
invite_response = await client.post(
|
invite_response = await client.post(
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
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_current['membership']['role'] == 'viewer'
|
||||||
assert 'member.invite' not in member_current['permissions']
|
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(
|
forbidden_invite = await client.post(
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
||||||
headers=_auth(member_token, workspace_uuid),
|
headers=_auth(member_token, workspace_uuid),
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_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
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -27,10 +27,9 @@ def test_owner_has_every_fixed_permission():
|
|||||||
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.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)
|
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.WORKSPACE_DELETE)
|
||||||
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
|
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
|
||||||
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
|
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
|
||||||
|
|||||||
@@ -73,11 +73,13 @@ def _snapshot(
|
|||||||
'workspace_uuid': WORKSPACE_A,
|
'workspace_uuid': WORKSPACE_A,
|
||||||
'owner_account_uuid': OWNER_A,
|
'owner_account_uuid': OWNER_A,
|
||||||
'api_key': key_a,
|
'api_key': key_a,
|
||||||
|
'credits': 25000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'workspace_uuid': WORKSPACE_B,
|
'workspace_uuid': WORKSPACE_B,
|
||||||
'owner_account_uuid': OWNER_B,
|
'owner_account_uuid': OWNER_B,
|
||||||
'api_key': 'owner-b-key',
|
'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()
|
first = await service.sync_once()
|
||||||
assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0}
|
assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0}
|
||||||
assert reload_counter.calls == 1
|
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:
|
async with engine.connect() as connection:
|
||||||
providers = (
|
providers = (
|
||||||
@@ -306,6 +310,8 @@ async def test_partial_workspace_failure_reloads_already_committed_changes() ->
|
|||||||
|
|
||||||
with pytest.raises(RuntimeError, match='second Workspace failed'):
|
with pytest.raises(RuntimeError, match='second Workspace failed'):
|
||||||
await service.sync_once()
|
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
|
assert reload_counter.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -203,20 +203,21 @@ async def test_last_owner_cannot_be_demoted(collaboration_context):
|
|||||||
second_membership,
|
second_membership,
|
||||||
)
|
)
|
||||||
|
|
||||||
promoted = await service.update_member_role(
|
with pytest.raises(MembershipPermissionError, match='cannot be transferred'):
|
||||||
workspace.uuid,
|
await service.update_member_role(
|
||||||
second.uuid,
|
workspace.uuid,
|
||||||
'owner',
|
second.uuid,
|
||||||
owner_membership,
|
'owner',
|
||||||
)
|
owner_membership,
|
||||||
assert promoted.role == 'owner'
|
)
|
||||||
demoted = await service.update_member_role(
|
|
||||||
workspace.uuid,
|
with pytest.raises(LastOwnerError):
|
||||||
owner_membership.account_uuid,
|
await service.update_member_role(
|
||||||
'admin',
|
workspace.uuid,
|
||||||
owner_membership,
|
owner_membership.account_uuid,
|
||||||
)
|
'admin',
|
||||||
assert demoted.role == 'admin'
|
owner_membership,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_workspace_selector_requires_membership(collaboration_context):
|
async def test_workspace_selector_requires_membership(collaboration_context):
|
||||||
|
|||||||
@@ -218,20 +218,22 @@ export default function ProviderCard({
|
|||||||
<span>
|
<span>
|
||||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
{isWorkspaceOwner && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-5 w-5"
|
size="icon"
|
||||||
onClick={(e) => {
|
className="h-5 w-5"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
window.open(
|
e.stopPropagation();
|
||||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
window.open(
|
||||||
'_blank',
|
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||||
);
|
'_blank',
|
||||||
}}
|
);
|
||||||
>
|
}}
|
||||||
<Plus className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export default function WorkspaceSettingsPanel({
|
|||||||
const canInvite = permissions.has('member.invite');
|
const canInvite = permissions.has('member.invite');
|
||||||
const canUpdateMembers = permissions.has('member.update_role');
|
const canUpdateMembers = permissions.has('member.update_role');
|
||||||
const canRemoveMembers = permissions.has('member.remove');
|
const canRemoveMembers = permissions.has('member.remove');
|
||||||
const canTransferOwner = permissions.has('owner.transfer');
|
|
||||||
const cloudPortalURL = workspaceInfo
|
const cloudPortalURL = workspaceInfo
|
||||||
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
|
? `${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}`)}
|
{t(`workspace.roles.${role}`)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
{canTransferOwner && (
|
|
||||||
<SelectItem value="owner">
|
|
||||||
{t('workspace.transferOwnership')}
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ export function makeWorkspaceEntry(
|
|||||||
'member.remove',
|
'member.remove',
|
||||||
'member.update_role',
|
'member.update_role',
|
||||||
'member.view',
|
'member.view',
|
||||||
'owner.transfer',
|
|
||||||
'provider_secret.manage',
|
'provider_secret.manage',
|
||||||
'resource.manage',
|
'resource.manage',
|
||||||
'resource.view',
|
'resource.view',
|
||||||
|
|||||||
@@ -46,4 +46,14 @@ test('provider card represents owner and member owner-bound states explicitly',
|
|||||||
assert.match(source, /ownerSpaceBound/);
|
assert.match(source, /ownerSpaceBound/);
|
||||||
assert.match(source, /models\.ownerMustBindSpace/);
|
assert.match(source, /models\.ownerMustBindSpace/);
|
||||||
assert.match(source, /models\.usesOwnerSpaceBilling/);
|
assert.match(source, /models\.usesOwnerSpaceBilling/);
|
||||||
|
assert.match(source, /isWorkspaceOwner && \(\s*<Button/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace member controls never offer ownership transfer', () => {
|
||||||
|
const source = read(
|
||||||
|
'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(source, /canTransferOwner/);
|
||||||
|
assert.doesNotMatch(source, /workspace\.transferOwnership/);
|
||||||
|
assert.doesNotMatch(source, /<SelectItem value="owner">/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user