mirror of
https://github.com/goauthentik/authentik
synced 2026-05-01 20:07:20 +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>
281 lines
9.4 KiB
Python
281 lines
9.4 KiB
Python
"""
|
|
ak-guardian helper functions.
|
|
|
|
Functions defined within this module are a part of ak-guardian's internal functionality
|
|
and be considered unstable; their APIs may change in any future releases.
|
|
"""
|
|
|
|
import gc
|
|
import logging
|
|
import time
|
|
from math import ceil
|
|
from typing import Any
|
|
|
|
from django.apps import apps
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.core.cache import cache
|
|
from django.db.models import Model, QuerySet
|
|
|
|
from guardian.conf import settings as guardian_settings
|
|
from guardian.exceptions import InvalidIdentity
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_content_type(obj: Model | type[Model]) -> ContentType:
|
|
return ContentType.objects.get_for_model(obj)
|
|
|
|
|
|
def get_role_obj_perms_model() -> type[Model]:
|
|
from guardian.models import RoleObjectPermission
|
|
|
|
return RoleObjectPermission
|
|
|
|
|
|
def get_role_model_perms_model() -> type[Model]:
|
|
from guardian.models import RoleModelPermission
|
|
|
|
return RoleModelPermission
|
|
|
|
|
|
def get_group_model() -> type[Model]:
|
|
app_name, model_name = guardian_settings.group_model_label.split(".", 1)
|
|
return apps.get_model(app_name, model_name)
|
|
|
|
|
|
def get_role_model() -> type[Model]:
|
|
app_name, model_name = guardian_settings.role_model_label.split(".", 1)
|
|
return apps.get_model(app_name, model_name)
|
|
|
|
|
|
def _get_anonymous_user_cached() -> Any:
|
|
"""Internal cached version of get_anonymous_user using Django's cache system."""
|
|
cache_key = f"guardian:anonymous_user:{guardian_settings.ANONYMOUS_USER_NAME}"
|
|
|
|
# Try to get from cache first
|
|
user = cache.get(cache_key)
|
|
if user is not None:
|
|
return user
|
|
|
|
# If not in cache, get from database and cache it
|
|
user_model = get_user_model()
|
|
lookup = {user_model.USERNAME_FIELD: guardian_settings.ANONYMOUS_USER_NAME} # type: ignore[attr-defined]
|
|
user = user_model.objects.get(**lookup)
|
|
|
|
# Cache with TTL from settings
|
|
# -1 means cache indefinitely (None), positive number is TTL in seconds
|
|
ttl = (
|
|
None
|
|
if guardian_settings.ANONYMOUS_USER_CACHE_TTL == -1
|
|
else guardian_settings.ANONYMOUS_USER_CACHE_TTL
|
|
)
|
|
cache.set(cache_key, user, ttl)
|
|
return user
|
|
|
|
|
|
def _get_anonymous_user_uncached() -> Any:
|
|
"""Internal uncached version of get_anonymous_user."""
|
|
user_model = get_user_model()
|
|
lookup = {user_model.USERNAME_FIELD: guardian_settings.ANONYMOUS_USER_NAME} # type: ignore[attr-defined]
|
|
return user_model.objects.get(**lookup)
|
|
|
|
|
|
def get_anonymous_user() -> Any:
|
|
"""Get the ak-guardian equivalent of the anonymous user.
|
|
|
|
It returns a `User` model instance (not `AnonymousUser`) depending on
|
|
`ANONYMOUS_USER_NAME` configuration.
|
|
|
|
This function can be cached to avoid repetitive database queries based on the
|
|
`GUARDIAN_ANONYMOUS_USER_CACHE_TTL` setting:
|
|
- 0 (default): No caching, each call performs a fresh database query
|
|
- Positive number: Cache for that many seconds
|
|
- -1: Cache indefinitely (not recommended)
|
|
|
|
See Also:
|
|
See the configuration docs that explain that the Guardian anonymous user is
|
|
not equivalent to Django's AnonymousUser.
|
|
|
|
- [Guardian Configuration](https://django-guardian.readthedocs.io/en/stable/configuration.html)
|
|
- [ANONYMOUS_USER_NAME configuration](https://django-guardian.readthedocs.io/en/stable/configuration.html#anonymous-user-nam)
|
|
- [ANONYMOUS_USER_CACHE_TTL configuration](https://django-guardian.readthedocs.io/en/stable/configuration.html#anonymous-user-cache-ttl)
|
|
"""
|
|
if (
|
|
guardian_settings.ANONYMOUS_USER_CACHE_TTL > 0
|
|
or guardian_settings.ANONYMOUS_USER_CACHE_TTL == -1
|
|
):
|
|
return _get_anonymous_user_cached()
|
|
else:
|
|
return _get_anonymous_user_uncached()
|
|
|
|
|
|
def get_identity(identity: Model) -> tuple[Any | None, Any | None, Any | None]:
|
|
"""Get a tuple with the identity of the given input.
|
|
|
|
Returns:
|
|
(user_obj, None, None) or
|
|
(None, group_obj, None) or
|
|
(None, None, role_obj)
|
|
|
|
Parameters:
|
|
identity: User | AnonymousUser | Group | Role
|
|
|
|
Raises:
|
|
InvalidIdentity: If the function cannot return proper identity instance
|
|
"""
|
|
if isinstance(identity, AnonymousUser):
|
|
identity = get_anonymous_user()
|
|
|
|
user_model = get_user_model()
|
|
group_model = get_group_model()
|
|
role_model = get_role_model()
|
|
|
|
# get identity from queryset model type
|
|
if isinstance(identity, QuerySet):
|
|
identity_model_type = identity.model
|
|
if identity_model_type == user_model:
|
|
return identity, None, None
|
|
elif identity_model_type == group_model:
|
|
return None, identity, None
|
|
elif identity_model_type == role_model:
|
|
return None, None, identity
|
|
|
|
# get identity from the first element in the list
|
|
if isinstance(identity, list) and isinstance(identity[0], user_model):
|
|
return identity, None, None
|
|
if isinstance(identity, list) and isinstance(identity[0], group_model):
|
|
return None, identity, None
|
|
if isinstance(identity, list) and isinstance(identity[0], role_model):
|
|
return None, None, identity
|
|
|
|
if isinstance(identity, user_model):
|
|
return identity, None, None
|
|
if isinstance(identity, group_model):
|
|
return None, identity, None
|
|
if isinstance(identity, role_model):
|
|
return None, None, identity
|
|
|
|
raise InvalidIdentity(
|
|
f"User/AnonymousUser or Group or Role instance is required (got {identity})"
|
|
)
|
|
|
|
|
|
def clean_orphan_obj_perms( # noqa: PLR0915
|
|
batch_size: int | None = None,
|
|
max_batches: int | None = None,
|
|
max_duration_secs: int | None = None,
|
|
skip_batches: int = 0,
|
|
) -> int:
|
|
"""
|
|
Removes orphan object permissions using queryset slice-based batching,
|
|
batch skipping, batch limit, and time-based interruption.
|
|
"""
|
|
|
|
RoleObjectPermission = get_role_obj_perms_model()
|
|
|
|
deleted = 0
|
|
scanned = 0
|
|
processed_batches = 0
|
|
batch_count = 0
|
|
start_time = time.monotonic()
|
|
|
|
if batch_size is None:
|
|
all_objs = list(RoleObjectPermission.objects.order_by("pk"))
|
|
|
|
for obj in all_objs:
|
|
if max_duration_secs is not None and (
|
|
time.monotonic() - start_time >= max_duration_secs
|
|
):
|
|
logger.info(f"Time limit of {max_duration_secs}s reached.")
|
|
break
|
|
|
|
scanned += 1
|
|
if obj.content_object is None:
|
|
logger.debug("Removing %s (pk=%d)", obj, obj.pk)
|
|
obj.delete()
|
|
deleted += 1
|
|
processed_batches = 1
|
|
else:
|
|
total_role = RoleObjectPermission.objects.count()
|
|
total_batches_possible = ceil(total_role / batch_size)
|
|
|
|
remaining_batches = total_batches_possible - skip_batches
|
|
if max_batches is not None:
|
|
remaining_batches = min(remaining_batches, max_batches)
|
|
|
|
logger.info(
|
|
f"Starting orphan object permissions cleanup with batch_size={batch_size}, "
|
|
f"max_batches={max_batches}, max_duration_secs={max_duration_secs}, "
|
|
f"skip_batches={skip_batches}"
|
|
)
|
|
|
|
roles_processed = 0
|
|
# Skip batches if needed
|
|
role_skip_records = min(skip_batches * batch_size, total_role)
|
|
roles_remaining = total_role - role_skip_records
|
|
|
|
while roles_remaining > 0 and remaining_batches > 0:
|
|
if max_duration_secs is not None and (
|
|
time.monotonic() - start_time >= max_duration_secs
|
|
):
|
|
logger.info(f"Time limit of {max_duration_secs}s reached.")
|
|
break
|
|
|
|
gc.collect()
|
|
|
|
current_batch_size = min(batch_size, roles_remaining)
|
|
batch = list(
|
|
RoleObjectPermission.objects.order_by("pk")[
|
|
role_skip_records
|
|
+ roles_processed : role_skip_records
|
|
+ roles_processed
|
|
+ current_batch_size
|
|
]
|
|
)
|
|
|
|
if not batch:
|
|
break
|
|
|
|
scanned += len(batch)
|
|
roles_processed += len(batch)
|
|
roles_remaining -= len(batch)
|
|
|
|
orphan_pks = [obj.pk for obj in batch if obj.content_object is None]
|
|
|
|
if orphan_pks:
|
|
logger.info(
|
|
f"!!! Found {len(orphan_pks)} orphan role permissions in batch "
|
|
f"{processed_batches + 1}. !!!"
|
|
)
|
|
RoleObjectPermission.objects.filter(pk__in=orphan_pks).delete()
|
|
deleted += len(orphan_pks)
|
|
|
|
processed_batches += 1
|
|
batch_count += 1
|
|
remaining_batches -= 1
|
|
|
|
logger.info(f"Processed role batch {processed_batches}, scanned {scanned} objects.")
|
|
|
|
logger.info(
|
|
f"Finished orphan object permissions cleanup. "
|
|
f"Scanned: {scanned} | Removed: {deleted} | "
|
|
f"Batches processed: {processed_batches}"
|
|
)
|
|
|
|
if batch_size:
|
|
suggestion = (
|
|
f"To resume cleanup, call:\n"
|
|
f"clean_orphan_obj_perms(batch_size={batch_size}, "
|
|
f"skip_batches={skip_batches + processed_batches}, "
|
|
)
|
|
if max_batches is not None:
|
|
suggestion += f"max_batches={max_batches - batch_count}, "
|
|
if max_duration_secs is not None:
|
|
suggestion += f"max_duration_secs={max_duration_secs}, "
|
|
suggestion = suggestion.rstrip(", ") + ")"
|
|
logger.info(suggestion)
|
|
|
|
return deleted
|