Repository: goauthentik/authentik

Issue:
Proxy header X-Authentik-Groups does not include parent groups
**Describe the bug**
When using Authentik as a HTTP proxy, the `X-Authentik-Groups` header only includes the groups the current user is a direct member of. Groups which are a parent to any groups they belong to are not listed in the header.

**To Reproduce**

1. Create a group called `foo`
2. Create a group called `admin`
3. Set the `admin` group's parent to be group `foo`
4. set your user to be a member of `admin` and ensure you are not a direct member of `foo`
5. clear all cookies (to force a clean auth state) and login again
6. use a service like [whoami](https://hub.docker.com/r/traefik/whoami) to see the HTTP headers authentik is forwarding to the backend http service.
7. Observe the `X-Authentik-Groups` header only contains the group `admin`, and does not contain `foo`.

**Expected behavior**
The `X-Authentik-Groups` header should contain `admin` and `foo`.

According to the [Authentik Groups Documentation](https://goauthentik.io/docs/user-group/group), groups which have a parent group inherit their parent's privileges as well. Which should also be reflected on the `X-Authentik-Groups` http header so that HTTP services that check for group membership can see the full list of groups a member belongs to.


**Version and Deployment (please complete the following information):**

-   authentik version:  2023.5.4
-   Deployment: docker-compose



Discussion:

Resolving patch:
diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py
--- a/authentik/core/api/users.py
+++ b/authentik/core/api/users.py
@@ -207,7 +207,7 @@ class UserSelfSerializer(ModelSerializer):
     )
     def get_groups(self, _: User):
         """Return only the group names a user is member of"""
-        for group in self.instance.ak_groups.all():
+        for group in self.instance.all_groups().order_by("name"):
             yield {
                 "name": group.name,
                 "pk": group.pk,
diff --git a/authentik/core/models.py b/authentik/core/models.py
--- a/authentik/core/models.py
+++ b/authentik/core/models.py
@@ -113,27 +113,7 @@ def num_pk(self) -> int:
 
     def is_member(self, user: "User") -> bool:
         """Recursively check if `user` is member of us, or any parent."""
-        query = """
-        WITH RECURSIVE parents AS (
-            SELECT authentik_core_group.*, 0 AS relative_depth
-            FROM authentik_core_group
-            WHERE authentik_core_group.group_uuid = %s
-
-            UNION ALL
-
-            SELECT authentik_core_group.*, parents.relative_depth - 1
-            FROM authentik_core_group,parents
-            WHERE (
-                authentik_core_group.parent_id = parents.group_uuid and
-                parents.relative_depth > -20
-            )
-        )
-        SELECT group_uuid
-        FROM parents
-        GROUP BY group_uuid;
-        """
-        groups = Group.objects.raw(query, [self.group_uuid])
-        return user.ak_groups.filter(pk__in=[group.pk for group in groups]).exists()
+        return user.all_groups().filter(group_uuid=self.group_uuid).exists()
 
     def __str__(self):
         return f"Group {self.name}"
@@ -176,13 +156,45 @@ def default_path() -> str:
         """Get the default user path"""
         return User._meta.get_field("path").default
 
+    def all_groups(self) -> QuerySet[Group]:
+        """Recursively get all groups this user is a member of.
+        At least one query is done to get the direct groups of the user, with groups
+        there are at most 3 queries done"""
+        direct_groups = tuple(
+            str(x) for x in self.ak_groups.all().values_list("pk", flat=True).iterator()
+        )
+        if len(direct_groups) < 1:
+            return Group.objects.none()
+        query = """
+        WITH RECURSIVE parents AS (
+            SELECT authentik_core_group.*, 0 AS relative_depth
+            FROM authentik_core_group
+            WHERE authentik_core_group.group_uuid IN (%s)
+
+            UNION ALL
+
+            SELECT authentik_core_group.*, parents.relative_depth + 1
+            FROM authentik_core_group, parents
+            WHERE (
+                authentik_core_group.group_uuid = parents.parent_id and
+                parents.relative_depth < 20
+            )
+        )
+        SELECT group_uuid
+        FROM parents
+        GROUP BY group_uuid, name
+        ORDER BY name;
+        """
+        group_pks = [group.pk for group in Group.objects.raw(query, direct_groups).iterator()]
+        return Group.objects.filter(pk__in=group_pks)
+
     def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
         """Get a dictionary containing the attributes from all groups the user belongs to,
         including the users attributes"""
         final_attributes = {}
         if request and hasattr(request, "tenant"):
             always_merger.merge(final_attributes, request.tenant.attributes)
-        for group in self.ak_groups.all().order_by("name"):
+        for group in self.all_groups().order_by("name"):
             always_merger.merge(final_attributes, group.attributes)
         always_merger.merge(final_attributes, self.attributes)
         return final_attributes
@@ -196,7 +208,7 @@ def serializer(self) -> Serializer:
     @cached_property
     def is_superuser(self) -> bool:
         """Get supseruser status based on membership in a group with superuser status"""
-        return self.ak_groups.filter(is_superuser=True).exists()
+        return self.all_groups().filter(is_superuser=True).exists()
 
     @property
     def is_staff(self) -> bool:
diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py
--- a/authentik/lib/expression/evaluator.py
+++ b/authentik/lib/expression/evaluator.py
@@ -112,7 +112,7 @@ def expr_regex_replace(value: Any, regex: str, repl: str) -> str:
     @staticmethod
     def expr_is_group_member(user: User, **group_filters) -> bool:
         """Check if `user` is member of group with name `group_name`"""
-        return user.ak_groups.filter(**group_filters).exists()
+        return user.all_groups().filter(**group_filters).exists()
 
     @staticmethod
     def expr_user_by(**filters) -> Optional[User]:


Test patch:
diff --git a/authentik/core/tests/test_groups.py b/authentik/core/tests/test_groups.py
--- a/authentik/core/tests/test_groups.py
+++ b/authentik/core/tests/test_groups.py
@@ -21,22 +21,26 @@ def test_group_membership_parent(self):
         """Test parent membership"""
         user = User.objects.create(username=generate_id())
         user2 = User.objects.create(username=generate_id())
-        first = Group.objects.create(name=generate_id())
-        second = Group.objects.create(name=generate_id(), parent=first)
-        second.users.add(user)
-        self.assertTrue(first.is_member(user))
-        self.assertFalse(first.is_member(user2))
+        parent = Group.objects.create(name=generate_id())
+        child = Group.objects.create(name=generate_id(), parent=parent)
+        child.users.add(user)
+        self.assertTrue(child.is_member(user))
+        self.assertTrue(parent.is_member(user))
+        self.assertFalse(child.is_member(user2))
+        self.assertFalse(parent.is_member(user2))
 
     def test_group_membership_parent_extra(self):
         """Test parent membership"""
         user = User.objects.create(username=generate_id())
         user2 = User.objects.create(username=generate_id())
-        first = Group.objects.create(name=generate_id())
-        second = Group.objects.create(name=generate_id(), parent=first)
+        parent = Group.objects.create(name=generate_id())
+        second = Group.objects.create(name=generate_id(), parent=parent)
         third = Group.objects.create(name=generate_id(), parent=second)
         second.users.add(user)
-        self.assertTrue(first.is_member(user))
-        self.assertFalse(first.is_member(user2))
+        self.assertTrue(parent.is_member(user))
+        self.assertFalse(parent.is_member(user2))
+        self.assertTrue(second.is_member(user))
+        self.assertFalse(second.is_member(user2))
         self.assertFalse(third.is_member(user))
         self.assertFalse(third.is_member(user2))