Repository: ckan/ckan

Issue:
member_list action returns deleted users
**CKAN version**
2.9.7

**Describe the bug**
The `member_list` action with  `'object_type': 'user'` returns all member users, including deleted ones
This was fixed with #3078 but relies on the `user_delete` action.
If I manually delete a user from the DB, this will fail.

**Steps to reproduce**
Trying to iterate all members, you will get `toolkit.ObjectNotFound` for deleted users

```py
 users = toolkit.get_action('member_list')(context, {
            'id': container['id'],
            'object_type': 'user',
        })
       
for user in users:
     duser = toolkit.get_action('user_show')(context, {'id': user[0]})
```

**Expected behavior**
`member_list` action must return only active users

**Possible fix**
Add an extra DB query to filter only active users




Discussion:
To mimic the behaviour at the Python level introduced in #3078 (mark membership records as deleted when you delete a user via `user_delete`) we need to implement a foreign key with the proper `CASCADE` behaviour between the `user` and `member` tables. 
Additionally we can add an extra check to the SQLAlchemy query in `member_list` that filters member records who don't have a record in the `user` table.
The same fix can probably be applied for datasets
@avdata99, @amercader I'm interested to work on this issue.

Resolving patch:
diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py
--- a/ckan/logic/action/get.py
+++ b/ckan/logic/action/get.py
@@ -55,6 +55,7 @@
 _and_ = sqlalchemy.and_
 _func = sqlalchemy.func
 _case = sqlalchemy.case
+_null = sqlalchemy.null
 
 
 def site_read(context: Context, data_dict: Optional[DataDict]=None) -> bool:
@@ -183,12 +184,13 @@ def member_list(context: Context, data_dict: DataDict) -> ActionResult.MemberLis
     # User must be able to update the group to remove a member from it
     _check_access('group_show', context, data_dict)
 
-    q = model.Session.query(model.Member).\
-        filter(model.Member.group_id == group.id).\
+    q = model.Session.query(model.Member)
+    if obj_type:
+        q = model.Member.all(obj_type)
+
+    q = q.filter(model.Member.group_id == group.id).\
         filter(model.Member.state == "active")
 
-    if obj_type:
-        q = q.filter(model.Member.table_name == obj_type)
     if capacity:
         q = q.filter(model.Member.capacity == capacity)
 
diff --git a/ckan/model/group.py b/ckan/model/group.py
--- a/ckan/model/group.py
+++ b/ckan/model/group.py
@@ -110,10 +110,32 @@ def get(cls, reference: str) -> Optional["Member"]:
             member = cls.by_name(reference)
         return member
 
+    @classmethod
+    def all(cls, object_type: str) -> Query[Self]:
+        """Filter members that do not have an associated entity.
+
+        Given the design of the member table and the lack of a CASCADE trigger, manually
+        deleted entities will not properly drop it's member row. This can cause member_list
+        to return users or datasets that no longer exist in the system.
+
+        Returns a query object
+        """
+        import ckan.model as model
+
+        models = { "user" : model.User, "package": model.Package, "group": model.Group}
+        outer_mdl = models.get(object_type, None)
+        q = meta.Session.query(cls).filter(model.Member.table_name == object_type)
+        if outer_mdl:
+            q = q.join(outer_mdl, outer_mdl.id == model.Member.table_id, isouter=True).\
+                filter(outer_mdl.state=='active')
+        return q
+
+
     def related_packages(self) -> list[_package.Package]:
         # TODO do we want to return all related packages or certain ones?
         return meta.Session.query(_package.Package).filter_by(
             id=self.table_id).all()
+    
 
     def __str__(self):
         # refer to objects by name, not ID, to help debugging


Test patch:
diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py
--- a/ckan/tests/logic/action/test_get.py
+++ b/ckan/tests/logic/action/test_get.py
@@ -2828,6 +2828,46 @@ def test_user_delete_marks_membership_of_org_as_deleted(self):
 
         assert len(org_members) == 0
 
+    def test_user_list_excludes_deleted_users_not_marked_membership_of_org_as_deleted(self):
+        sysadmin = factories.Sysadmin()
+        org = factories.Organization()
+        user = factories.User()
+        context = {"user": sysadmin["name"]}
+
+        member_dict = {
+            "username": user["id"],
+            "id": org["id"],
+            "role": "member",
+        }
+        helpers.call_action(
+            "organization_member_create", context, **member_dict
+        )
+
+        org_members = helpers.call_action(
+            "member_list",
+            context,
+            id=org["id"],
+            object_type="user",
+            capacity="member",
+        )
+
+        assert len(org_members) == 1
+        assert org_members[0][0] == user["id"]
+        assert org_members[0][1] == "user"
+
+        model.Session.delete(model.User.get(user["id"]))
+        model.Session.commit()
+
+        org_members = helpers.call_action(
+            "member_list",
+            context,
+            id=org["id"],
+            object_type="user",
+            capacity="member",
+        )
+
+        assert len(org_members) == 0
+
 
 @pytest.mark.usefixtures("non_clean_db")
 class TestFollow(object):