mirror of
https://github.com/goauthentik/authentik
synced 2026-05-01 11:57:09 +02:00
* clean up roles and permissions This was purposefully not included in `2025.12` to split the changes up. The main content of this patch is in the migrations. Everything else follows more or less automatically. * add breaking change warning to release notes * add `ak_groups` --> `groups` deprecated proxy * fixup! add `ak_groups` --> `groups` deprecated proxy * fixup! add `ak_groups` --> `groups` deprecated proxy * fixup! add `ak_groups` --> `groups` deprecated proxy * add configuration warning to default notifications blueprint * add rudimentary tests for User.ak_groups * remove no longer used permissions * clarify deprecation Co-authored-by: Jens L. <jens@goauthentik.io> Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com> * remove integration changes These will be included in a separate PR once this is released. --------- Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com> Co-authored-by: Jens L. <jens@goauthentik.io>
296 lines
10 KiB
Python
296 lines
10 KiB
Python
"""Convenient shortcuts to manage or check object permissions."""
|
|
|
|
from functools import lru_cache
|
|
from typing import Any, TypeVar
|
|
|
|
from django.contrib.auth.models import Permission
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db.models import (
|
|
Count,
|
|
ForeignKey,
|
|
Model,
|
|
QuerySet,
|
|
UUIDField,
|
|
)
|
|
from django.db.models.expressions import RawSQL
|
|
|
|
from guardian.core import ObjectPermissionChecker
|
|
from guardian.exceptions import (
|
|
GuardianError,
|
|
InvalidIdentity,
|
|
MixedContentTypeError,
|
|
)
|
|
from guardian.utils import (
|
|
get_anonymous_user,
|
|
get_content_type,
|
|
get_identity,
|
|
get_role_model_perms_model,
|
|
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)
|
|
|
|
|
|
# kwargs are required to be connected to a django signal
|
|
def clear_ct_cache(**kwargs) -> None:
|
|
"""Helper to clear cache of `_get_ct_cached`"""
|
|
if hasattr(_get_ct_cached, "cache_clear"):
|
|
_get_ct_cached.cache_clear()
|
|
|
|
|
|
def assign_perm(
|
|
perm: str | Permission,
|
|
role: Any,
|
|
obj: Model | None = None,
|
|
):
|
|
"""Assigns permission to role and object pair.
|
|
|
|
Parameters:
|
|
perm (str | Permission): permission to assign for the given `obj`,
|
|
in format: `app_label.codename` or `codename` or `Permission` instance.
|
|
If `obj` is not given, must be in format `app_label.codename` or `Permission` instance.
|
|
role (Role):
|
|
The role to add the parmission to.
|
|
Passing any other object would raise `guardian.exceptions.InvalidIdentity`
|
|
obj (Model | None): Django's `Model` instance or `None` if assigning a global permission.
|
|
*Default* is `None`.
|
|
"""
|
|
|
|
role_model = get_role_obj_perms_model().role.field.related_model
|
|
if not isinstance(role, role_model):
|
|
raise InvalidIdentity("Can only assign_perm to a Role.")
|
|
role = get_identity(role)[2]
|
|
if not role:
|
|
return None
|
|
|
|
if not isinstance(perm, Permission):
|
|
try:
|
|
app_label, codename = perm.split(".", 1)
|
|
except ValueError:
|
|
raise ValueError(
|
|
"For global permissions, first argument must be in format: "
|
|
f"'app_label.codename' (is {perm})"
|
|
) from None
|
|
permission = Permission.objects.get(content_type__app_label=app_label, codename=codename)
|
|
else:
|
|
permission = perm
|
|
|
|
kwargs = {
|
|
"content_type": permission.content_type,
|
|
"permission": permission,
|
|
"role": role,
|
|
}
|
|
if obj is None:
|
|
model_perm, _ = get_role_model_perms_model().objects.get_or_create(**kwargs)
|
|
return model_perm
|
|
else:
|
|
kwargs["object_pk"] = obj.pk
|
|
obj_perm, _ = get_role_obj_perms_model().objects.get_or_create(**kwargs)
|
|
return obj_perm
|
|
|
|
|
|
def remove_perm(
|
|
perm: str | Permission,
|
|
role: Any,
|
|
obj: Model | None = None,
|
|
):
|
|
"""Removes permission from role and object pair.
|
|
|
|
Parameters:
|
|
perm (str): Permission for `obj`, in format `app_label.codename` or `codename`.
|
|
If `obj` is not given, must be in format `app_label.codename`.
|
|
role (Role): The role to remove the permission from.
|
|
Passing any other object would raise `guardian.exceptions.InvalidIdentity`
|
|
obj (Model): Django's `Model` instance or `None` if removing a global permission.
|
|
*Default* is `None`.
|
|
"""
|
|
role_model = get_role_obj_perms_model().role.field.related_model
|
|
if not isinstance(role, role_model):
|
|
raise InvalidIdentity("Can only assign_perm to a Role.")
|
|
role = get_identity(role)[2]
|
|
if not role:
|
|
return None
|
|
|
|
if not isinstance(perm, Permission):
|
|
try:
|
|
app_label, codename = perm.split(".", 1)
|
|
except ValueError:
|
|
raise ValueError(
|
|
"For global permissions, first argument must be in format: "
|
|
f"'app_label.codename' (is {perm})"
|
|
) from None
|
|
permission = Permission.objects.get(content_type__app_label=app_label, codename=codename)
|
|
else:
|
|
permission = perm
|
|
|
|
kwargs = {
|
|
"content_type": permission.content_type,
|
|
"permission": permission,
|
|
"role": role,
|
|
}
|
|
if obj is None:
|
|
model_perm = get_role_model_perms_model().objects.filter(**kwargs).delete()
|
|
return model_perm
|
|
else:
|
|
kwargs["object_pk"] = obj.pk
|
|
obj_perm = get_role_obj_perms_model().objects.filter(**kwargs).delete()
|
|
return obj_perm
|
|
|
|
|
|
def get_perms(identity: Any, obj: Model | None = None) -> set[str]:
|
|
"""Gets the permissions for given user/group/role and object pair,
|
|
|
|
Returns:
|
|
List of permissions for the given user/group/role and object pair.
|
|
"""
|
|
check = ObjectPermissionChecker(identity)
|
|
return check.get_perms(obj)
|
|
|
|
|
|
T = TypeVar("T", bound=Model)
|
|
|
|
|
|
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* the supplied permissions for.
|
|
|
|
Parameters:
|
|
user (User | AnonymousUser): user to check for permissions.
|
|
perms (str | list[str]): permission(s) to be checked.
|
|
These should be full permission names rather than only codenames
|
|
(i.e. `auth.change_user`).
|
|
If more than one permission is present within sequence, their content type **must** be
|
|
the same or `MixedContentTypeError` exception would be raised.
|
|
queryset (QuerySet): a queryset from which to filter objects.
|
|
If not present, the base queryset will just be all objects for the given `perms`.
|
|
|
|
Raises:
|
|
MixedContentTypeError: when computed content type for `perms` clashes.
|
|
|
|
Example:
|
|
```shell
|
|
>>> from django.contrib.auth.models import User
|
|
>>> from guardian.shortcuts import get_objects_for_user
|
|
>>> joe = User.objects.get(username='joe')
|
|
>>> get_objects_for_user(joe, 'auth.change_group')
|
|
[]
|
|
>>> from guardian.shortcuts import assign_perm
|
|
>>> group = Group.objects.create('some group')
|
|
>>> assign_perm('auth.change_group', joe, group)
|
|
>>> get_objects_for_user(joe, 'auth.change_group')
|
|
[<Group some group>]
|
|
|
|
# The permission string can also be an iterable. Continuing with the previous example:
|
|
|
|
>>> get_objects_for_user(joe, ['auth.change_group', 'auth.delete_group'])
|
|
[]
|
|
>>> get_objects_for_user(joe, ['auth.change_group', 'auth.delete_group'], any_perm=True)
|
|
[<Group some group>]
|
|
>>> assign_perm('auth.delete_group', joe, group)
|
|
>>> get_objects_for_user(joe, ['auth.change_group', 'auth.delete_group'])
|
|
[<Group some group>]
|
|
"""
|
|
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()
|
|
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
|
|
|
|
def _cast_type(pk):
|
|
if isinstance(pk, ForeignKey):
|
|
return _cast_type(pk.target_field)
|
|
if isinstance(pk, UUIDField):
|
|
return "uuid"
|
|
return "bigint"
|
|
|
|
cast_type = _cast_type(pk)
|
|
|
|
perms_queryset = perms_queryset.values_list(pk_field, flat=True)
|
|
# The raw subquery is done to ensure that casting only takes place after the WHERE clause of
|
|
# `perms_queryset` is ran. Otherwise, the query planner may decide to cast every `object_pk`,
|
|
# which breaks (for example) if it tries to cast an integer to a UUID. In such a case, the WHERE
|
|
# of `perms_queryset` will remove any integer.
|
|
# However, the subquery might get optimized out by the query planner, which would cause the same
|
|
# cast issue as before. To prevent the subquery from being collapsed in the query below, we add
|
|
# OFFSET 0.
|
|
perms_subquery_sql, perms_subquery_params = perms_queryset.query.sql_with_params()
|
|
subquery = RawSQL(
|
|
f"""
|
|
SELECT ("permission_subquery"."{pk_field}")::{cast_type} as "object_pk"
|
|
FROM ({perms_subquery_sql}) "permission_subquery"
|
|
OFFSET 0
|
|
""", # nosec
|
|
perms_subquery_params,
|
|
)
|
|
return queryset.filter(pk__in=subquery)
|