153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
"""event utilities"""
|
|
import re
|
|
from copy import copy
|
|
from dataclasses import asdict, is_dataclass
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from types import GeneratorType
|
|
from typing import Any, Optional
|
|
from uuid import UUID
|
|
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.core.handlers.wsgi import WSGIRequest
|
|
from django.db import models
|
|
from django.db.models.base import Model
|
|
from django.http.request import HttpRequest
|
|
from django.views.debug import SafeExceptionReporterFilter
|
|
from geoip2.models import City
|
|
from guardian.utils import get_anonymous_user
|
|
|
|
from authentik.blueprints.v1.common import YAMLTag
|
|
from authentik.core.models import User
|
|
from authentik.events.geo import GEOIP_READER
|
|
from authentik.policies.types import PolicyRequest
|
|
|
|
# Special keys which are *not* cleaned, even when the default filter
|
|
# is matched
|
|
ALLOWED_SPECIAL_KEYS = re.compile("passing", flags=re.I)
|
|
|
|
|
|
def cleanse_item(key: str, value: Any) -> Any:
|
|
"""Cleanse a single item"""
|
|
if isinstance(value, dict):
|
|
return cleanse_dict(value)
|
|
if isinstance(value, (list, tuple, set)):
|
|
for idx, item in enumerate(value):
|
|
value[idx] = cleanse_item(key, item)
|
|
return value
|
|
try:
|
|
if SafeExceptionReporterFilter.hidden_settings.search(
|
|
key
|
|
) and not ALLOWED_SPECIAL_KEYS.search(key):
|
|
return SafeExceptionReporterFilter.cleansed_substitute
|
|
except TypeError: # pragma: no cover
|
|
return value
|
|
return value
|
|
|
|
|
|
def cleanse_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
|
"""Cleanse a dictionary, recursively"""
|
|
final_dict = {}
|
|
for key, value in source.items():
|
|
new_value = cleanse_item(key, value)
|
|
if new_value is not ...:
|
|
final_dict[key] = new_value
|
|
return final_dict
|
|
|
|
|
|
def model_to_dict(model: Model) -> dict[str, Any]:
|
|
"""Convert model to dict"""
|
|
name = str(model)
|
|
if hasattr(model, "name"):
|
|
name = model.name
|
|
return {
|
|
"app": model._meta.app_label,
|
|
"model_name": model._meta.model_name,
|
|
"pk": model.pk,
|
|
"name": name,
|
|
}
|
|
|
|
|
|
def get_user(user: User, original_user: Optional[User] = None) -> dict[str, Any]:
|
|
"""Convert user object to dictionary, optionally including the original user"""
|
|
if isinstance(user, AnonymousUser):
|
|
user = get_anonymous_user()
|
|
user_data = {
|
|
"username": user.username,
|
|
"pk": user.pk,
|
|
"email": user.email,
|
|
}
|
|
if original_user:
|
|
original_data = get_user(original_user)
|
|
original_data["on_behalf_of"] = user_data
|
|
return original_data
|
|
return user_data
|
|
|
|
|
|
# pylint: disable=too-many-return-statements
|
|
def sanitize_item(value: Any) -> Any:
|
|
"""Sanitize a single item, ensure it is JSON parsable"""
|
|
if is_dataclass(value):
|
|
# Because asdict calls `copy.deepcopy(obj)` on everything that's not tuple/dict,
|
|
# and deepcopy doesn't work with HttpRequest (neither django nor rest_framework).
|
|
# (more specifically doesn't work with ResolverMatch)
|
|
# rest_framework's custom Request class makes this more complicated as it also holds a
|
|
# thread lock.
|
|
# Since this class is mainly used for Events which already hold the http request context
|
|
# we just remove the http_request from the shallow policy request
|
|
# Currently, the only dataclass that actually holds an http request is a PolicyRequest
|
|
if isinstance(value, PolicyRequest) and value.http_request is not None:
|
|
value: PolicyRequest = copy(value)
|
|
value.http_request = None
|
|
value = asdict(value)
|
|
if isinstance(value, dict):
|
|
return sanitize_dict(value)
|
|
if isinstance(value, GeneratorType):
|
|
return sanitize_item(list(value))
|
|
if isinstance(value, (list, tuple, set)):
|
|
new_values = []
|
|
for item in value:
|
|
new_value = sanitize_item(item)
|
|
if new_value:
|
|
new_values.append(new_value)
|
|
return new_values
|
|
if isinstance(value, (User, AnonymousUser)):
|
|
return sanitize_dict(get_user(value))
|
|
if isinstance(value, models.Model):
|
|
return sanitize_dict(model_to_dict(value))
|
|
if isinstance(value, UUID):
|
|
return value.hex
|
|
if isinstance(value, (HttpRequest, WSGIRequest)):
|
|
return ...
|
|
if isinstance(value, City):
|
|
return GEOIP_READER.city_to_dict(value)
|
|
if isinstance(value, Path):
|
|
return str(value)
|
|
if isinstance(value, Exception):
|
|
return str(value)
|
|
if isinstance(value, YAMLTag):
|
|
return str(value)
|
|
if isinstance(value, Enum):
|
|
return value.value
|
|
if isinstance(value, type):
|
|
return {
|
|
"type": value.__name__,
|
|
"module": value.__module__,
|
|
}
|
|
return value
|
|
|
|
|
|
def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
|
"""clean source of all Models that would interfere with the JSONField.
|
|
Models are replaced with a dictionary of {
|
|
app: str,
|
|
name: str,
|
|
pk: Any
|
|
}"""
|
|
final_dict = {}
|
|
for key, value in source.items():
|
|
new_value = sanitize_item(value)
|
|
if new_value is not ...:
|
|
final_dict[key] = new_value
|
|
return final_dict
|