diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index c2533c9872..dfc4142085 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -70,6 +70,12 @@ register_schema_models( ) +def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool: + if role != TenantAccountRole.DATASET_OPERATOR: + return True + return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled + + @console_ns.route("/workspaces/current/members") class MemberListApi(Resource): """List all members of current tenant.""" @@ -110,6 +116,8 @@ class MemberInviteEmailApi(Resource): inviter = current_user if not inviter.current_tenant: raise ValueError("No current tenant") + if not _is_role_enabled(invitee_role, inviter.current_tenant.id): + return {"code": "invalid-role", "message": "Invalid role"}, 400 # Check workspace permission for member invitations from libs.workspace_permission import check_workspace_member_invite_permission @@ -208,6 +216,8 @@ class MemberUpdateRoleApi(Resource): current_user, _ = current_account_with_tenant() if not current_user.current_tenant: raise ValueError("No current tenant") + if not _is_role_enabled(new_role, current_user.current_tenant.id): + return {"code": "invalid-role", "message": "Invalid role"}, 400 member = db.session.get(Account, str(member_id)) if not member: abort(404) @@ -215,11 +225,17 @@ class MemberUpdateRoleApi(Resource): try: assert member is not None, "Member not found" TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user) + except services.errors.account.CannotOperateSelfError as e: + return {"code": "cannot-operate-self", "message": str(e)}, 400 + except services.errors.account.NoPermissionError as e: + return {"code": "forbidden", "message": str(e)}, 403 + except services.errors.account.MemberNotInTenantError as e: + return {"code": "member-not-found", "message": str(e)}, 404 + except services.errors.account.RoleAlreadyAssignedError as e: + return {"code": "role-already-assigned", "message": str(e)}, 400 except Exception as e: raise ValueError(str(e)) - # todo: 403 - return {"result": "success"} diff --git a/api/services/account_service.py b/api/services/account_service.py index b6554a3de7..744c17d126 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -1280,8 +1280,8 @@ class TenantService: """Check member permission""" perms = { "add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - "remove": [TenantAccountRole.OWNER], - "update": [TenantAccountRole.OWNER], + "remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + "update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], } if action not in {"add", "remove", "update"}: raise InvalidActionError("Invalid action.") @@ -1299,6 +1299,15 @@ class TenantService: if not ta_operator or ta_operator.role not in perms[action]: raise NoPermissionError(f"No permission to {action} member.") + if action == "remove" and ta_operator.role == TenantAccountRole.ADMIN and member: + ta_member = db.session.scalar( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id) + .limit(1) + ) + if ta_member and ta_member.role == TenantAccountRole.OWNER: + raise NoPermissionError(f"No permission to {action} member.") + @staticmethod def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account): """Remove member from tenant. @@ -1370,6 +1379,7 @@ class TenantService: def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account): """Update member role""" TenantService.check_member_permission(tenant, operator, member, "update") + new_tenant_role = TenantAccountRole(new_role) target_member_join = db.session.scalar( select(TenantAccountJoin) @@ -1380,6 +1390,11 @@ class TenantService: if not target_member_join: raise MemberNotInTenantError("Member not in tenant.") + operator_role = TenantService.get_user_role(operator, tenant) + target_role = TenantAccountRole(target_member_join.role) + if operator_role == TenantAccountRole.ADMIN and (TenantAccountRole.OWNER in {target_role, new_tenant_role}): + raise NoPermissionError("No permission to update member.") + if target_member_join.role == new_role: raise RoleAlreadyAssignedError("The provided role is already assigned to the member.") @@ -1394,7 +1409,7 @@ class TenantService: current_owner_join.role = TenantAccountRole.ADMIN # Update the role of the target member - target_member_join.role = TenantAccountRole(new_role) + target_member_join.role = new_tenant_role db.session.commit() @staticmethod diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index e9d2f1481e..02013392fc 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -13,6 +13,7 @@ from services.errors.account import ( AccountPasswordError, AccountRegisterError, CurrentPasswordIncorrectError, + NoPermissionError, ) @@ -817,8 +818,8 @@ class TestTenantService: # Mock the database queries in update_member_role method with patch("services.account_service.db") as mock_db: - # scalar calls: permission check, target member lookup - mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join] + # scalar calls: permission check, target member lookup, operator role lookup + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] # Execute test TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator) @@ -827,6 +828,65 @@ class TestTenantService: assert mock_target_join.role == "admin" self._assert_database_operations_called(mock_db) + def test_admin_can_update_admin_member_role(self): + """Test admin can update another non-owner member, including an admin.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator) + + assert mock_target_join.role == "editor" + self._assert_database_operations_called(mock_db) + + def test_admin_cannot_update_owner_member_role(self): + """Test admin cannot update an owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="owner" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + with pytest.raises(NoPermissionError): + TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator) + + def test_admin_cannot_promote_member_to_owner(self): + """Test admin cannot promote a non-owner member to owner.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + with pytest.raises(NoPermissionError): + TenantService.update_member_role(mock_tenant, mock_member, "owner", mock_operator) + # ==================== Permission Check Tests ==================== def test_check_member_permission_success(self, mock_db_dependencies): @@ -864,6 +924,39 @@ class TestTenantService: "add", ) + def test_admin_can_remove_non_owner_member(self, mock_db_dependencies): + """Test admin can remove a non-owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join] + + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_admin_cannot_remove_owner_member(self, mock_db_dependencies): + """Test admin cannot remove an owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="owner" + ) + mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join] + + with pytest.raises(NoPermissionError): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + class TestRegisterService: """ diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index a6d490ff77..e129800487 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -52,7 +52,13 @@ vi.mock('../invited-modal', () => ({ ), })) vi.mock('../operation', () => ({ - default: () =>