mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
409 lines
15 KiB
Python
409 lines
15 KiB
Python
"""Service for managing organization members."""
|
|
|
|
from uuid import UUID
|
|
|
|
from server.constants import ROLE_ADMIN, ROLE_OWNER
|
|
from server.routes.org_models import (
|
|
CannotModifySelfError,
|
|
InsufficientPermissionError,
|
|
InvalidRoleError,
|
|
LastOwnerError,
|
|
MemberUpdateError,
|
|
MeResponse,
|
|
OrgMemberNotFoundError,
|
|
OrgMemberPage,
|
|
OrgMemberResponse,
|
|
OrgMemberUpdate,
|
|
RoleNotFoundError,
|
|
)
|
|
from storage.lite_llm_manager import LiteLlmManager
|
|
from storage.org_member_store import OrgMemberStore
|
|
from storage.role_store import RoleStore
|
|
from storage.user_store import UserStore
|
|
|
|
from openhands.core.logger import openhands_logger as logger
|
|
|
|
|
|
class OrgMemberService:
|
|
"""Service for organization member operations."""
|
|
|
|
@staticmethod
|
|
async def get_me(org_id: UUID, user_id: UUID) -> MeResponse:
|
|
"""Get the current user's membership record for an organization.
|
|
|
|
Retrieves the authenticated user's role, status, email, and LLM override
|
|
fields (with masked API keys) within the specified organization.
|
|
|
|
Args:
|
|
org_id: Organization ID (UUID)
|
|
user_id: User ID (UUID)
|
|
|
|
Returns:
|
|
MeResponse: The user's membership data with masked API keys
|
|
|
|
Raises:
|
|
OrgMemberNotFoundError: If user is not a member of the organization
|
|
RoleNotFoundError: If the role associated with the member is not found
|
|
"""
|
|
# Look up the user's membership in this org
|
|
org_member = await OrgMemberStore.get_org_member(org_id, user_id)
|
|
if org_member is None:
|
|
raise OrgMemberNotFoundError(str(org_id), str(user_id))
|
|
|
|
# Resolve role name from role_id
|
|
role = await RoleStore.get_role_by_id(org_member.role_id)
|
|
if role is None:
|
|
raise RoleNotFoundError(org_member.role_id)
|
|
|
|
# Get user email
|
|
user = await UserStore.get_user_by_id(str(user_id))
|
|
email = user.email if user and user.email else ''
|
|
|
|
return MeResponse.from_org_member(org_member, role, email)
|
|
|
|
@staticmethod
|
|
async def get_org_members(
|
|
org_id: UUID,
|
|
current_user_id: UUID,
|
|
page_id: str | None = None,
|
|
limit: int = 10,
|
|
email_filter: str | None = None,
|
|
) -> tuple[bool, str | None, OrgMemberPage | None]:
|
|
"""Get organization members with authorization check.
|
|
|
|
Args:
|
|
org_id: Organization UUID.
|
|
current_user_id: Requesting user's UUID.
|
|
page_id: Offset encoded as string (e.g., "0", "10", "20").
|
|
limit: Items per page (default 10).
|
|
email_filter: Optional case-insensitive partial email match.
|
|
|
|
Returns:
|
|
Tuple of (success, error_code, data). If success is True, error_code is None.
|
|
"""
|
|
# Verify current user is a member of the organization
|
|
requester_membership = await OrgMemberStore.get_org_member(
|
|
org_id, current_user_id
|
|
)
|
|
if not requester_membership:
|
|
return False, 'not_a_member', None
|
|
|
|
# Parse page_id to get offset (page_id is offset encoded as string)
|
|
offset = 0
|
|
if page_id is not None:
|
|
try:
|
|
offset = int(page_id)
|
|
if offset < 0:
|
|
return False, 'invalid_page_id', None
|
|
except ValueError:
|
|
return False, 'invalid_page_id', None
|
|
|
|
# Call store to get paginated members
|
|
members, _ = await OrgMemberStore.get_org_members_paginated(
|
|
org_id=org_id,
|
|
offset=offset,
|
|
limit=limit,
|
|
email_filter=email_filter,
|
|
)
|
|
|
|
# Transform data to response format
|
|
items = []
|
|
for member in members:
|
|
# Access user and role relationships (eagerly loaded)
|
|
user = member.user
|
|
role = member.role
|
|
|
|
items.append(
|
|
OrgMemberResponse(
|
|
user_id=str(member.user_id),
|
|
email=user.email if user else None,
|
|
role_id=member.role_id,
|
|
role=role.name if role else '',
|
|
role_rank=role.rank if role else 0,
|
|
status=member.status,
|
|
)
|
|
)
|
|
|
|
# Calculate current page (1-indexed)
|
|
current_page = (offset // limit) + 1
|
|
|
|
return (
|
|
True,
|
|
None,
|
|
OrgMemberPage(
|
|
items=items,
|
|
current_page=current_page,
|
|
per_page=limit,
|
|
),
|
|
)
|
|
|
|
@staticmethod
|
|
async def get_org_members_count(
|
|
org_id: UUID,
|
|
current_user_id: UUID,
|
|
email_filter: str | None = None,
|
|
) -> int:
|
|
"""Get count of organization members with authorization check.
|
|
|
|
Args:
|
|
org_id: Organization UUID.
|
|
current_user_id: Requesting user's UUID.
|
|
email_filter: Optional case-insensitive partial email match.
|
|
|
|
Returns:
|
|
int: Count of organization members matching the filter.
|
|
|
|
Raises:
|
|
OrgMemberNotFoundError: If requesting user is not a member of the organization.
|
|
"""
|
|
# Verify current user is a member of the organization
|
|
requester_membership = await OrgMemberStore.get_org_member(
|
|
org_id, current_user_id
|
|
)
|
|
if not requester_membership:
|
|
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
|
|
|
|
return await OrgMemberStore.get_org_members_count(
|
|
org_id=org_id,
|
|
email_filter=email_filter,
|
|
)
|
|
|
|
@staticmethod
|
|
async def remove_org_member(
|
|
org_id: UUID,
|
|
target_user_id: UUID,
|
|
current_user_id: UUID,
|
|
) -> tuple[bool, str | None]:
|
|
"""Remove a member from an organization.
|
|
|
|
Returns:
|
|
Tuple of (success, error_message). If success is True, error_message is None.
|
|
"""
|
|
# Get current user's membership in the org
|
|
requester_membership = await OrgMemberStore.get_org_member(
|
|
org_id, current_user_id
|
|
)
|
|
if not requester_membership:
|
|
return False, 'not_a_member'
|
|
|
|
# Check if trying to remove self
|
|
if str(current_user_id) == str(target_user_id):
|
|
return False, 'cannot_remove_self'
|
|
|
|
# Get target user's membership
|
|
target_membership = await OrgMemberStore.get_org_member(org_id, target_user_id)
|
|
if not target_membership:
|
|
return False, 'member_not_found'
|
|
|
|
requester_role = await RoleStore.get_role_by_id(requester_membership.role_id)
|
|
target_role = await RoleStore.get_role_by_id(target_membership.role_id)
|
|
|
|
if not requester_role or not target_role:
|
|
return False, 'role_not_found'
|
|
|
|
# Check permission based on roles
|
|
if not OrgMemberService._can_remove_member(
|
|
requester_role.name, target_role.name
|
|
):
|
|
return False, 'insufficient_permission'
|
|
|
|
# Check if removing the last owner
|
|
if target_role.name == ROLE_OWNER:
|
|
if await OrgMemberService._is_last_owner(org_id, target_user_id):
|
|
return False, 'cannot_remove_last_owner'
|
|
|
|
# Perform the removal
|
|
success = await OrgMemberStore.remove_user_from_org(org_id, target_user_id)
|
|
if not success:
|
|
return False, 'removal_failed'
|
|
|
|
# Update user's current_org_id if it points to the org they were removed from
|
|
user = await UserStore.get_user_by_id(str(target_user_id))
|
|
if user and user.current_org_id == org_id:
|
|
# Set current_org_id to personal workspace (org.id == user.id)
|
|
await UserStore.update_current_org(str(target_user_id), target_user_id)
|
|
|
|
# If database removal succeeded, also remove from LiteLLM team
|
|
try:
|
|
await LiteLlmManager.remove_user_from_team(str(target_user_id), str(org_id))
|
|
logger.info(
|
|
'Successfully removed user from LiteLLM team',
|
|
extra={
|
|
'user_id': str(target_user_id),
|
|
'org_id': str(org_id),
|
|
},
|
|
)
|
|
except Exception as e:
|
|
# Log but don't fail the operation - database removal already succeeded
|
|
# LiteLLM state will be eventually consistent
|
|
logger.warning(
|
|
'Failed to remove user from LiteLLM team',
|
|
extra={
|
|
'user_id': str(target_user_id),
|
|
'org_id': str(org_id),
|
|
'error': str(e),
|
|
},
|
|
)
|
|
|
|
return True, None
|
|
|
|
@staticmethod
|
|
async def update_org_member(
|
|
org_id: UUID,
|
|
target_user_id: UUID,
|
|
current_user_id: UUID,
|
|
update_data: OrgMemberUpdate,
|
|
) -> OrgMemberResponse:
|
|
"""Update a member's role in an organization.
|
|
|
|
Permission rules:
|
|
- Owners can modify anyone (including other owners), can set any role
|
|
- Admins can modify other admins and users
|
|
- Admins can only set admin or user roles (not owner)
|
|
|
|
Args:
|
|
org_id: Organization ID
|
|
target_user_id: User ID of the member to update
|
|
current_user_id: User ID of the requester
|
|
update_data: Update data containing fields to modify
|
|
|
|
Returns:
|
|
OrgMemberResponse: The updated member data
|
|
|
|
Raises:
|
|
OrgMemberNotFoundError: If requester or target is not a member
|
|
CannotModifySelfError: If trying to modify self
|
|
RoleNotFoundError: If role configuration is invalid
|
|
InvalidRoleError: If new_role_name is not a valid role
|
|
InsufficientPermissionError: If requester lacks permission
|
|
LastOwnerError: If trying to demote the last owner
|
|
MemberUpdateError: If update operation fails
|
|
"""
|
|
new_role_name = update_data.role
|
|
|
|
# Get current user's membership in the org
|
|
requester_membership = await OrgMemberStore.get_org_member(
|
|
org_id, current_user_id
|
|
)
|
|
if not requester_membership:
|
|
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
|
|
|
|
# Check if trying to modify self
|
|
if str(current_user_id) == str(target_user_id):
|
|
raise CannotModifySelfError('modify')
|
|
|
|
# Get target user's membership
|
|
target_membership = await OrgMemberStore.get_org_member(org_id, target_user_id)
|
|
if not target_membership:
|
|
raise OrgMemberNotFoundError(str(org_id), str(target_user_id))
|
|
|
|
# Get roles
|
|
requester_role = await RoleStore.get_role_by_id(requester_membership.role_id)
|
|
target_role = await RoleStore.get_role_by_id(target_membership.role_id)
|
|
|
|
if not requester_role:
|
|
raise RoleNotFoundError(requester_membership.role_id)
|
|
if not target_role:
|
|
raise RoleNotFoundError(target_membership.role_id)
|
|
|
|
# If no role change requested, return current state
|
|
if new_role_name is None:
|
|
user = await UserStore.get_user_by_id(str(target_user_id))
|
|
return OrgMemberResponse(
|
|
user_id=str(target_membership.user_id),
|
|
email=user.email if user else None,
|
|
role_id=target_membership.role_id,
|
|
role=target_role.name,
|
|
role_rank=target_role.rank,
|
|
status=target_membership.status,
|
|
)
|
|
|
|
# Validate new role exists
|
|
new_role = await RoleStore.get_role_by_name(new_role_name.lower())
|
|
if not new_role:
|
|
raise InvalidRoleError(new_role_name)
|
|
|
|
# Check permission to modify target
|
|
if not OrgMemberService._can_update_member_role(
|
|
requester_role.name, target_role.name, new_role.name
|
|
):
|
|
raise InsufficientPermissionError(
|
|
'You do not have permission to modify this member'
|
|
)
|
|
|
|
# Check if demoting the last owner
|
|
if (
|
|
target_role.name == ROLE_OWNER
|
|
and new_role.name != ROLE_OWNER
|
|
and await OrgMemberService._is_last_owner(org_id, target_user_id)
|
|
):
|
|
raise LastOwnerError('demote')
|
|
|
|
# Perform the update
|
|
updated_member = await OrgMemberStore.update_user_role_in_org(
|
|
org_id, target_user_id, new_role.id
|
|
)
|
|
if not updated_member:
|
|
raise MemberUpdateError('Failed to update member')
|
|
|
|
# Get user email for response
|
|
user = await UserStore.get_user_by_id(str(target_user_id))
|
|
|
|
return OrgMemberResponse(
|
|
user_id=str(updated_member.user_id),
|
|
email=user.email if user else None,
|
|
role_id=updated_member.role_id,
|
|
role=new_role.name,
|
|
role_rank=new_role.rank,
|
|
status=updated_member.status,
|
|
)
|
|
|
|
@staticmethod
|
|
def _can_update_member_role(
|
|
requester_role_name: str, target_role_name: str, new_role_name: str
|
|
) -> bool:
|
|
"""Check if requester can change target's role to new_role.
|
|
|
|
Permission rules:
|
|
- Owners can modify anyone (including other owners), can set any role
|
|
- Admins can modify other admins and users
|
|
- Admins can only set admin or user roles (not owner)
|
|
"""
|
|
is_requester_owner = requester_role_name == ROLE_OWNER
|
|
is_requester_admin = requester_role_name == ROLE_ADMIN
|
|
is_target_owner = target_role_name == ROLE_OWNER
|
|
is_new_role_owner = new_role_name == ROLE_OWNER
|
|
|
|
if is_requester_owner:
|
|
# Owners can modify anyone (including other owners)
|
|
return True
|
|
elif is_requester_admin:
|
|
# Admins cannot modify owners
|
|
if is_target_owner:
|
|
return False
|
|
# Admins can only set admin or user roles (not owner)
|
|
return not is_new_role_owner
|
|
return False
|
|
|
|
@staticmethod
|
|
def _can_remove_member(requester_role_name: str, target_role_name: str) -> bool:
|
|
"""Check if requester can remove target based on roles."""
|
|
if requester_role_name == ROLE_OWNER:
|
|
return True
|
|
elif requester_role_name == ROLE_ADMIN:
|
|
# Admins can remove admins and members (not owners)
|
|
return target_role_name != ROLE_OWNER
|
|
return False
|
|
|
|
@staticmethod
|
|
async def _is_last_owner(org_id: UUID, user_id: UUID) -> bool:
|
|
"""Check if user is the last owner of the organization."""
|
|
members = await OrgMemberStore.get_org_members(org_id)
|
|
owners = []
|
|
for m in members:
|
|
# Use role_id (column) instead of role (relationship) to avoid DetachedInstanceError
|
|
role = await RoleStore.get_role_by_id(m.role_id)
|
|
if role and role.name == ROLE_OWNER:
|
|
owners.append(m)
|
|
return len(owners) == 1 and str(owners[0].user_id) == str(user_id)
|