Skip to content

Commit 1e6e4c0

Browse files
authored
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>
1 parent 408c803 commit 1e6e4c0

19 files changed

Lines changed: 274 additions & 71 deletions

File tree

src/langbot/pkg/api/http/authz.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ class Permission(enum.StrEnum):
1919
WORKSPACE_VIEW = 'workspace.view'
2020
WORKSPACE_UPDATE = 'workspace.update'
2121
WORKSPACE_DELETE = 'workspace.delete'
22-
OWNER_TRANSFER = 'owner.transfer'
2322
MEMBER_VIEW = 'member.view'
2423
MEMBER_INVITE = 'member.invite'
2524
MEMBER_UPDATE_ROLE = 'member.update_role'
@@ -49,7 +48,6 @@ class Permission(enum.StrEnum):
4948
if permission
5049
not in {
5150
Permission.WORKSPACE_DELETE,
52-
Permission.OWNER_TRANSFER,
5351
Permission.BILLING_LINK_MANAGE,
5452
}
5553
),

src/langbot/pkg/api/http/controller/group.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ class AuthType(enum.Enum):
6262

6363
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
6464
{
65-
Permission.OWNER_TRANSFER.value,
6665
Permission.MEMBER_VIEW.value,
6766
Permission.MEMBER_INVITE.value,
6867
Permission.MEMBER_UPDATE_ROLE.value,

src/langbot/pkg/api/http/controller/groups/user.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,11 +291,19 @@ async def _(request_context: RequestContext) -> str:
291291
# Workspace owner is already bound even when this Core has no local OAuth
292292
# token row (model billing uses the owner's control-plane API key).
293293
owner_space_bound = cloud_mode or owner_has_local_space_credentials
294-
credits = (
295-
await self.ap.space_service.get_credits(owner.user)
296-
if owner is not None and owner.space_account_uuid
297-
else None
298-
)
294+
if cloud_mode:
295+
catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None)
296+
credits = (
297+
catalog_service.get_workspace_credits(access.workspace.uuid)
298+
if catalog_service is not None
299+
else None
300+
)
301+
else:
302+
credits = (
303+
await self.ap.space_service.get_credits(owner.user)
304+
if owner is not None and owner.space_account_uuid
305+
else None
306+
)
299307
return self.success(
300308
data={
301309
'credits': credits,

src/langbot/pkg/cloud/model_catalog.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class CloudWorkspaceModelBilling(BaseModel):
5353
workspace_uuid: str = Field(min_length=36, max_length=36)
5454
owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
5555
api_key: SecretStr | None = None
56+
credits: int | None = None
5657

5758
@field_validator('workspace_uuid')
5859
@classmethod
@@ -149,6 +150,11 @@ def __init__(
149150
# convergence marker so a failed runtime reload is retried even when the
150151
# following database reconciliation is a no-op.
151152
self._runtime_reload_pending = False
153+
self._workspace_credits: dict[str, int | None] = {}
154+
155+
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
156+
"""Return the latest signed owner-credit projection for a Workspace."""
157+
return self._workspace_credits.get(str(uuid.UUID(workspace_uuid)))
152158

153159
async def initialize(self) -> None:
154160
await self.sync_once(reload_runtime=False)
@@ -198,6 +204,7 @@ async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]:
198204
self._runtime_reload_pending = True
199205
for key in ('created', 'updated', 'deleted'):
200206
summary[key] += counts[key]
207+
self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
201208
except Exception as exc:
202209
sync_error = exc
203210
finally:

src/langbot/pkg/entity/persistence/workspace.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ class WorkspaceMembership(Base):
163163
__table_args__ = (
164164
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
165165
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
166+
sqlalchemy.Index(
167+
'uq_workspace_memberships_one_active_owner',
168+
'workspace_uuid',
169+
unique=True,
170+
sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
171+
postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
172+
),
166173
sqlalchemy.CheckConstraint(
167174
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
168175
name='ck_workspace_memberships_role',
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""enforce one active owner per Workspace
2+
3+
Revision ID: 0019_single_workspace_owner
4+
Revises: 0018_merge_launch_replay
5+
Create Date: 2026-08-02
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
revision = '0019_single_workspace_owner'
14+
down_revision = '0018_merge_launch_replay'
15+
branch_labels = None
16+
depends_on = None
17+
18+
_INDEX_NAME = 'uq_workspace_memberships_one_active_owner'
19+
20+
21+
def upgrade() -> None:
22+
conn = op.get_bind()
23+
inspector = sa.inspect(conn)
24+
if 'workspace_memberships' not in inspector.get_table_names():
25+
return
26+
27+
# Ownership transfer used to promote a second member without demoting the
28+
# original owner. Preserve the Workspace creator where possible and demote
29+
# every historical extra owner before installing the database invariant.
30+
op.execute(
31+
sa.text(
32+
"""
33+
WITH ranked_owners AS (
34+
SELECT membership.uuid,
35+
ROW_NUMBER() OVER (
36+
PARTITION BY membership.workspace_uuid
37+
ORDER BY
38+
CASE
39+
WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0
40+
ELSE 1
41+
END,
42+
COALESCE(membership.joined_at, membership.created_at),
43+
membership.uuid
44+
) AS owner_rank
45+
FROM workspace_memberships AS membership
46+
JOIN workspaces AS workspace
47+
ON workspace.uuid = membership.workspace_uuid
48+
WHERE membership.role = 'owner'
49+
AND membership.status = 'active'
50+
)
51+
UPDATE workspace_memberships
52+
SET role = 'admin'
53+
WHERE uuid IN (
54+
SELECT uuid
55+
FROM ranked_owners
56+
WHERE owner_rank > 1
57+
)
58+
"""
59+
)
60+
)
61+
# Fresh installations may already have this index because SQLAlchemy
62+
# metadata is created before Alembic advances the revision marker.
63+
op.execute(
64+
sa.text(
65+
'CREATE UNIQUE INDEX IF NOT EXISTS '
66+
'uq_workspace_memberships_one_active_owner '
67+
'ON workspace_memberships (workspace_uuid) '
68+
"WHERE role = 'owner' AND status = 'active'"
69+
)
70+
)
71+
72+
73+
def downgrade() -> None:
74+
conn = op.get_bind()
75+
inspector = sa.inspect(conn)
76+
if 'workspace_memberships' not in inspector.get_table_names():
77+
return
78+
index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')}
79+
if _INDEX_NAME in index_names:
80+
op.drop_index(_INDEX_NAME, table_name='workspace_memberships')

src/langbot/pkg/workspace/collaboration.py

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,8 @@ async def update_member_role(
606606
) -> WorkspaceMembership:
607607
if role not in {item.value for item in MembershipRole}:
608608
raise MembershipPermissionError('Unknown Workspace role')
609+
if role == MembershipRole.OWNER.value:
610+
raise MembershipPermissionError('Workspace ownership cannot be transferred')
609611

610612
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
611613
await self._require_active_workspace(active_session, workspace_uuid)
@@ -617,8 +619,8 @@ async def operation(active_session: AsyncSession) -> WorkspaceMembership:
617619
target_account_uuid,
618620
)
619621
self._require_can_manage_target(persisted_actor, target, new_role=role)
620-
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
621-
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
622+
if target.role == MembershipRole.OWNER.value:
623+
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
622624
target.role = role
623625
await active_session.flush()
624626
return target
@@ -644,7 +646,7 @@ async def operation(active_session: AsyncSession) -> WorkspaceMembership:
644646
)
645647
self._require_can_manage_target(persisted_actor, target)
646648
if target.role == MembershipRole.OWNER.value:
647-
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
649+
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
648650
target.status = MembershipStatus.REMOVED.value
649651
await active_session.flush()
650652
return target
@@ -751,26 +753,6 @@ async def _load_actor(
751753
raise WorkspaceNotFoundError('Workspace not found')
752754
return persisted_actor
753755

754-
async def _require_another_owner(
755-
self,
756-
session: AsyncSession,
757-
workspace_uuid: str,
758-
excluded_account_uuid: str,
759-
) -> None:
760-
owners = (
761-
await session.scalars(
762-
sqlalchemy.select(WorkspaceMembership)
763-
.where(
764-
WorkspaceMembership.workspace_uuid == workspace_uuid,
765-
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
766-
WorkspaceMembership.role == MembershipRole.OWNER.value,
767-
)
768-
.with_for_update()
769-
)
770-
).all()
771-
if not any(owner.account_uuid != excluded_account_uuid for owner in owners):
772-
raise LastOwnerError('The last Workspace owner cannot be removed or demoted')
773-
774756
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
775757
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
776758
raise WorkspaceNotFoundError('Workspace not found')

tests/integration/api/test_support_admin_launch.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,6 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s
333333
assert Permission.RESOURCE_MANAGE.value in permissions
334334
assert not permissions.intersection(
335335
{
336-
Permission.OWNER_TRANSFER.value,
337336
Permission.MEMBER_VIEW.value,
338337
Permission.MEMBER_INVITE.value,
339338
Permission.MEMBER_UPDATE_ROLE.value,

tests/integration/api/test_user_space_oauth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau
289289
application.deployment.mode = 'cloud'
290290
application.user_service.get_workspace_owner = AsyncMock(return_value=None)
291291
application.space_service.get_credits = AsyncMock()
292+
application.cloud_model_catalog_service = SimpleNamespace(
293+
get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None
294+
)
292295

293296
response = await client.get(
294297
'/api/v1/user/space-credits',
@@ -298,7 +301,7 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau
298301

299302
assert response.status_code == 200
300303
assert payload['data'] == {
301-
'credits': None,
304+
'credits': 25000,
302305
'owner_space_bound': True,
303306
'is_workspace_owner': True,
304307
}

tests/integration/api/test_workspaces.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
188188
workspace_uuid = current['workspace']['uuid']
189189
assert current['membership']['role'] == 'owner'
190190
assert 'member.invite' in current['permissions']
191+
assert 'owner.transfer' not in current['permissions']
191192

192193
invite_response = await client.post(
193194
f'/api/v1/workspaces/{workspace_uuid}/invitations',
@@ -263,6 +264,14 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
263264
assert member_current['membership']['role'] == 'viewer'
264265
assert 'member.invite' not in member_current['permissions']
265266

267+
transfer_response = await client.patch(
268+
f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}',
269+
headers=_auth(owner_token, workspace_uuid),
270+
json={'role': 'owner'},
271+
)
272+
assert transfer_response.status_code == 403
273+
assert (await transfer_response.get_json())['code'] == 'permission_denied'
274+
266275
forbidden_invite = await client.post(
267276
f'/api/v1/workspaces/{workspace_uuid}/invitations',
268277
headers=_auth(member_token, workspace_uuid),

0 commit comments

Comments
 (0)