-
Notifications
You must be signed in to change notification settings - Fork 0
feat(guardian): Downstream permissions targets to groups #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: suse-2025.12
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| """Convenient shortcuts to manage or check object permissions.""" | ||
|
|
||
| from functools import lru_cache | ||
| from typing import Any | ||
|
|
||
| from django.contrib.contenttypes.models import ContentType | ||
| from django.db.models import Count, Q, QuerySet | ||
| from django.db.models.functions import Cast | ||
| from guardian.ctypes import get_content_type | ||
| from guardian.exceptions import ( | ||
| GuardianError, | ||
| MixedContentTypeError, | ||
| ) | ||
| from guardian.utils import ( | ||
| get_anonymous_user, | ||
| get_role_obj_perms_model, | ||
| ) | ||
|
|
||
|
|
||
| @lru_cache(None) | ||
| def _get_ct_cached(app_label: str, codename: str) -> ContentType: | ||
| """Caches `ContentType` instances like its `QuerySet` does.""" | ||
| return ContentType.objects.get(app_label=app_label, permission__codename=codename) | ||
|
|
||
|
|
||
| def get_objects_for_user( # noqa: PLR0912 PLR0915 | ||
| user: Any, | ||
| perms: str | list[str], | ||
| queryset: QuerySet | None = None, | ||
| ) -> QuerySet: | ||
| """Get objects that a user has *all* + descendant models the supplied permissions for.""" | ||
| if isinstance(perms, str): | ||
| perms = [perms] | ||
| ctype = None | ||
| app_label = None | ||
| codenames = set() | ||
| pk_field = "object_pk" | ||
|
|
||
| # Compute codenames, app_label, ctype | ||
| for perm in perms: | ||
| if "." not in perm: | ||
| raise GuardianError(f"Cannot determine app label and content type from {perm}") | ||
| new_app_label, new_codename = perm.split(".", 1) | ||
| if not new_app_label or not new_codename: | ||
| raise GuardianError(f"Cannot determine app label and content type from {perm}") | ||
|
|
||
| if app_label is not None and app_label != new_app_label: | ||
| raise MixedContentTypeError( | ||
| f"Given perms must have same app label ({app_label} != {new_app_label})" | ||
| ) | ||
|
|
||
| new_ctype = _get_ct_cached(new_app_label, new_codename) | ||
| if ctype is not None and ctype != new_ctype: | ||
| raise MixedContentTypeError( | ||
| f"ContentType was once computed to be {ctype} and another one {new_ctype}" | ||
| ) | ||
|
|
||
| ctype = new_ctype | ||
| app_label = new_app_label | ||
| codenames.add(new_codename) | ||
|
|
||
| if queryset is None: | ||
| queryset = ctype.model_class()._default_manager.all() | ||
| elif ctype != get_content_type(queryset.model): | ||
| raise MixedContentTypeError("Content type for given perms and queryset differs") | ||
|
|
||
| # Superuser has access to all objects | ||
| if user.is_superuser: | ||
| return queryset | ||
|
|
||
| # The anonymous user can have permissions | ||
| if user.is_anonymous: | ||
| user = get_anonymous_user() | ||
|
|
||
| # If the user has a model-level permission, we don't need to filter on it | ||
| model_perms = {code for code in codenames if user.has_perm(ctype.app_label + "." + code)} | ||
| for code in model_perms: | ||
| codenames.discard(code) | ||
| # We may be done | ||
| if len(codenames) == 0: | ||
| return queryset | ||
|
|
||
| # Now we should extract the list of pk values for which we would filter the queryset | ||
| role_model = get_role_obj_perms_model(queryset.model) | ||
| perms_queryset = ( | ||
| role_model.objects.filter(role__in=user.all_roles()) | ||
| .filter(permission__content_type=ctype) | ||
| .filter(permission__codename__in=codenames) | ||
| ) | ||
|
|
||
| if len(codenames) > 1: | ||
| perms_queryset = ( | ||
| perms_queryset.values(pk_field) | ||
| .annotate(object_pk_count=Count(pk_field)) | ||
| .filter(object_pk_count__gte=len(codenames)) | ||
| ) | ||
|
|
||
| # pk is either UUID or an integer type, while object_pk is a varchar | ||
| pk = queryset.model._meta.pk | ||
|
|
||
| # From here on, we'll play a type normalization game. Postgresql has strict | ||
| # type matching for types varchars can't be implicitly coerced into uuids | ||
| # and vice versa, same with integers. | ||
|
|
||
| # thus comparing uuid/int with varchar fails on query execution. | ||
| normalized_pk_field = f"t__normalized_{pk_field}" | ||
| normalized_pk_field_lookup = f"{normalized_pk_field}__in" | ||
|
|
||
| perms_queryset = perms_queryset.annotate( | ||
| **{normalized_pk_field: Cast(pk_field, pk)} | ||
| ).values_list(normalized_pk_field, flat=True) | ||
|
|
||
| if getattr(queryset.model, "parents", None) is not None: | ||
| # here, we play the same game pretty much, this time we extend the | ||
| # subject part of the permissions to include group children. | ||
| # | ||
| # Meaning: granting view permissions on a group parent, gives you view | ||
| # permissions on the children. | ||
|
|
||
| normalized_parents_field = f"t__normalized_{queryset.model.parents.field.name}" | ||
| normalized_parents_field_lookup = f"{normalized_parents_field}__in" | ||
|
|
||
| # Pull now groups matching the current pks returned by the original | ||
| # query _and_ matching the parents. | ||
| values_list = perms_queryset.values_list(normalized_pk_field, flat=True) | ||
| perms_queryset = ( | ||
| queryset.model.objects.annotate( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you check the generated SQL?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ready to read cursed SQL? Baseline (upcasting to target type [uuid, int, varchar]): SELECT
"authentik_core_user"."id", "authentik_core_user"."password", "authentik_core_user"."last_login",
"authentik_core_user"."username", "authentik_core_user"."first_name", "authentik_core_user"."last_name",
"authentik_core_user"."email", "authentik_core_user"."is_active", "authentik_core_user"."date_joined",
"authentik_core_user"."attributes", "authentik_core_user"."uuid", "authentik_core_user"."name",
"authentik_core_user"."path", "authentik_core_user"."type", "authentik_core_user"."password_change_date",
"authentik_core_user"."last_updated"
FROM "authentik_core_user"
WHERE (
NOT ("authentik_core_user"."username" = %s)
AND "authentik_core_user"."id" IN (
SELECT ("permission_subquery"."object_pk")::bigint AS "object_pk"
FROM (
SELECT "guardian_roleobjectpermission"."object_pk" AS "object_pk"
FROM "guardian_roleobjectpermission"
inner join "auth_permission"
ON ("guardian_roleobjectpermission"."permission_id" = "auth_permission"."id")
WHERE (
"guardian_roleobjectpermission"."role_id" IN
(
SELECT DISTINCT w0."uuid"
FROM "authentik_rbac_role" w0
left outer join "authentik_core_user_roles" w1
ON (w0."uuid" = w1."role_id")
left outer join "authentik_core_group_roles" w3
ON (w0."uuid" = w3."role_id")
WHERE (w1."user_id" = %s OR w3."group_id" IN
(
SELECT DISTINCT v0."group_uuid"
FROM "authentik_core_group" v0
left outer join "authentik_core_groupancestry" v1
ON ( v0."group_uuid" = v1."ancestor_id")
WHERE (v0."group_uuid" IN
(
SELECT u0."group_uuid" AS "pk"
FROM "authentik_core_group" u0
inner join "authentik_core_user_ak_groups" u1
ON (u0."group_uuid" = u1."group_id")
WHERE u1."user_id" = %s
)
OR v1."descendant_id" IN
(
SELECT u0."group_uuid" AS "pk"
FROM "authentik_core_group" u0
inner join "authentik_core_user_ak_groups" u1
ON (u0."group_uuid" = u1."group_id")
WHERE u1."user_id" = %s
)
)
)
)
)
AND "auth_permission"."content_type_id" = %s
AND "auth_permission"."codename" IN (%s))
) "permission_subquery" offset 0)
)This change (downcasting from target type [all varchars]): SELECT
"authentik_core_user"."id", "authentik_core_user"."password", "authentik_core_user"."last_login",
"authentik_core_user"."username", "authentik_core_user"."first_name", "authentik_core_user"."last_name",
"authentik_core_user"."email", "authentik_core_user"."is_active", "authentik_core_user"."date_joined",
"authentik_core_user"."attributes", "authentik_core_user"."uuid", "authentik_core_user"."name",
"authentik_core_user"."path", "authentik_core_user"."type", "authentik_core_user"."password_change_date",
"authentik_core_user"."last_updated",( "authentik_core_user"."id" ) :: VARCHAR AS "t__normalized_id"
FROM "authentik_core_user"
WHERE (
NOT ( "authentik_core_user"."username" = %s )
AND ( "authentik_core_user"."id" ) :: VARCHAR IN (
SELECT ( X0."object_pk" ) :: VARCHAR AS "t__normalized_id"
FROM "guardian_roleobjectpermission" X0
inner join "auth_permission" X2
ON ( X0."permission_id" = X2."id" )
WHERE (
X0."role_id" IN (
SELECT DISTINCT W0."uuid"
FROM "authentik_rbac_role" W0
left outer join "authentik_core_user_roles" W1
ON ( W0."uuid" = W1."role_id" )
left outer join "authentik_core_group_roles" W3
ON ( W0."uuid" = W3."role_id" )
WHERE
( W1."user_id" = %s OR W3."group_id" IN (
SELECT DISTINCT V0."group_uuid"
FROM "authentik_core_group" V0
left outer join "authentik_core_groupancestry" V1
ON (V0."group_uuid" = V1."ancestor_id")
WHERE ( V0."group_uuid" IN (
SELECT U0."group_uuid" AS "pk"
FROM "authentik_core_group" U0
inner join "authentik_core_user_ak_groups" U1
ON ( U0."group_uuid" = U1."group_id" )
WHERE U1."user_id" = %s
)
OR V1."descendant_id" IN (
SELECT U0."group_uuid" AS "pk"
FROM "authentik_core_group" U0
inner join "authentik_core_user_ak_groups" U1
ON ( U0."group_uuid" = U1."group_id" )
WHERE U1."user_id" = %s
)
)
)
)
)
AND X2."content_type_id" = %s AND X2."codename" IN ( %s )
)
)
) |
||
| **{ | ||
| normalized_pk_field: Cast(pk.name, pk), | ||
| normalized_parents_field: Cast(queryset.model.parents.field.name, pk), | ||
| } | ||
| ).filter( | ||
| Q(**{normalized_pk_field_lookup: values_list}) | ||
| | Q(**{normalized_parents_field_lookup: values_list}) | ||
| ) | ||
| ).values_list(normalized_pk_field, flat=True) | ||
|
|
||
| # at this point now the `normalized_pk_field` field in the `perms_queryset` is | ||
| # guaranteed to be the target type (uuid, integer, varchar). | ||
| queryset = queryset.annotate(**{normalized_pk_field: Cast(pk.name, pk)}) | ||
| # Now at this point `normalized_pk_field` in the `queryset` is guaranteed to | ||
| # be the target type (uuid, integer, varchar). | ||
|
|
||
| # Now both sides of the comparison are homogeneus, the query planner can | ||
| # take _any_ decision with regards to evaluating these conditions. | ||
| # and still no type mismatch will happen. | ||
| return queryset.filter(**{normalized_pk_field_lookup: perms_queryset}) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| """Test RBAC permissions group descendants""" | ||
|
|
||
| from django.test.utils import override_settings | ||
| from django.urls import reverse | ||
| from rest_framework.test import APITestCase | ||
|
|
||
| from authentik.core.models import Group | ||
| from authentik.core.tests.utils import create_test_user | ||
| from authentik.lib.generators import generate_id | ||
|
|
||
|
|
||
| class TestRBACGroupDescendants(APITestCase): | ||
| """ | ||
| Test that granting permissions upon a group, also grant the same | ||
| permissionsto the children groups | ||
| """ | ||
|
|
||
| def setUp(self) -> None: | ||
| self.user = create_test_user() | ||
|
|
||
| self.group_admin = Group.objects.create( | ||
| name=f"admin_{generate_id(10)}", | ||
| ) | ||
| self.group_admin.users.set([self.user]) | ||
|
|
||
| self.target_parent_group = Group.objects.create( | ||
| name=f"parent_{generate_id(10)}", | ||
| ) | ||
| self.target_child_group = Group.objects.create( | ||
| name=f"child_{generate_id(10)}", | ||
| ) | ||
| self.target_child_group.parents.set([self.target_parent_group]) | ||
|
|
||
| self.group_admin.assign_perms_to_managed_role( | ||
| "authentik_core.view_group", obj=self.target_parent_group | ||
| ) | ||
|
|
||
| # Baseline | ||
|
|
||
| @override_settings(USE_CUSTOM_GUARDIAN=False) | ||
| def test_original_permission_on_parent(self): | ||
| """Baseline assert: permission granted to the parent, action performed upon parent""" | ||
| self.client.force_login(self.user) | ||
| response = self.client.get( | ||
| reverse( | ||
| "authentik_api:group-detail", | ||
| kwargs={"pk": self.target_parent_group.pk}, | ||
| ) | ||
| ) | ||
| self.assertEqual(response.status_code, 200) | ||
|
|
||
| @override_settings(USE_CUSTOM_GUARDIAN=False) | ||
| def test_original_permission_on_child(self): | ||
| """New behavior: permission granted to the parent, action performed upon child""" | ||
| self.client.force_login(self.user) | ||
| response = self.client.get( | ||
| reverse( | ||
| "authentik_api:group-detail", | ||
| kwargs={"pk": self.target_child_group.pk}, | ||
| ) | ||
| ) | ||
| self.assertEqual(response.status_code, 404) | ||
|
|
||
| # SUSE Behavior | ||
|
|
||
| @override_settings(USE_CUSTOM_GUARDIAN=True) | ||
| def test_permission_on_parent(self): | ||
| """Baseline assert: permission granted to the parent, action performed upon parent""" | ||
| self.client.force_login(self.user) | ||
| response = self.client.get( | ||
| reverse( | ||
| "authentik_api:group-detail", | ||
| kwargs={"pk": self.target_parent_group.pk}, | ||
| ) | ||
| ) | ||
| self.assertEqual(response.status_code, 200) | ||
|
|
||
| @override_settings(USE_CUSTOM_GUARDIAN=True) | ||
| def test_permission_on_child(self): | ||
| """New behavior: permission granted to the parent, action performed upon child""" | ||
| self.client.force_login(self.user) | ||
| response = self.client.get( | ||
| reverse( | ||
| "authentik_api:group-detail", | ||
| kwargs={"pk": self.target_child_group.pk}, | ||
| ) | ||
| ) | ||
| self.assertEqual(response.status_code, 200) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For my personal taste this method is too long.
It mixes setup (parsing the permissions) with normalization and then execution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, however kept it 1:1 possible with the upstream code cuz the customization comes at the veeery end of the code rather than the beginning, and as usual nothing is encapsulated into overridable code chunks :c
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So upstream having too long methods actually bricks inheritance and the ability to override small parts....
You see that's another reason for short methods....
We should have a preprocessor that just inlines upstream code 😸