Skip to content

Commit 3866a97

Browse files
committed
Prevent workspace-wide users from being user group members
1 parent 98f135b commit 3866a97

File tree

3 files changed

+183
-76
lines changed

3 files changed

+183
-76
lines changed

libs/labelbox/src/labelbox/schema/user_group.py

Lines changed: 53 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def update(self) -> UserGroup:
230230
Raises:
231231
ValueError: If group ID or name is not set, or if projects don't exist.
232232
ResourceNotFoundError: If the group or projects are not found.
233-
UnprocessableEntityError: If user validation fails.
233+
UnprocessableEntityError: If user validation fails or users have workspace-level org roles.
234234
"""
235235
if not self.id:
236236
raise ValueError("Group id is required")
@@ -247,8 +247,11 @@ def update(self) -> UserGroup:
247247
)
248248

249249
# Filter eligible users and build user roles
250-
eligible_users = self._filter_project_based_users()
251-
user_roles = self._build_user_roles(eligible_users)
250+
try:
251+
eligible_users = self._filter_project_based_users()
252+
user_roles = self._build_user_roles(eligible_users)
253+
except ValueError as e:
254+
raise UnprocessableEntityError(str(e)) from e
252255

253256
query = """
254257
mutation UpdateUserGroupPyApi($id: ID!, $name: String!, $description: String, $color: String!, $projectIds: [ID!]!, $userRoles: [UserRoleInput!], $notifyMembers: Boolean) {
@@ -312,7 +315,7 @@ def create(self) -> UserGroup:
312315
313316
Raises:
314317
ValueError: If group already has ID, name is invalid, or projects don't exist.
315-
ResourceCreationError: If creation fails or user validation fails.
318+
ResourceCreationError: If creation fails, user validation fails, or users have workspace-level org roles.
316319
ResourceConflict: If a group with the same name already exists.
317320
"""
318321
if self.id:
@@ -330,8 +333,11 @@ def create(self) -> UserGroup:
330333
)
331334

332335
# Filter eligible users and build user roles
333-
eligible_users = self._filter_project_based_users()
334-
user_roles = self._build_user_roles(eligible_users)
336+
try:
337+
eligible_users = self._filter_project_based_users()
338+
user_roles = self._build_user_roles(eligible_users)
339+
except ValueError as e:
340+
raise ResourceCreationError(str(e)) from e
335341

336342
query = """
337343
mutation CreateUserGroupPyApi($name: String!, $description: String, $color: String!, $projectIds: [ID!]!, $userRoles: [UserRoleInput!]!, $notifyMembers: Boolean, $roleId: String, $searchQuery: AlignerrSearchServiceQuery) {
@@ -496,11 +502,14 @@ def get_user_groups(
496502
def _filter_project_based_users(self) -> Set[User]:
497503
"""Filter users to only include users eligible for UserGroups.
498504
499-
Filters out users with specific admin organization roles that cannot be
500-
added to UserGroups. Most users should be eligible.
505+
Only project-based users (org role "NONE") can be added to UserGroups.
506+
Users with any workspace-level organization role cannot be added.
501507
502508
Returns:
503509
Set of users that are eligible to be added to the group.
510+
511+
Raises:
512+
ValueError: If any user has a workspace-level organization role.
504513
"""
505514
all_users = set()
506515
for member in self.members:
@@ -509,63 +518,52 @@ def _filter_project_based_users(self) -> Set[User]:
509518
if not all_users:
510519
return set()
511520

512-
user_ids = [user.uid for user in all_users]
513-
query = """
514-
query CheckUserOrgRolesPyApi($userIds: [ID!]!) {
515-
users(where: {id_in: $userIds}) {
516-
id
517-
orgRole { id name }
518-
}
519-
}
520-
"""
521+
# Check each user's org role directly
522+
invalid_users = []
523+
eligible_users = set()
521524

522-
try:
523-
result = self.client.execute(query, {"userIds": user_ids})
524-
if not result or "users" not in result:
525-
return all_users # Fallback: let server handle validation
526-
527-
# Check for users with org roles that cannot be used in UserGroups
528-
# Only users with no org role (project-based users) can be assigned to UserGroups
529-
eligible_user_ids = set()
530-
invalid_users = []
531-
532-
for user_data in result["users"]:
533-
org_role = user_data.get("orgRole")
534-
user_id = user_data["id"]
535-
user_email = user_data.get("email", "unknown")
536-
537-
if org_role is None:
538-
# Users with no org role (project-based users) are eligible
539-
eligible_user_ids.add(user_id)
525+
for user in all_users:
526+
try:
527+
# Get the user's organization role directly
528+
org_role = user.org_role()
529+
if org_role is None or org_role.name.upper() == "NONE":
530+
# Users with no org role or "NONE" role are project-based and eligible
531+
eligible_users.add(user)
540532
else:
541-
# Users with ANY workspace org role cannot be assigned to UserGroups
533+
# Users with any workspace org role cannot be assigned to UserGroups
542534
invalid_users.append(
543535
{
544-
"id": user_id,
545-
"email": user_email,
546-
"org_role": org_role.get("name"),
536+
"id": user.uid,
537+
"email": getattr(user, "email", "unknown"),
538+
"org_role": org_role.name,
547539
}
548540
)
541+
except Exception as e:
542+
# If we can't determine the user's role, treat as invalid for safety
543+
invalid_users.append(
544+
{
545+
"id": user.uid,
546+
"email": getattr(user, "email", "unknown"),
547+
"org_role": f"unknown (error: {str(e)})",
548+
}
549+
)
549550

550-
# Raise error if any invalid users found
551-
if invalid_users:
552-
error_details = []
553-
for user in invalid_users:
554-
error_details.append(
555-
f"User {user['id']} ({user['email']}) has org role '{user['org_role']}'"
556-
)
557-
558-
raise ValueError(
559-
f"Cannot create UserGroup with users who have organization roles. "
560-
f"Only project-based users (no org role) can be assigned to UserGroups.\n"
561-
f"Invalid users:\n"
562-
+ "\n".join(f" • {detail}" for detail in error_details)
551+
# Raise error if any invalid users found
552+
if invalid_users:
553+
error_details = []
554+
for user in invalid_users:
555+
error_details.append(
556+
f"User {user['id']} ({user['email']}) has org role '{user['org_role']}'"
563557
)
564558

565-
return {user for user in all_users if user.uid in eligible_user_ids}
559+
raise ValueError(
560+
f"Cannot create UserGroup with users who have organization roles. "
561+
f"Only project-based users (no org role or role 'NONE') can be assigned to UserGroups.\n"
562+
f"Invalid users:\n"
563+
+ "\n".join(f" • {detail}" for detail in error_details)
564+
)
566565

567-
except Exception:
568-
return all_users # Fallback: let server handle validation
566+
return eligible_users
569567

570568
def _build_user_roles(
571569
self, eligible_users: Set[User]

libs/labelbox/tests/unit/schema/test_user_group.py

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ def group_user():
4141
user_values["createdAt"] = "2023-01-01T00:00:00Z"
4242
user_values["isExternalUser"] = False
4343
user_values["isViewer"] = False
44-
return User(MagicMock(Client), user_values)
44+
user = User(MagicMock(Client), user_values)
45+
# Mock org_role() to return None (project-based user)
46+
user.org_role = MagicMock(return_value=None)
47+
return user
4548

4649

4750
@pytest.fixture
@@ -237,16 +240,6 @@ def test_update(self, group_user, group_project, mock_role):
237240
self.client.get_project.return_value = group_project
238241

239242
self.client.execute.side_effect = [
240-
# Mock user roles query response
241-
{
242-
"users": [
243-
{
244-
"id": "user_id",
245-
"email": "[email protected]",
246-
"orgRole": None, # Project-based user
247-
}
248-
]
249-
},
250243
# Mock update mutation response
251244
{
252245
"updateUserGroupV3": {
@@ -458,16 +451,6 @@ def test_create(self, group_user, group_project, mock_role):
458451
self.client.get_project.return_value = group_project
459452

460453
self.client.execute.side_effect = [
461-
# Mock user roles query response
462-
{
463-
"users": [
464-
{
465-
"id": "user_id",
466-
"email": "[email protected]",
467-
"orgRole": None, # Project-based user
468-
}
469-
]
470-
},
471454
# Mock create mutation response
472455
{
473456
"createUserGroupV3": {

test_usergroup_fix.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Test script to demonstrate the fixed UserGroup validation behavior.
4+
This script shows how workspace-based users are now properly rejected.
5+
"""
6+
7+
from unittest.mock import MagicMock, Mock
8+
from collections import defaultdict
9+
10+
# Import the fixed UserGroup classes
11+
from libs.labelbox.src.labelbox.schema.user_group import UserGroup, UserGroupMember, UserGroupColor
12+
from libs.labelbox.src.labelbox.schema.user import User
13+
from libs.labelbox.src.labelbox.schema.role import Role
14+
from lbox.exceptions import ResourceCreationError, UnprocessableEntityError
15+
16+
17+
def create_mock_user(user_id: str, email: str, org_role_name: str = None):
18+
"""Create a mock user with specified org role."""
19+
client = MagicMock()
20+
user_values = defaultdict(lambda: None)
21+
user_values["id"] = user_id
22+
user_values["email"] = email
23+
24+
user = User(client, user_values)
25+
26+
# Mock the org_role() method
27+
if org_role_name is None or org_role_name.upper() == "NONE":
28+
user.org_role = Mock(return_value=Mock(name="NONE"))
29+
else:
30+
user.org_role = Mock(return_value=Mock(name=org_role_name))
31+
32+
return user
33+
34+
35+
def create_mock_role(role_name: str):
36+
"""Create a mock role."""
37+
client = MagicMock()
38+
role_values = defaultdict(lambda: None)
39+
role_values["id"] = f"role_{role_name.lower()}"
40+
role_values["name"] = role_name
41+
return Role(client, role_values)
42+
43+
44+
def test_workspace_user_rejection():
45+
"""Test that workspace-based users are properly rejected."""
46+
print("🧪 Testing UserGroup validation fixes...")
47+
48+
client = MagicMock()
49+
50+
# Create test users
51+
project_user = create_mock_user("user1", "[email protected]", "NONE") # Valid
52+
workspace_labeler = create_mock_user("user2", "[email protected]", "LABELER") # Invalid
53+
workspace_admin = create_mock_user("user3", "[email protected]", "ADMIN") # Invalid
54+
55+
# Create test role
56+
labeler_role = create_mock_role("LABELER")
57+
58+
print("\n✅ Test 1: Project-based user should be accepted")
59+
try:
60+
user_group = UserGroup(
61+
client=client,
62+
name="Valid Group",
63+
color=UserGroupColor.BLUE,
64+
members={UserGroupMember(user=project_user, role=labeler_role)}
65+
)
66+
# This should work - call the validation method directly
67+
eligible_users = user_group._filter_project_based_users()
68+
print(f" ✓ Project-based user accepted: {len(eligible_users)} eligible users")
69+
except Exception as e:
70+
print(f" ❌ Unexpected error: {e}")
71+
72+
print("\n❌ Test 2: Workspace labeler should be rejected")
73+
try:
74+
user_group = UserGroup(
75+
client=client,
76+
name="Invalid Group",
77+
color=UserGroupColor.BLUE,
78+
members={UserGroupMember(user=workspace_labeler, role=labeler_role)}
79+
)
80+
# This should fail
81+
eligible_users = user_group._filter_project_based_users()
82+
print(f" ❌ ERROR: Workspace labeler was incorrectly accepted!")
83+
except ValueError as e:
84+
print(f" ✓ Workspace labeler correctly rejected: {str(e)[:100]}...")
85+
86+
print("\n❌ Test 3: Workspace admin should be rejected")
87+
try:
88+
user_group = UserGroup(
89+
client=client,
90+
name="Invalid Group 2",
91+
color=UserGroupColor.BLUE,
92+
members={UserGroupMember(user=workspace_admin, role=labeler_role)}
93+
)
94+
# This should fail
95+
eligible_users = user_group._filter_project_based_users()
96+
print(f" ❌ ERROR: Workspace admin was incorrectly accepted!")
97+
except ValueError as e:
98+
print(f" ✓ Workspace admin correctly rejected: {str(e)[:100]}...")
99+
100+
print("\n❌ Test 4: Mixed users - should reject all if any are invalid")
101+
try:
102+
user_group = UserGroup(
103+
client=client,
104+
name="Mixed Group",
105+
color=UserGroupColor.BLUE,
106+
members={
107+
UserGroupMember(user=project_user, role=labeler_role), # Valid
108+
UserGroupMember(user=workspace_labeler, role=labeler_role), # Invalid
109+
}
110+
)
111+
# This should fail because of the workspace labeler
112+
eligible_users = user_group._filter_project_based_users()
113+
print(f" ❌ ERROR: Mixed group with workspace user was incorrectly accepted!")
114+
except ValueError as e:
115+
print(f" ✓ Mixed group correctly rejected: {str(e)[:100]}...")
116+
117+
print("\n🎉 All tests completed!")
118+
print("\n📋 Summary:")
119+
print(" • Project-based users (org role 'NONE' or null) ✅ ACCEPTED")
120+
print(" • Workspace-based users (any org role) ❌ REJECTED")
121+
print(" • Clear error messages provided")
122+
print(" • No more silent failures or server crashes")
123+
124+
125+
if __name__ == "__main__":
126+
test_workspace_user_rejection()

0 commit comments

Comments
 (0)