mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(oss): enforce invitation account and owner billing flows
This commit is contained in:
@@ -258,7 +258,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
except ControlPlaneDirectoryRequiredError as e:
|
||||
return self.http_status(409, e.code, str(e))
|
||||
except account_errors.AccountEmailMismatchError as e:
|
||||
return self.fail(3, str(e))
|
||||
return self.fail(getattr(e, 'code', 3), str(e))
|
||||
except ValueError:
|
||||
self.ap.logger.exception('Space OAuth callback failed')
|
||||
return self.fail(1, 'Space OAuth failed')
|
||||
@@ -278,10 +278,22 @@ class UserRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
@self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Get Space credits balance for current user"""
|
||||
credits = await self.ap.space_service.get_credits(user_email)
|
||||
return self.success(data={'credits': credits})
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get Space credits using only the selected Workspace owner's credentials."""
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
request_context.account_uuid,
|
||||
request_context.workspace_uuid,
|
||||
)
|
||||
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
|
||||
owner_space_bound = bool(owner and owner.space_account_uuid)
|
||||
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
|
||||
return self.success(
|
||||
data={
|
||||
'credits': credits,
|
||||
'owner_space_bound': owner_space_bound,
|
||||
'is_workspace_owner': access.membership.role == 'owner',
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
@@ -289,16 +301,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if not await self.ap.user_service.is_initialized():
|
||||
return self.success(data={'initialized': False})
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'initialized': True,
|
||||
# Login is selected per account in a multi-user instance. A public
|
||||
# bootstrap endpoint must never project one user's authentication
|
||||
# methods onto every other user or disclose that user's state.
|
||||
'password_login_enabled': getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud',
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
)
|
||||
capabilities = await self.ap.user_service.get_login_capabilities()
|
||||
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
||||
capabilities['password_login_enabled'] = False
|
||||
return self.success(data={'initialized': True, **capabilities})
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
@@ -369,8 +375,13 @@ class UserRouterGroup(group.RouterGroup):
|
||||
'account_type': updated_user.account_type,
|
||||
}
|
||||
)
|
||||
except account_errors.AccountEmailMismatchError:
|
||||
return self.http_status(
|
||||
409,
|
||||
'space_account_email_mismatch',
|
||||
'Bind the LangBot Account with the same email as this local Account',
|
||||
)
|
||||
except ValueError:
|
||||
self.ap.logger.exception('Space account binding failed')
|
||||
return self.http_status(400, -1, 'Space account binding failed')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@@ -311,6 +311,12 @@ class InvitationsRouterGroup(group.RouterGroup):
|
||||
|
||||
authorization = quart.request.headers.get('Authorization', '')
|
||||
if authorization.startswith('Bearer '):
|
||||
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
|
||||
return self.http_status(
|
||||
409,
|
||||
'invitation_logout_required',
|
||||
'Sign out before creating the invited local Account',
|
||||
)
|
||||
try:
|
||||
account = await self.ap.user_service.get_authenticated_account(
|
||||
authorization.removeprefix('Bearer ')
|
||||
@@ -345,7 +351,7 @@ class InvitationsRouterGroup(group.RouterGroup):
|
||||
if not isinstance(password, str) or len(password) < 8:
|
||||
return self.http_status(400, 'invalid_password', 'Password must contain at least 8 characters')
|
||||
try:
|
||||
_, membership, token = await self.ap.user_service.register_invited_account(
|
||||
_, membership = await self.ap.user_service.register_invited_account(
|
||||
invitation_token,
|
||||
str(registration.get('email', '')),
|
||||
password,
|
||||
@@ -354,4 +360,4 @@ class InvitationsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(409, exc.code, str(exc))
|
||||
except AccountExistsLoginRequiredError as exc:
|
||||
return self.http_status(409, exc.code, str(exc))
|
||||
return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid})
|
||||
return self.success(data={'workspace_uuid': membership.workspace_uuid, 'login_required': True})
|
||||
|
||||
@@ -15,10 +15,10 @@ import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ....entity.persistence import user
|
||||
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
|
||||
from ....utils import constants
|
||||
from ....entity.errors import account as account_errors
|
||||
from ....workspace.collaboration import normalize_email
|
||||
from ..authz import Permission, permissions_for_role
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
@@ -151,8 +151,8 @@ class UserService:
|
||||
|
||||
Space OAuth credentials belong to an Account, while model-provider secrets
|
||||
belong to a Workspace. Community edition has one unambiguous Workspace, so
|
||||
the historical automatic refresh remains available to members allowed to
|
||||
manage provider secrets. In multi-Workspace SaaS mode the OAuth callback has
|
||||
the historical automatic refresh remains available only to the Workspace owner.
|
||||
In multi-Workspace SaaS mode the OAuth callback has
|
||||
no trusted Workspace selector; the closed control plane or an explicit
|
||||
Workspace settings action must perform that linkage instead.
|
||||
"""
|
||||
@@ -170,7 +170,7 @@ class UserService:
|
||||
if len(accesses) != 1:
|
||||
return
|
||||
access = accesses[0]
|
||||
if Permission.PROVIDER_SECRET_MANAGE.value not in permissions_for_role(access.membership.role):
|
||||
if access.membership.role != MembershipRole.OWNER.value:
|
||||
return
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(
|
||||
access.workspace.uuid,
|
||||
@@ -184,6 +184,37 @@ class UserService:
|
||||
)
|
||||
return account is not None
|
||||
|
||||
async def get_login_capabilities(self) -> dict[str, bool]:
|
||||
"""Derive enabled public login methods from all active Accounts."""
|
||||
password_count = sqlalchemy.func.count().filter(
|
||||
user.User.password.is_not(None), user.User.password != ''
|
||||
)
|
||||
space_count = sqlalchemy.func.count().filter(user.User.space_account_uuid.is_not(None))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(password_count, space_count).where(
|
||||
user.User.status == user.AccountStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
password_accounts, space_accounts = result.one()
|
||||
return {
|
||||
'password_login_enabled': bool(password_accounts),
|
||||
'space_login_enabled': bool(space_accounts),
|
||||
}
|
||||
|
||||
async def get_workspace_owner(self, workspace_uuid: str) -> user.User | None:
|
||||
"""Resolve the active owner Account for a Workspace."""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User)
|
||||
.join(WorkspaceMembership, WorkspaceMembership.account_uuid == user.User.uuid)
|
||||
.where(
|
||||
WorkspaceMembership.workspace_uuid == workspace_uuid,
|
||||
WorkspaceMembership.role == MembershipRole.OWNER.value,
|
||||
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
|
||||
user.User.status == user.AccountStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
|
||||
|
||||
@@ -230,7 +261,7 @@ class UserService:
|
||||
invitation_token: str,
|
||||
user_email: str,
|
||||
password: str,
|
||||
) -> tuple[user.User, typing.Any, str]:
|
||||
) -> tuple[user.User, typing.Any]:
|
||||
"""Create an invited Account and accept its Membership in one transaction."""
|
||||
|
||||
normalized_email = normalize_email(user_email)
|
||||
@@ -261,8 +292,7 @@ class UserService:
|
||||
account.uuid,
|
||||
session=session,
|
||||
)
|
||||
token = await self.generate_jwt_token(account)
|
||||
return account, membership, token
|
||||
return account, membership
|
||||
|
||||
def _new_account(self, normalized_email: str, hashed_password: str) -> user.User:
|
||||
return user.User(
|
||||
@@ -497,12 +527,12 @@ class UserService:
|
||||
# Account merely by presenting the same email. The Account
|
||||
# owner must first authenticate locally and use the explicit,
|
||||
# account-bound bind flow.
|
||||
raise account_errors.AccountEmailMismatchError()
|
||||
raise account_errors.SpaceAccountBindingRequiredError()
|
||||
|
||||
# Check if system is already initialized
|
||||
is_initialized = await self.is_initialized()
|
||||
if is_initialized:
|
||||
raise account_errors.AccountEmailMismatchError()
|
||||
raise account_errors.SpaceAccountNotRegisteredError()
|
||||
|
||||
# Create new Space user (first time initialization)
|
||||
if hasattr(self.ap.persistence_mgr, 'get_db_engine') and hasattr(self.ap, 'workspace_service'):
|
||||
@@ -707,6 +737,8 @@ class UserService:
|
||||
|
||||
if not space_account_uuid or not space_email:
|
||||
raise ValueError('Invalid Space user info')
|
||||
if normalize_email(space_email) != normalize_email(user_email):
|
||||
raise account_errors.AccountEmailMismatchError()
|
||||
|
||||
# Check if this Space account is already bound to another user
|
||||
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
|
||||
@@ -2,5 +2,19 @@ from __future__ import annotations
|
||||
|
||||
|
||||
class AccountEmailMismatchError(Exception):
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return 'Account email mismatch'
|
||||
|
||||
|
||||
class SpaceAccountNotRegisteredError(AccountEmailMismatchError):
|
||||
code = 'space_account_not_registered'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return 'No Account is registered for this Space email'
|
||||
|
||||
|
||||
class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
|
||||
code = 'space_account_binding_required'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return 'This local Account must bind Space from Account settings before Space login'
|
||||
|
||||
Reference in New Issue
Block a user