mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 20:30:59 +00:00
fix(cloud): track workspace membership provenance
This commit is contained in:
@@ -15,6 +15,7 @@ from ..entity.persistence.cloud_directory import DirectoryProjectionInbox, Direc
|
||||
from ..entity.persistence.user import AccountSource, AccountStatus, User
|
||||
from ..entity.persistence.workspace import (
|
||||
MembershipRole,
|
||||
MembershipSource,
|
||||
MembershipStatus,
|
||||
Workspace,
|
||||
WorkspaceExecutionSource,
|
||||
@@ -865,12 +866,6 @@ class DirectoryProjectionService:
|
||||
)
|
||||
).all()
|
||||
}
|
||||
existing_account_sources: dict[str, str] = {}
|
||||
if existing:
|
||||
existing_account_sources = {
|
||||
account.uuid: account.source
|
||||
for account in (await session.scalars(sqlalchemy.select(User).where(User.uuid.in_(existing)))).all()
|
||||
}
|
||||
included_accounts: set[str] = set()
|
||||
for member in candidate.members:
|
||||
included_accounts.add(member.account_uuid)
|
||||
@@ -892,18 +887,15 @@ class DirectoryProjectionService:
|
||||
account_uuid=member.account_uuid,
|
||||
role=role,
|
||||
status=status,
|
||||
source=MembershipSource.CLOUD_PROJECTION.value,
|
||||
joined_at=joined_at,
|
||||
projection_revision=member.projection_revision,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if (
|
||||
membership.projection_revision == 0
|
||||
and existing_account_sources.get(member.account_uuid) != AccountSource.CLOUD_PROJECTION.value
|
||||
):
|
||||
# Keep genuinely local collaboration state. Historical Cloud
|
||||
# memberships also used revision zero; once Space includes the
|
||||
# account in its authoritative directory, adopt that row.
|
||||
if membership.source != MembershipSource.CLOUD_PROJECTION.value:
|
||||
# Core-owned collaboration state is never adopted based on
|
||||
# account provenance, revision, or matching account identity.
|
||||
continue
|
||||
if membership.uuid != member.membership_uuid:
|
||||
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
|
||||
@@ -915,12 +907,12 @@ class DirectoryProjectionService:
|
||||
raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents')
|
||||
membership.role = role
|
||||
membership.status = status
|
||||
membership.source = MembershipSource.CLOUD_PROJECTION.value
|
||||
membership.joined_at = joined_at
|
||||
membership.projection_revision = member.projection_revision
|
||||
|
||||
for account_uuid, membership in existing.items():
|
||||
space_owned = existing_account_sources.get(account_uuid) == AccountSource.CLOUD_PROJECTION.value
|
||||
if account_uuid not in included_accounts and (membership.projection_revision != 0 or space_owned):
|
||||
if account_uuid not in included_accounts and membership.source == MembershipSource.CLOUD_PROJECTION.value:
|
||||
membership.status = MembershipStatus.REMOVED.value
|
||||
membership.projection_revision = max(
|
||||
int(membership.projection_revision),
|
||||
|
||||
@@ -40,6 +40,11 @@ class MembershipStatus(enum.StrEnum):
|
||||
REMOVED = 'removed'
|
||||
|
||||
|
||||
class MembershipSource(enum.StrEnum):
|
||||
LOCAL = 'local'
|
||||
CLOUD_PROJECTION = 'cloud_projection'
|
||||
|
||||
|
||||
class InvitationStatus(enum.StrEnum):
|
||||
PENDING = 'pending'
|
||||
ACCEPTED = 'accepted'
|
||||
@@ -151,6 +156,11 @@ class WorkspaceMembership(Base):
|
||||
nullable=True,
|
||||
)
|
||||
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
source = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=MembershipSource.LOCAL.value,
|
||||
)
|
||||
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
@@ -178,6 +188,10 @@ class WorkspaceMembership(Base):
|
||||
"status IN ('active', 'disabled', 'removed')",
|
||||
name='ck_workspace_memberships_status',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"source IN ('local', 'cloud_projection')",
|
||||
name='ck_workspace_memberships_source',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""add explicit Workspace membership source
|
||||
|
||||
Revision ID: 0020_membership_source
|
||||
Revises: 001a_pgvector_dimension_3072
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0020_membership_source'
|
||||
down_revision = '001a_pgvector_dimension_3072'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_CONSTRAINT_NAME = 'ck_workspace_memberships_source'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'workspace_memberships' not in inspector.get_table_names():
|
||||
return
|
||||
if 'source' in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
|
||||
return
|
||||
|
||||
# No durable historical field distinguishes Directory-created revision-zero
|
||||
# rows from Core invitations. Protect every existing row; production can
|
||||
# reclassify separately after UUIDs have been verified against Space.
|
||||
with op.batch_alter_table('workspace_memberships') as batch_op:
|
||||
batch_op.add_column(sa.Column('source', sa.String(length=32), nullable=False, server_default='local'))
|
||||
batch_op.create_check_constraint(
|
||||
_CONSTRAINT_NAME,
|
||||
"source IN ('local', 'cloud_projection')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'workspace_memberships' not in inspector.get_table_names():
|
||||
return
|
||||
if 'source' not in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
|
||||
return
|
||||
with op.batch_alter_table('workspace_memberships') as batch_op:
|
||||
batch_op.drop_constraint(_CONSTRAINT_NAME, type_='check')
|
||||
batch_op.drop_column('source')
|
||||
@@ -17,6 +17,7 @@ from ..entity.persistence.user import AccountStatus, User
|
||||
from ..entity.persistence.workspace import (
|
||||
InvitationStatus,
|
||||
MembershipRole,
|
||||
MembershipSource,
|
||||
MembershipStatus,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
@@ -483,6 +484,7 @@ class WorkspaceCollaborationService:
|
||||
account_uuid=account_uuid,
|
||||
role=invitation.role,
|
||||
status=MembershipStatus.ACTIVE.value,
|
||||
source=MembershipSource.LOCAL.value,
|
||||
invited_by_account_uuid=invitation.created_by_account_uuid,
|
||||
joined_at=now,
|
||||
projection_revision=0,
|
||||
@@ -491,6 +493,7 @@ class WorkspaceCollaborationService:
|
||||
elif membership.status != MembershipStatus.ACTIVE.value:
|
||||
membership.role = invitation.role
|
||||
membership.status = MembershipStatus.ACTIVE.value
|
||||
membership.source = MembershipSource.LOCAL.value
|
||||
membership.invited_by_account_uuid = invitation.created_by_account_uuid
|
||||
membership.joined_at = now
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ..entity.persistence.workspace import (
|
||||
MembershipRole,
|
||||
MembershipSource,
|
||||
MembershipStatus,
|
||||
Workspace,
|
||||
WorkspaceExecutionSource,
|
||||
@@ -451,6 +452,7 @@ class WorkspaceService:
|
||||
account_uuid=account_uuid,
|
||||
role=MembershipRole.OWNER.value,
|
||||
status=MembershipStatus.ACTIVE.value,
|
||||
source=MembershipSource.LOCAL.value,
|
||||
joined_at=joined_at,
|
||||
projection_revision=0,
|
||||
)
|
||||
@@ -458,6 +460,7 @@ class WorkspaceService:
|
||||
else:
|
||||
membership.role = MembershipRole.OWNER.value
|
||||
membership.status = MembershipStatus.ACTIVE.value
|
||||
membership.source = MembershipSource.LOCAL.value
|
||||
membership.joined_at = membership.joined_at or joined_at
|
||||
|
||||
if workspace.created_by_account_uuid is None:
|
||||
|
||||
Reference in New Issue
Block a user