Merge branch 'main' into web/theme-controller-2

* main: (57 commits)
  stages/email: Fix query parameters getting lost in Email links (#5376)
  core/rbac: fix missing field when removing perm, add delete from object page (#7226)
  website/integrations: grafana: add Helm and Terraform config examples (#7121)
  web: bump @types/codemirror from 5.60.11 to 5.60.12 in /web (#7223)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#7224)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#7225)
  website/blogs: blog about sso tax (#7202)
  web: Application wizard v2 with tests (#7004)
  web: bump API Client version (#7220)
  core: bump goauthentik.io/api/v3 from 3.2023083.7 to 3.2023083.8 (#7221)
  providers/radius: TOTP MFA support (#7217)
  web: bump API Client version (#7218)
  stage/deny: add custom message (#7144)
  docs: update full-dev-setup docs (#7205)
  enterprise: bump license usage task frequency (#7215)
  web: bump the storybook group in /web with 5 updates (#7212)
  web: bump the sentry group in /web with 2 updates (#7211)
  Revert "web: Updates to the Context and Tasks libraries from lit. (#7168)"
  web: bump @types/codemirror from 5.60.10 to 5.60.11 in /web (#7209)
  web: bump @types/chart.js from 2.9.38 to 2.9.39 in /web (#7206)
  ...
This commit is contained in:
Ken Sternberg 2023-10-19 08:59:15 -07:00
commit e51b36c614
275 changed files with 16905 additions and 4861 deletions

4
.github/codecov.yml vendored
View file

@ -6,5 +6,5 @@ coverage:
# adjust accordingly based on how flaky your tests are
# this allows a 1% drop from the previous base commit coverage
threshold: 1%
notify:
after_n_builds: 3
comment:
after_n_builds: 3

View file

@ -90,6 +90,7 @@ jobs:
psql:
- 12-alpine
- 15-alpine
- 16-alpine
steps:
- uses: actions/checkout@v4
- name: Setup authentik env

View file

@ -56,14 +56,15 @@ test: ## Run the server tests and produce a coverage report (locally)
coverage report
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
isort authentik $(PY_SOURCES)
black authentik $(PY_SOURCES)
ruff authentik $(PY_SOURCES)
isort $(PY_SOURCES)
black $(PY_SOURCES)
ruff $(PY_SOURCES)
codespell -w $(CODESPELL_ARGS)
lint: ## Lint the python and golang sources
pylint $(PY_SOURCES)
bandit -r $(PY_SOURCES) -x node_modules
./web/node_modules/.bin/pyright $(PY_SOURCES)
pylint $(PY_SOURCES)
golangci-lint run -v
migrate: ## Run the Authentik Django server's migrations

View file

@ -1,7 +1,7 @@
"""Meta API"""
from drf_spectacular.utils import extend_schema
from rest_framework.fields import CharField
from rest_framework.permissions import IsAdminUser
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
@ -21,7 +21,7 @@ class AppSerializer(PassiveSerializer):
class AppsViewSet(ViewSet):
"""Read-only view list all installed apps"""
permission_classes = [IsAdminUser]
permission_classes = [IsAuthenticated]
@extend_schema(responses={200: AppSerializer(many=True)})
def list(self, request: Request) -> Response:
@ -35,7 +35,7 @@ class AppsViewSet(ViewSet):
class ModelViewSet(ViewSet):
"""Read-only view list all installed models"""
permission_classes = [IsAdminUser]
permission_classes = [IsAuthenticated]
@extend_schema(responses={200: AppSerializer(many=True)})
def list(self, request: Request) -> Response:

View file

@ -5,7 +5,7 @@ from django.db.models.functions import ExtractHour
from drf_spectacular.utils import extend_schema, extend_schema_field
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@ -68,7 +68,7 @@ class LoginMetricsSerializer(PassiveSerializer):
class AdministrationMetricsViewSet(APIView):
"""Login Metrics per 1h"""
permission_classes = [IsAdminUser]
permission_classes = [IsAuthenticated]
@extend_schema(responses={200: LoginMetricsSerializer(many=False)})
def get(self, request: Request) -> Response:

View file

@ -8,7 +8,6 @@ from django.utils.timezone import now
from drf_spectacular.utils import extend_schema
from gunicorn import version_info as gunicorn_version
from rest_framework.fields import SerializerMethodField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@ -17,6 +16,7 @@ from authentik.core.api.utils import PassiveSerializer
from authentik.lib.utils.reflection import get_env
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
from authentik.rbac.permissions import HasPermission
class RuntimeDict(TypedDict):
@ -88,7 +88,7 @@ class SystemSerializer(PassiveSerializer):
class SystemView(APIView):
"""Get system information."""
permission_classes = [IsAdminUser]
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
pagination_class = None
filter_backends = []
serializer_class = SystemSerializer

View file

@ -14,14 +14,15 @@ from rest_framework.fields import (
ListField,
SerializerMethodField,
)
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from structlog.stdlib import get_logger
from authentik.api.decorators import permission_required
from authentik.core.api.utils import PassiveSerializer
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
from authentik.rbac.permissions import HasPermission
LOGGER = get_logger()
@ -63,7 +64,7 @@ class TaskSerializer(PassiveSerializer):
class TaskViewSet(ViewSet):
"""Read-only view set that returns all background tasks"""
permission_classes = [IsAdminUser]
permission_classes = [HasPermission("authentik_rbac.view_system_tasks")]
serializer_class = TaskSerializer
@extend_schema(
@ -93,6 +94,7 @@ class TaskViewSet(ViewSet):
tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name)
return Response(TaskSerializer(tasks, many=True).data)
@permission_required(None, ["authentik_rbac.run_system_tasks"])
@extend_schema(
request=OpenApiTypes.NONE,
responses={

View file

@ -2,18 +2,18 @@
from django.conf import settings
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.fields import IntegerField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.rbac.permissions import HasPermission
from authentik.root.celery import CELERY_APP
class WorkerView(APIView):
"""Get currently connected worker count."""
permission_classes = [IsAdminUser]
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
@extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()}))
def get(self, request: Request) -> Response:

View file

@ -7,9 +7,9 @@ from rest_framework.authentication import get_authorization_header
from rest_framework.filters import BaseFilterBackend
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.api.authentication import validate_auth
from authentik.rbac.filters import ObjectFilter
class OwnerFilter(BaseFilterBackend):
@ -26,14 +26,14 @@ class OwnerFilter(BaseFilterBackend):
class SecretKeyFilter(DjangoFilterBackend):
"""Allow access to all objects when authenticated with secret key as token.
Replaces both DjangoFilterBackend and ObjectPermissionsFilter"""
Replaces both DjangoFilterBackend and ObjectFilter"""
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
auth_header = get_authorization_header(request)
token = validate_auth(auth_header)
if token and token == settings.SECRET_KEY:
return queryset
queryset = ObjectPermissionsFilter().filter_queryset(request, queryset, view)
queryset = ObjectFilter().filter_queryset(request, queryset, view)
return super().filter_queryset(request, queryset, view)

View file

@ -10,7 +10,7 @@ from structlog.stdlib import get_logger
LOGGER = get_logger()
def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None):
def permission_required(obj_perm: Optional[str] = None, global_perms: Optional[list[str]] = None):
"""Check permissions for a single custom action"""
def wrapper_outter(func: Callable):
@ -18,15 +18,17 @@ def permission_required(perm: Optional[str] = None, other_perms: Optional[list[s
@wraps(func)
def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response:
if perm:
if obj_perm:
obj = self.get_object()
if not request.user.has_perm(perm, obj):
LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj)
if not request.user.has_perm(obj_perm, obj):
LOGGER.debug(
"denying access for object", user=request.user, perm=obj_perm, obj=obj
)
return self.permission_denied(request)
if other_perms:
for other_perm in other_perms:
if global_perms:
for other_perm in global_perms:
if not request.user.has_perm(other_perm):
LOGGER.debug("denying access for other", user=request.user, perm=perm)
LOGGER.debug("denying access for other", user=request.user, perm=other_perm)
return self.permission_denied(request)
return func(self, request, *args, **kwargs)

View file

@ -77,3 +77,10 @@ class Pagination(pagination.PageNumberPagination):
},
"required": ["pagination", "results"],
}
class SmallerPagination(Pagination):
"""Smaller pagination for objects which might require a lot of queries
to retrieve all data for."""
max_page_size = 10

View file

@ -16,6 +16,7 @@ def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
def tester(self: TestModelViewSets):
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
filterset_class = getattr(test_viewset, "filterset_class", None)
if not filterset_class:
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))

View file

@ -4,7 +4,6 @@ from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField, JSONField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer
@ -87,11 +86,11 @@ class BlueprintInstanceSerializer(ModelSerializer):
class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
"""Blueprint instances"""
permission_classes = [IsAdminUser]
serializer_class = BlueprintInstanceSerializer
queryset = BlueprintInstance.objects.all()
search_fields = ["name", "path"]
filterset_fields = ["name", "path"]
ordering = ["name"]
@extend_schema(
responses={

View file

@ -35,25 +35,28 @@ from authentik.core.models import (
Source,
UserSourceConnection,
)
from authentik.enterprise.models import LicenseUsage
from authentik.events.utils import cleanse_dict
from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.providers.scim.models import SCIMGroup, SCIMUser
# Context set when the serializer is created in a blueprint context
# Update website/developer-docs/blueprints/v1/models.md when used
SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry"
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
def excluded_models() -> list[type[Model]]:
"""Return a list of all excluded models that shouldn't be exposed via API
or other means (internal only, base classes, non-used objects, etc)"""
# pylint: disable=imported-auth-user
from django.contrib.auth.models import Group as DjangoGroup
from django.contrib.auth.models import User as DjangoUser
excluded_models = (
return (
DjangoUser,
DjangoGroup,
# Base classes
@ -69,8 +72,15 @@ def is_model_allowed(model: type[Model]) -> bool:
AuthenticatedSession,
# Classes which are only internally managed
FlowToken,
LicenseUsage,
SCIMGroup,
SCIMUser,
)
return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel))
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
return model not in excluded_models() and issubclass(model, (SerializerModel, BaseMetaModel))
class DoRollback(SentryIgnoredException):

View file

@ -17,7 +17,6 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from structlog.stdlib import get_logger
from structlog.testing import capture_logs
@ -38,6 +37,7 @@ from authentik.lib.utils.file import (
from authentik.policies.api.exec import PolicyTestResultSerializer
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyResult
from authentik.rbac.filters import ObjectFilter
LOGGER = get_logger()
@ -122,7 +122,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):
if backend == ObjectPermissionsFilter:
if backend == ObjectFilter:
continue
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset

View file

@ -2,7 +2,6 @@
from json import loads
from typing import Optional
from django.db.models.query import QuerySet
from django.http import Http404
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet
@ -14,12 +13,12 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.api.decorators import permission_required
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer, is_dict
from authentik.core.models import Group, User
from authentik.rbac.api.roles import RoleSerializer
class GroupMemberSerializer(ModelSerializer):
@ -49,6 +48,12 @@ class GroupSerializer(ModelSerializer):
users_obj = ListSerializer(
child=GroupMemberSerializer(), read_only=True, source="users", required=False
)
roles_obj = ListSerializer(
child=RoleSerializer(),
read_only=True,
source="roles",
required=False,
)
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
num_pk = IntegerField(read_only=True)
@ -71,8 +76,10 @@ class GroupSerializer(ModelSerializer):
"parent",
"parent_name",
"users",
"attributes",
"users_obj",
"attributes",
"roles",
"roles_obj",
]
extra_kwargs = {
"users": {
@ -138,19 +145,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
filterset_class = GroupFilter
ordering = ["name"]
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):
if backend == ObjectPermissionsFilter:
continue
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def filter_queryset(self, queryset):
if self.request.user.has_perm("authentik_core.view_group"):
return self._filter_queryset_for_list(queryset)
return super().filter_queryset(queryset)
@permission_required(None, ["authentik_core.add_user"])
@extend_schema(
request=UserAccountSerializer,

View file

@ -119,6 +119,7 @@ class TransactionApplicationResponseSerializer(PassiveSerializer):
class TransactionalApplicationView(APIView):
"""Create provider and application and attach them in a single transaction"""
# TODO: Migrate to a more specific permission
permission_classes = [IsAdminUser]
@extend_schema(

View file

@ -73,6 +73,11 @@ class UsedByMixin:
# but so we only apply them once, have a simple flag for the first object
first_object = True
# TODO: This will only return the used-by references that the user can see
# Either we have to leak model information here to not make the list
# useless if the user doesn't have all permissions, or we need to double
# query and check if there is a difference between modes the user can see
# and can't see and add a warning
for obj in get_objects_for_user(
request.user, f"{app}.view_{model_name}", manager
).all():

View file

@ -7,7 +7,6 @@ from django.contrib.auth import update_session_auth_hash
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour
from django.db.models.query import QuerySet
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from django.urls import reverse_lazy
@ -52,7 +51,6 @@ from rest_framework.serializers import (
)
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer
@ -205,6 +203,7 @@ class UserSelfSerializer(ModelSerializer):
groups = SerializerMethodField()
uid = CharField(read_only=True)
settings = SerializerMethodField()
system_permissions = SerializerMethodField()
@extend_schema_field(
ListSerializer(
@ -226,6 +225,14 @@ class UserSelfSerializer(ModelSerializer):
"""Get user settings with tenant and group settings applied"""
return user.group_attributes(self._context["request"]).get("settings", {})
def get_system_permissions(self, user: User) -> list[str]:
"""Get all system permissions assigned to the user"""
return list(
user.user_permissions.filter(
content_type__app_label="authentik_rbac", content_type__model="systempermission"
).values_list("codename", flat=True)
)
class Meta:
model = User
fields = [
@ -240,6 +247,7 @@ class UserSelfSerializer(ModelSerializer):
"uid",
"settings",
"type",
"system_permissions",
]
extra_kwargs = {
"is_active": {"read_only": True},
@ -654,19 +662,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
return Response(status=204)
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):
if backend == ObjectPermissionsFilter:
continue
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def filter_queryset(self, queryset):
if self.request.user.has_perm("authentik_core.view_user"):
return self._filter_queryset_for_list(queryset)
return super().filter_queryset(queryset)
@extend_schema(
responses={
200: inline_serializer(

View file

@ -0,0 +1,45 @@
# Generated by Django 4.2.6 on 2023-10-11 13:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0031_alter_user_type"),
("authentik_rbac", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="group",
options={"verbose_name": "Group", "verbose_name_plural": "Groups"},
),
migrations.AlterModelOptions(
name="token",
options={
"permissions": [("view_token_key", "View token's key")],
"verbose_name": "Token",
"verbose_name_plural": "Tokens",
},
),
migrations.AlterModelOptions(
name="user",
options={
"permissions": [
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
("assign_user_permissions", "Can assign permissions to users"),
("unassign_user_permissions", "Can unassign permissions from users"),
],
"verbose_name": "User",
"verbose_name_plural": "Users",
},
),
migrations.AddField(
model_name="group",
name="roles",
field=models.ManyToManyField(
blank=True, related_name="ak_groups", to="authentik_rbac.role"
),
),
]

View file

@ -1,7 +1,7 @@
"""authentik core models"""
from datetime import timedelta
from hashlib import sha256
from typing import Any, Optional
from typing import Any, Optional, Self
from uuid import uuid4
from deepmerge import always_merger
@ -88,6 +88,8 @@ class Group(SerializerModel):
default=False, help_text=_("Users added to this group will be superusers.")
)
roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True)
parent = models.ForeignKey(
"Group",
blank=True,
@ -115,6 +117,38 @@ class Group(SerializerModel):
"""Recursively check if `user` is member of us, or any parent."""
return user.all_groups().filter(group_uuid=self.group_uuid).exists()
def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]:
"""Recursively get all groups that have this as parent or are indirectly related"""
direct_groups = []
if isinstance(self, QuerySet):
direct_groups = list(x for x in self.all().values_list("pk", flat=True).iterator())
else:
direct_groups = [self.pk]
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 = ANY(%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 __str__(self):
return f"Group {self.name}"
@ -125,6 +159,8 @@ class Group(SerializerModel):
"parent",
),
)
verbose_name = _("Group")
verbose_name_plural = _("Groups")
class UserManager(DjangoUserManager):
@ -160,33 +196,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
"""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 = list(
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 = ANY(%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)
return Group.children_recursive(self.ak_groups.all())
def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to,
@ -261,12 +271,14 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
return get_avatar(self)
class Meta:
permissions = (
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
)
verbose_name = _("User")
verbose_name_plural = _("Users")
permissions = [
("reset_user_password", _("Reset Password")),
("impersonate", _("Can impersonate other users")),
("assign_user_permissions", _("Can assign permissions to users")),
("unassign_user_permissions", _("Can unassign permissions from users")),
]
class Provider(SerializerModel):
@ -675,7 +687,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
models.Index(fields=["identifier"]),
models.Index(fields=["key"]),
]
permissions = (("view_token_key", "View token's key"),)
permissions = [("view_token_key", _("View token's key"))]
class PropertyMapping(SerializerModel, ManagedModel):

View file

@ -7,6 +7,7 @@ from django.db.models import Model
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from django.http.request import HttpRequest
from structlog.stdlib import get_logger
from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider, User
@ -15,6 +16,8 @@ password_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
login_failed = Signal()
LOGGER = get_logger()
@receiver(post_save, sender=Application)
def post_save_application(sender: type[Model], instance, created: bool, **_):

View file

@ -21,10 +21,9 @@ def create_test_flow(
)
def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
"""Generate a test-admin user"""
def create_test_user(name: Optional[str] = None, **kwargs) -> User:
"""Generate a test user"""
uid = generate_id(20) if not name else name
group = Group.objects.create(name=uid, is_superuser=True)
kwargs.setdefault("email", f"{uid}@goauthentik.io")
kwargs.setdefault("username", uid)
user: User = User.objects.create(
@ -33,6 +32,13 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
)
user.set_password(uid)
user.save()
return user
def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
"""Generate a test-admin user"""
user = create_test_user(name, **kwargs)
group = Group.objects.create(name=user.name or name, is_superuser=True)
group.users.add(user)
return user

View file

@ -6,7 +6,7 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.decorators import action
from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
@ -84,7 +84,7 @@ class LicenseViewSet(UsedByMixin, ModelViewSet):
200: inline_serializer("InstallIDSerializer", {"install_id": CharField(required=True)}),
},
)
@action(detail=False, methods=["GET"], permission_classes=[IsAdminUser])
@action(detail=False, methods=["GET"])
def get_install_id(self, request: Request) -> Response:
"""Get install_id"""
return Response(

View file

@ -33,4 +33,8 @@ class Migration(migrations.Migration):
"verbose_name_plural": "License Usage Records",
},
),
migrations.AlterModelOptions(
name="license",
options={"verbose_name": "License", "verbose_name_plural": "Licenses"},
),
]

View file

@ -19,8 +19,10 @@ from django.utils.translation import gettext as _
from guardian.shortcuts import get_anonymous_user
from jwt import PyJWTError, decode, get_unverified_header
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer
from authentik.core.models import ExpiringModel, User, UserTypes
from authentik.lib.models import SerializerModel
from authentik.root.install_id import get_install_id
@ -134,6 +136,9 @@ class LicenseKey:
def record_usage(self):
"""Capture the current validity status and metrics and save them"""
threshold = now() - timedelta(hours=8)
if LicenseUsage.objects.filter(record_date__gte=threshold).exists():
return
LicenseUsage.objects.create(
user_count=self.get_default_user_count(),
external_user_count=self.get_external_user_count(),
@ -151,7 +156,7 @@ class LicenseKey:
return usage.record_date
class License(models.Model):
class License(SerializerModel):
"""An authentik enterprise license"""
license_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -162,6 +167,12 @@ class License(models.Model):
internal_users = models.BigIntegerField()
external_users = models.BigIntegerField()
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.api import LicenseSerializer
return LicenseSerializer
@property
def status(self) -> LicenseKey:
"""Get parsed license status"""
@ -169,6 +180,8 @@ class License(models.Model):
class Meta:
indexes = (HashIndex(fields=("key",)),)
verbose_name = _("License")
verbose_name_plural = _("Licenses")
def usage_expiry():

View file

@ -6,7 +6,7 @@ from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"enterprise_calculate_license": {
"task": "authentik.enterprise.tasks.calculate_license",
"schedule": crontab(minute=fqdn_rand("calculate_license"), hour="*/8"),
"schedule": crontab(minute=fqdn_rand("calculate_license"), hour="*/2"),
"options": {"queue": "authentik_scheduled"},
}
}

View file

@ -6,5 +6,4 @@ from authentik.root.celery import CELERY_APP
@CELERY_APP.task()
def calculate_license():
"""Calculate licensing status"""
total = LicenseKey.get_total()
total.record_usage()
LicenseKey.get_total().record_usage()

View file

@ -45,3 +45,4 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet):
serializer_class = FlowStageBindingSerializer
filterset_fields = "__all__"
search_fields = ["stage__name"]
ordering = ["order"]

View file

@ -132,13 +132,6 @@ class PermissionDict(TypedDict):
name: str
class PermissionSerializer(PassiveSerializer):
"""Permission used for consent"""
name = CharField(allow_blank=True)
id = CharField()
class ChallengeResponse(PassiveSerializer):
"""Base class for all challenge responses"""

View file

@ -0,0 +1,25 @@
# Generated by Django 4.2.6 on 2023-10-10 17:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="flow",
options={
"permissions": [
("export_flow", "Can export a Flow"),
("inspect_flow", "Can inspect a Flow's execution"),
("view_flow_cache", "View Flow's cache metrics"),
("clear_flow_cache", "Clear Flow's cache metrics"),
],
"verbose_name": "Flow",
"verbose_name_plural": "Flows",
},
),
]

View file

@ -194,9 +194,10 @@ class Flow(SerializerModel, PolicyBindingModel):
verbose_name_plural = _("Flows")
permissions = [
("export_flow", "Can export a Flow"),
("view_flow_cache", "View Flow's cache metrics"),
("clear_flow_cache", "Clear Flow's cache metrics"),
("export_flow", _("Can export a Flow")),
("inspect_flow", _("Can inspect a Flow's execution")),
("view_flow_cache", _("View Flow's cache metrics")),
("clear_flow_cache", _("Clear Flow's cache metrics")),
]

View file

@ -3,6 +3,7 @@ from hashlib import sha256
from typing import Any
from django.conf import settings
from django.http import Http404
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404
@ -11,7 +12,6 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.fields import BooleanField, ListField, SerializerMethodField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@ -68,21 +68,19 @@ class FlowInspectionSerializer(PassiveSerializer):
class FlowInspectorView(APIView):
"""Flow inspector API"""
permission_classes = [IsAdminUser]
flow: Flow
_logger: BoundLogger
def check_permissions(self, request):
"""Always allow access when in debug mode"""
if settings.DEBUG:
return None
return super().check_permissions(request)
permission_classes = []
def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug)
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
self._logger = get_logger().bind(flow_slug=flow_slug)
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
if settings.DEBUG:
return
if request.user.has_perm("authentik_flow.inspect_flow", self.flow):
return
raise Http404
@extend_schema(
responses={

View file

@ -0,0 +1,32 @@
"""Serializer validators"""
from typing import Optional
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import Serializer
from rest_framework.utils.representation import smart_repr
class RequiredTogetherValidator:
"""Serializer-level validator that ensures all fields in `fields` are only
used together"""
fields: list[str]
requires_context = True
message = _("The fields {field_names} must be used together.")
def __init__(self, fields: list[str], message: Optional[str] = None) -> None:
self.fields = fields
self.message = message or self.message
def __call__(self, attrs: dict, serializer: Serializer):
"""Check that if any of the fields in `self.fields` are set, all of them must be set"""
if any(field in attrs for field in self.fields) and not all(
field in attrs for field in self.fields
):
field_names = ", ".join(self.fields)
message = self.message.format(field_names=field_names)
raise ValidationError(message, code="required")
def __repr__(self):
return "<%s(fields=%s)>" % (self.__class__.__name__, smart_repr(self.fields))

View file

@ -4,6 +4,7 @@ from datetime import datetime
from enum import IntEnum
from typing import Any, Optional
from asgiref.sync import async_to_sync
from channels.exceptions import DenyConnection
from dacite.core import from_dict
from dacite.data import Data
@ -14,6 +15,8 @@ from authentik.core.channels import AuthJsonConsumer
from authentik.outposts.apps import GAUGE_OUTPOSTS_CONNECTED, GAUGE_OUTPOSTS_LAST_UPDATE
from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState
OUTPOST_GROUP = "group_outpost_%(outpost_pk)s"
class WebsocketMessageInstruction(IntEnum):
"""Commands which can be triggered over Websocket"""
@ -47,8 +50,6 @@ class OutpostConsumer(AuthJsonConsumer):
last_uid: Optional[str] = None
first_msg = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logger = get_logger()
@ -71,22 +72,26 @@ class OutpostConsumer(AuthJsonConsumer):
raise DenyConnection()
self.outpost = outpost
self.last_uid = self.channel_name
async_to_sync(self.channel_layer.group_add)(
OUTPOST_GROUP % {"outpost_pk": str(self.outpost.pk)}, self.channel_name
)
GAUGE_OUTPOSTS_CONNECTED.labels(
outpost=self.outpost.name,
uid=self.last_uid,
expected=self.outpost.config.kubernetes_replicas,
).inc()
def disconnect(self, code):
if self.outpost:
async_to_sync(self.channel_layer.group_discard)(
OUTPOST_GROUP % {"outpost_pk": str(self.outpost.pk)}, self.channel_name
)
if self.outpost and self.last_uid:
state = OutpostState.for_instance_uid(self.outpost, self.last_uid)
if self.channel_name in state.channel_ids:
state.channel_ids.remove(self.channel_name)
state.save()
GAUGE_OUTPOSTS_CONNECTED.labels(
outpost=self.outpost.name,
uid=self.last_uid,
expected=self.outpost.config.kubernetes_replicas,
).dec()
self.logger.debug(
"removed outpost instance from cache",
instance_uuid=self.last_uid,
)
def receive_json(self, content: Data):
msg = from_dict(WebsocketMessage, content)
@ -97,26 +102,13 @@ class OutpostConsumer(AuthJsonConsumer):
raise DenyConnection()
state = OutpostState.for_instance_uid(self.outpost, uid)
if self.channel_name not in state.channel_ids:
state.channel_ids.append(self.channel_name)
state.last_seen = datetime.now()
state.hostname = msg.args.get("hostname", "")
if not self.first_msg:
GAUGE_OUTPOSTS_CONNECTED.labels(
outpost=self.outpost.name,
uid=self.last_uid,
expected=self.outpost.config.kubernetes_replicas,
).inc()
self.logger.debug(
"added outpost instance to cache",
instance_uuid=self.last_uid,
)
self.first_msg = True
state.hostname = msg.args.pop("hostname", "")
if msg.instruction == WebsocketMessageInstruction.HELLO:
state.version = msg.args.get("version", None)
state.build_hash = msg.args.get("buildHash", "")
state.version = msg.args.pop("version", None)
state.build_hash = msg.args.pop("buildHash", "")
state.args = msg.args
elif msg.instruction == WebsocketMessageInstruction.ACK:
return
GAUGE_OUTPOSTS_LAST_UPDATE.labels(

View file

@ -28,4 +28,8 @@ class Migration(migrations.Migration):
verbose_name="Managed by authentik",
),
),
migrations.AlterModelOptions(
name="outpost",
options={"verbose_name": "Outpost", "verbose_name_plural": "Outposts"},
),
]

View file

@ -405,18 +405,22 @@ class Outpost(SerializerModel, ManagedModel):
def __str__(self) -> str:
return f"Outpost {self.name}"
class Meta:
verbose_name = _("Outpost")
verbose_name_plural = _("Outposts")
@dataclass
class OutpostState:
"""Outpost instance state, last_seen and version"""
uid: str
channel_ids: list[str] = field(default_factory=list)
last_seen: Optional[datetime] = field(default=None)
version: Optional[str] = field(default=None)
version_should: Version = field(default=OUR_VERSION)
build_hash: str = field(default="")
hostname: str = field(default="")
args: dict = field(default_factory=dict)
_outpost: Optional[Outpost] = field(default=None)

View file

@ -25,6 +25,7 @@ from authentik.events.monitored_tasks import (
)
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import path_to_class
from authentik.outposts.consumer import OUTPOST_GROUP
from authentik.outposts.controllers.base import BaseController, ControllerException
from authentik.outposts.controllers.docker import DockerClient
from authentik.outposts.controllers.kubernetes import KubernetesClient
@ -34,7 +35,6 @@ from authentik.outposts.models import (
Outpost,
OutpostModel,
OutpostServiceConnection,
OutpostState,
OutpostType,
ServiceConnectionInvalid,
)
@ -243,10 +243,9 @@ def _outpost_single_update(outpost: Outpost, layer=None):
outpost.build_user_permissions(outpost.user)
if not layer: # pragma: no cover
layer = get_channel_layer()
for state in OutpostState.for_outpost(outpost):
for channel in state.channel_ids:
LOGGER.debug("sending update", channel=channel, instance=state.uid, outpost=outpost)
async_to_sync(layer.send)(channel, {"type": "event.update"})
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
LOGGER.debug("sending update", channel=group, outpost=outpost)
async_to_sync(layer.group_send)(group, {"type": "event.update"})
@CELERY_APP.task(

View file

@ -7,7 +7,7 @@ from django.test import TransactionTestCase
from authentik import __version__
from authentik.core.tests.utils import create_test_flow
from authentik.outposts.channels import WebsocketMessage, WebsocketMessageInstruction
from authentik.outposts.consumer import WebsocketMessage, WebsocketMessageInstruction
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.proxy.models import ProxyProvider
from authentik.root import websocket

View file

@ -7,7 +7,7 @@ from authentik.outposts.api.service_connections import (
KubernetesServiceConnectionViewSet,
ServiceConnectionViewSet,
)
from authentik.outposts.channels import OutpostConsumer
from authentik.outposts.consumer import OutpostConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware
websocket_urlpatterns = [

View file

@ -190,8 +190,8 @@ class Policy(SerializerModel, CreatedUpdatedModel):
verbose_name_plural = _("Policies")
permissions = [
("view_policy_cache", "View Policy's cache metrics"),
("clear_policy_cache", "Clear Policy's cache metrics"),
("view_policy_cache", _("View Policy's cache metrics")),
("clear_policy_cache", _("Clear Policy's cache metrics")),
]
class PolicyMeta:

View file

@ -3,7 +3,8 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import DatabaseError, InternalError, ProgrammingError
from authentik.outposts.models import Outpost, OutpostState, OutpostType
from authentik.outposts.consumer import OUTPOST_GROUP
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.proxy.models import ProxyProvider
from authentik.root.celery import CELERY_APP
@ -23,13 +24,12 @@ def proxy_on_logout(session_id: str):
"""Update outpost instances connected to a single outpost"""
layer = get_channel_layer()
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
for state in OutpostState.for_outpost(outpost):
for channel in state.channel_ids:
async_to_sync(layer.send)(
channel,
{
"type": "event.provider.specific",
"sub_type": "logout",
"session_id": session_id,
},
)
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
async_to_sync(layer.group_send)(
group,
{
"type": "event.provider.specific",
"sub_type": "logout",
"session_id": session_id,
},
)

View file

@ -21,6 +21,7 @@ class RadiusProviderSerializer(ProviderSerializer):
# an admin might have to view it
"shared_secret",
"outpost_set",
"mfa_support",
]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
@ -55,6 +56,7 @@ class RadiusOutpostConfigSerializer(ModelSerializer):
"auth_flow_slug",
"client_networks",
"shared_secret",
"mfa_support",
]

View file

@ -0,0 +1,21 @@
# Generated by Django 4.2.6 on 2023-10-18 15:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_radius", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="radiusprovider",
name="mfa_support",
field=models.BooleanField(
default=True,
help_text="When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.",
verbose_name="MFA Support",
),
),
]

View file

@ -27,6 +27,17 @@ class RadiusProvider(OutpostModel, Provider):
),
)
mfa_support = models.BooleanField(
default=True,
verbose_name="MFA Support",
help_text=_(
"When enabled, code-based multi-factor authentication can be used by appending a "
"semicolon and the TOTP code to the password. This should only be enabled if all "
"users that will bind to this provider have a TOTP device configured, as otherwise "
"a password may incorrectly be rejected if it contains a semicolon."
),
)
@property
def launch_url(self) -> Optional[str]:
"""Radius never has a launch URL"""

View file

View file

130
authentik/rbac/api/rbac.py Normal file
View file

@ -0,0 +1,130 @@
"""common RBAC serializers"""
from django.apps import apps
from django.contrib.auth.models import Permission
from django.db.models import QuerySet
from django_filters.filters import ModelChoiceFilter
from django_filters.filterset import FilterSet
from rest_framework.exceptions import ValidationError
from rest_framework.fields import (
CharField,
ChoiceField,
ListField,
ReadOnlyField,
SerializerMethodField,
)
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User
from authentik.lib.validators import RequiredTogetherValidator
from authentik.policies.event_matcher.models import model_choices
from authentik.rbac.models import Role
class PermissionSerializer(ModelSerializer):
"""Global permission"""
app_label = ReadOnlyField(source="content_type.app_label")
app_label_verbose = SerializerMethodField()
model = ReadOnlyField(source="content_type.model")
model_verbose = SerializerMethodField()
def get_app_label_verbose(self, instance: Permission) -> str:
"""Human-readable app label"""
return apps.get_app_config(instance.content_type.app_label).verbose_name
def get_model_verbose(self, instance: Permission) -> str:
"""Human-readable model name"""
return apps.get_model(
instance.content_type.app_label, instance.content_type.model
)._meta.verbose_name
class Meta:
model = Permission
fields = [
"id",
"name",
"codename",
"model",
"app_label",
"app_label_verbose",
"model_verbose",
]
class PermissionFilter(FilterSet):
"""Filter permissions"""
role = ModelChoiceFilter(queryset=Role.objects.all(), method="filter_role")
user = ModelChoiceFilter(queryset=User.objects.all())
def filter_role(self, queryset: QuerySet, name, value: Role) -> QuerySet:
"""Filter permissions based on role"""
return queryset.filter(group__role=value)
class Meta:
model = Permission
fields = [
"codename",
"content_type__model",
"content_type__app_label",
"role",
"user",
]
class RBACPermissionViewSet(ReadOnlyModelViewSet):
"""Read-only list of all permissions, filterable by model and app"""
queryset = Permission.objects.none()
serializer_class = PermissionSerializer
ordering = ["name"]
filterset_class = PermissionFilter
search_fields = [
"codename",
"content_type__model",
"content_type__app_label",
]
def get_queryset(self) -> QuerySet:
return (
Permission.objects.all()
.select_related("content_type")
.filter(
content_type__app_label__startswith="authentik",
)
)
class PermissionAssignSerializer(PassiveSerializer):
"""Request to assign a new permission"""
permissions = ListField(child=CharField())
model = ChoiceField(choices=model_choices(), required=False)
object_pk = CharField(required=False)
validators = [RequiredTogetherValidator(fields=["model", "object_pk"])]
def validate(self, attrs: dict) -> dict:
model_instance = None
# Check if we're setting an object-level perm or global
model = attrs.get("model")
object_pk = attrs.get("object_pk")
if model and object_pk:
model = apps.get_model(attrs["model"])
model_instance = model.objects.filter(pk=attrs["object_pk"]).first()
attrs["model_instance"] = model_instance
if attrs.get("model"):
return attrs
permissions = attrs.get("permissions", [])
if not all("." in perm for perm in permissions):
raise ValidationError(
{
"permissions": (
"When assigning global permissions, codename must be given as "
"app_label.codename"
)
}
)
return attrs

View file

@ -0,0 +1,123 @@
"""common RBAC serializers"""
from django.db.models import Q, QuerySet
from django.db.transaction import atomic
from django_filters.filters import CharFilter, ChoiceFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.models import GroupObjectPermission
from guardian.shortcuts import assign_perm, remove_perm
from rest_framework.decorators import action
from rest_framework.fields import CharField, ReadOnlyField
from rest_framework.mixins import ListModelMixin
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet
from authentik.api.decorators import permission_required
from authentik.core.api.utils import PassiveSerializer
from authentik.policies.event_matcher.models import model_choices
from authentik.rbac.api.rbac import PermissionAssignSerializer
from authentik.rbac.models import Role
class RoleObjectPermissionSerializer(ModelSerializer):
"""Role-bound object level permission"""
app_label = ReadOnlyField(source="content_type.app_label")
model = ReadOnlyField(source="content_type.model")
codename = ReadOnlyField(source="permission.codename")
name = ReadOnlyField(source="permission.name")
object_pk = ReadOnlyField()
class Meta:
model = GroupObjectPermission
fields = ["id", "codename", "model", "app_label", "object_pk", "name"]
class RoleAssignedObjectPermissionSerializer(PassiveSerializer):
"""Roles assigned object permission serializer"""
role_pk = CharField(source="group.role.pk", read_only=True)
name = CharField(source="group.name", read_only=True)
permissions = RoleObjectPermissionSerializer(
many=True, source="group.groupobjectpermission_set"
)
class Meta:
model = Role
fields = ["role_pk", "name", "permissions"]
class RoleAssignedPermissionFilter(FilterSet):
"""Role Assigned permission filter"""
model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True)
object_pk = CharFilter(method="filter_object_pk")
def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet:
"""Filter by object type"""
app, _, model = value.partition(".")
return queryset.filter(
Q(
group__permissions__content_type__app_label=app,
group__permissions__content_type__model=model,
)
| Q(
group__groupobjectpermission__permission__content_type__app_label=app,
group__groupobjectpermission__permission__content_type__model=model,
)
).distinct()
def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet:
"""Filter by object primary key"""
return queryset.filter(Q(group__groupobjectpermission__object_pk=value)).distinct()
class RoleAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
"""Get assigned object permissions for a single object"""
serializer_class = RoleAssignedObjectPermissionSerializer
ordering = ["name"]
# The filtering is done in the filterset,
# which has a required filter that does the heavy lifting
queryset = Role.objects.all()
filterset_class = RoleAssignedPermissionFilter
@permission_required("authentik_rbac.assign_role_permissions")
@extend_schema(
request=PermissionAssignSerializer(),
responses={
204: OpenApiResponse(description="Successfully assigned"),
},
)
@action(methods=["POST"], detail=True, pagination_class=None, filter_backends=[])
def assign(self, request: Request, *args, **kwargs) -> Response:
"""Assign permission(s) to role. When `object_pk` is set, the permissions
are only assigned to the specific object, otherwise they are assigned globally."""
role: Role = self.get_object()
data = PermissionAssignSerializer(data=request.data)
data.is_valid(raise_exception=True)
with atomic():
for perm in data.validated_data["permissions"]:
assign_perm(perm, role.group, data.validated_data["model_instance"])
return Response(status=204)
@permission_required("authentik_rbac.unassign_role_permissions")
@extend_schema(
request=PermissionAssignSerializer(),
responses={
204: OpenApiResponse(description="Successfully unassigned"),
},
)
@action(methods=["PATCH"], detail=True, pagination_class=None, filter_backends=[])
def unassign(self, request: Request, *args, **kwargs) -> Response:
"""Unassign permission(s) to role. When `object_pk` is set, the permissions
are only assigned to the specific object, otherwise they are assigned globally."""
role: Role = self.get_object()
data = PermissionAssignSerializer(data=request.data)
data.is_valid(raise_exception=True)
with atomic():
for perm in data.validated_data["permissions"]:
remove_perm(perm, role.group, data.validated_data["model_instance"])
return Response(status=204)

View file

@ -0,0 +1,129 @@
"""common RBAC serializers"""
from django.db.models import Q, QuerySet
from django.db.transaction import atomic
from django_filters.filters import CharFilter, ChoiceFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm, remove_perm
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, ReadOnlyField
from rest_framework.mixins import ListModelMixin
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet
from authentik.api.decorators import permission_required
from authentik.core.api.groups import GroupMemberSerializer
from authentik.core.models import User, UserTypes
from authentik.policies.event_matcher.models import model_choices
from authentik.rbac.api.rbac import PermissionAssignSerializer
class UserObjectPermissionSerializer(ModelSerializer):
"""User-bound object level permission"""
app_label = ReadOnlyField(source="content_type.app_label")
model = ReadOnlyField(source="content_type.model")
codename = ReadOnlyField(source="permission.codename")
name = ReadOnlyField(source="permission.name")
object_pk = ReadOnlyField()
class Meta:
model = UserObjectPermission
fields = ["id", "codename", "model", "app_label", "object_pk", "name"]
class UserAssignedObjectPermissionSerializer(GroupMemberSerializer):
"""Users assigned object permission serializer"""
permissions = UserObjectPermissionSerializer(many=True, source="userobjectpermission_set")
is_superuser = BooleanField()
class Meta:
model = GroupMemberSerializer.Meta.model
fields = GroupMemberSerializer.Meta.fields + ["permissions", "is_superuser"]
class UserAssignedPermissionFilter(FilterSet):
"""Assigned permission filter"""
model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True)
object_pk = CharFilter(method="filter_object_pk")
def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet:
"""Filter by object type"""
app, _, model = value.partition(".")
return queryset.filter(
Q(
user_permissions__content_type__app_label=app,
user_permissions__content_type__model=model,
)
| Q(
userobjectpermission__permission__content_type__app_label=app,
userobjectpermission__permission__content_type__model=model,
)
| Q(ak_groups__is_superuser=True)
).distinct()
def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet:
"""Filter by object primary key"""
return queryset.filter(
Q(userobjectpermission__object_pk=value) | Q(ak_groups__is_superuser=True),
).distinct()
class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
"""Get assigned object permissions for a single object"""
serializer_class = UserAssignedObjectPermissionSerializer
ordering = ["username"]
# The filtering is done in the filterset,
# which has a required filter that does the heavy lifting
queryset = User.objects.all()
filterset_class = UserAssignedPermissionFilter
@permission_required("authentik_core.assign_user_permissions")
@extend_schema(
request=PermissionAssignSerializer(),
responses={
204: OpenApiResponse(description="Successfully assigned"),
},
)
@action(methods=["POST"], detail=True, pagination_class=None, filter_backends=[])
def assign(self, request: Request, *args, **kwargs) -> Response:
"""Assign permission(s) to user"""
user: User = self.get_object()
if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
raise ValidationError("Permissions cannot be assigned to an internal service account.")
data = PermissionAssignSerializer(data=request.data)
data.is_valid(raise_exception=True)
with atomic():
for perm in data.validated_data["permissions"]:
assign_perm(perm, user, data.validated_data["model_instance"])
return Response(status=204)
@permission_required("authentik_core.unassign_user_permissions")
@extend_schema(
request=PermissionAssignSerializer(),
responses={
204: OpenApiResponse(description="Successfully unassigned"),
},
)
@action(methods=["PATCH"], detail=True, pagination_class=None, filter_backends=[])
def unassign(self, request: Request, *args, **kwargs) -> Response:
"""Unassign permission(s) to user. When `object_pk` is set, the permissions
are only assigned to the specific object, otherwise they are assigned globally."""
user: User = self.get_object()
if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
raise ValidationError(
"Permissions cannot be unassigned from an internal service account."
)
data = PermissionAssignSerializer(data=request.data)
data.is_valid(raise_exception=True)
with atomic():
for perm in data.validated_data["permissions"]:
remove_perm(perm, user, data.validated_data["model_instance"])
return Response(status=204)

View file

@ -0,0 +1,71 @@
"""common RBAC serializers"""
from typing import Optional
from django.apps import apps
from django_filters.filters import UUIDFilter
from django_filters.filterset import FilterSet
from guardian.models import GroupObjectPermission
from guardian.shortcuts import get_objects_for_group
from rest_framework.fields import SerializerMethodField
from rest_framework.mixins import ListModelMixin
from rest_framework.viewsets import GenericViewSet
from authentik.api.pagination import SmallerPagination
from authentik.rbac.api.rbac_assigned_by_roles import RoleObjectPermissionSerializer
class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer):
"""User permission with additional object-related data"""
app_label_verbose = SerializerMethodField()
model_verbose = SerializerMethodField()
object_description = SerializerMethodField()
def get_app_label_verbose(self, instance: GroupObjectPermission) -> str:
"""Get app label from permission's model"""
return apps.get_app_config(instance.content_type.app_label).verbose_name
def get_model_verbose(self, instance: GroupObjectPermission) -> str:
"""Get model label from permission's model"""
return apps.get_model(
instance.content_type.app_label, instance.content_type.model
)._meta.verbose_name
def get_object_description(self, instance: GroupObjectPermission) -> Optional[str]:
"""Get model description from attached model. This operation takes at least
one additional query, and the description is only shown if the user/role has the
view_ permission on the object"""
app_label = instance.content_type.app_label
model = instance.content_type.model
model_class = apps.get_model(app_label, model)
objects = get_objects_for_group(instance.group, f"{app_label}.view_{model}", model_class)
obj = objects.first()
if not obj:
return None
return str(obj)
class Meta(RoleObjectPermissionSerializer.Meta):
fields = RoleObjectPermissionSerializer.Meta.fields + [
"app_label_verbose",
"model_verbose",
"object_description",
]
class RolePermissionFilter(FilterSet):
"""Role permission filter"""
uuid = UUIDFilter("group__role__uuid", required=True)
class RolePermissionViewSet(ListModelMixin, GenericViewSet):
"""Get a role's assigned object permissions"""
serializer_class = ExtraRoleObjectPermissionSerializer
ordering = ["group__role__name"]
pagination_class = SmallerPagination
# The filtering is done in the filterset,
# which has a required filter that does the heavy lifting
queryset = GroupObjectPermission.objects.select_related("content_type", "group__role").all()
filterset_class = RolePermissionFilter

View file

@ -0,0 +1,71 @@
"""common RBAC serializers"""
from typing import Optional
from django.apps import apps
from django_filters.filters import NumberFilter
from django_filters.filterset import FilterSet
from guardian.models import UserObjectPermission
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import SerializerMethodField
from rest_framework.mixins import ListModelMixin
from rest_framework.viewsets import GenericViewSet
from authentik.api.pagination import SmallerPagination
from authentik.rbac.api.rbac_assigned_by_users import UserObjectPermissionSerializer
class ExtraUserObjectPermissionSerializer(UserObjectPermissionSerializer):
"""User permission with additional object-related data"""
app_label_verbose = SerializerMethodField()
model_verbose = SerializerMethodField()
object_description = SerializerMethodField()
def get_app_label_verbose(self, instance: UserObjectPermission) -> str:
"""Get app label from permission's model"""
return apps.get_app_config(instance.content_type.app_label).verbose_name
def get_model_verbose(self, instance: UserObjectPermission) -> str:
"""Get model label from permission's model"""
return apps.get_model(
instance.content_type.app_label, instance.content_type.model
)._meta.verbose_name
def get_object_description(self, instance: UserObjectPermission) -> Optional[str]:
"""Get model description from attached model. This operation takes at least
one additional query, and the description is only shown if the user/role has the
view_ permission on the object"""
app_label = instance.content_type.app_label
model = instance.content_type.model
model_class = apps.get_model(app_label, model)
objects = get_objects_for_user(instance.user, f"{app_label}.view_{model}", model_class)
obj = objects.first()
if not obj:
return None
return str(obj)
class Meta(UserObjectPermissionSerializer.Meta):
fields = UserObjectPermissionSerializer.Meta.fields + [
"app_label_verbose",
"model_verbose",
"object_description",
]
class UserPermissionFilter(FilterSet):
"""User-assigned permission filter"""
user_id = NumberFilter("user__id", required=True)
class UserPermissionViewSet(ListModelMixin, GenericViewSet):
"""Get a users's assigned object permissions"""
serializer_class = ExtraUserObjectPermissionSerializer
ordering = ["user__username"]
pagination_class = SmallerPagination
# The filtering is done in the filterset,
# which has a required filter that does the heavy lifting
queryset = UserObjectPermission.objects.select_related("content_type", "user").all()
filterset_class = UserPermissionFilter

View file

@ -0,0 +1,24 @@
"""RBAC Roles"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.rbac.models import Role
class RoleSerializer(ModelSerializer):
"""Role serializer"""
class Meta:
model = Role
fields = ["pk", "name"]
class RoleViewSet(UsedByMixin, ModelViewSet):
"""Role viewset"""
serializer_class = RoleSerializer
queryset = Role.objects.all()
search_fields = ["group__name"]
ordering = ["group__name"]
filterset_fields = ["group__name"]

15
authentik/rbac/apps.py Normal file
View file

@ -0,0 +1,15 @@
"""authentik rbac app config"""
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikRBACConfig(ManagedAppConfig):
"""authentik rbac app config"""
name = "authentik.rbac"
label = "authentik_rbac"
verbose_name = "authentik RBAC"
default = True
def reconcile_load_rbac_signals(self):
"""Load rbac signals"""
self.import_module("authentik.rbac.signals")

33
authentik/rbac/filters.py Normal file
View file

@ -0,0 +1,33 @@
"""RBAC API Filter"""
from django.db.models import QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.core.models import UserTypes
class ObjectFilter(ObjectPermissionsFilter):
"""Object permission filter that grants global permission higher priority than
per-object permissions"""
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
permission = self.perm_format % {
"app_label": queryset.model._meta.app_label,
"model_name": queryset.model._meta.model_name,
}
# having the global permission set on a user has higher priority than
# per-object permissions
if request.user.has_perm(permission):
return queryset
queryset = super().filter_queryset(request, queryset, view)
# Outposts (which are the only objects using internal service accounts)
# except requests to return an empty list when they have no objects
# assigned
if request.user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
return queryset
if not queryset.exists():
# User doesn't have direct permission to all objects
# and also no object permissions assigned (directly or via role)
raise PermissionDenied()
return queryset

View file

@ -0,0 +1,47 @@
# Generated by Django 4.2.6 on 2023-10-11 13:37
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="Role",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("name", models.TextField(max_length=150, unique=True)),
(
"group",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to="auth.group"
),
),
],
options={
"verbose_name": "Role",
"verbose_name_plural": "Roles",
"permissions": [
("assign_role_permissions", "Can assign permissions to users"),
("unassign_role_permissions", "Can unassign permissions from users"),
],
},
),
]

View file

@ -0,0 +1,35 @@
# Generated by Django 4.2.6 on 2023-10-12 15:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_rbac", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="SystemPermission",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
],
options={
"permissions": [
("view_system_info", "Can view system info"),
("view_system_tasks", "Can view system tasks"),
("run_system_tasks", "Can run system tasks"),
("access_admin_interface", "Can access admin interface"),
],
"verbose_name": "System permission",
"verbose_name_plural": "System permissions",
"managed": False,
"default_permissions": (),
},
),
]

View file

73
authentik/rbac/models.py Normal file
View file

@ -0,0 +1,73 @@
"""RBAC models"""
from typing import Optional
from uuid import uuid4
from django.db import models
from django.db.transaction import atomic
from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import assign_perm
from rest_framework.serializers import BaseSerializer
from authentik.lib.models import SerializerModel
class Role(SerializerModel):
"""RBAC role, which can have different permissions (both global and per-object) attached
to it."""
uuid = models.UUIDField(default=uuid4, editable=False, unique=True, primary_key=True)
# Due to the way django and django-guardian work, this is somewhat of a hack.
# Django and django-guardian allow for setting permissions on users and groups, but they
# only allow for a custom user object, not a custom group object, which is why
# we have both authentik and django groups. With this model, we use the inbuilt group system
# for RBAC. This means that every Role needs a single django group that its assigned to
# which will hold all of the actual permissions
# The main advantage of that is that all the permission checking just works out of the box,
# as these permissions are checked by default by django and most other libraries that build
# on top of django
group = models.OneToOneField("auth.Group", on_delete=models.CASCADE)
# name field has the same constraints as the group model
name = models.TextField(max_length=150, unique=True)
def assign_permission(self, *perms: str, obj: Optional[models.Model] = None):
"""Assign permission to role, can handle multiple permissions,
but when assigning multiple permissions to an object the permissions
must all belong to the object given"""
with atomic():
for perm in perms:
assign_perm(perm, self.group, obj)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.rbac.api.roles import RoleSerializer
return RoleSerializer
def __str__(self) -> str:
return f"Role {self.name}"
class Meta:
verbose_name = _("Role")
verbose_name_plural = _("Roles")
permissions = [
("assign_role_permissions", _("Can assign permissions to users")),
("unassign_role_permissions", _("Can unassign permissions from users")),
]
class SystemPermission(models.Model):
"""System-wide permissions that are not related to any direct
database model"""
class Meta:
managed = False
default_permissions = ()
verbose_name = _("System permission")
verbose_name_plural = _("System permissions")
permissions = [
("view_system_info", _("Can view system info")),
("view_system_tasks", _("Can view system tasks")),
("run_system_tasks", _("Can run system tasks")),
("access_admin_interface", _("Can access admin interface")),
]

View file

@ -0,0 +1,30 @@
"""RBAC Permissions"""
from django.db.models import Model
from rest_framework.permissions import BasePermission, DjangoObjectPermissions
from rest_framework.request import Request
class ObjectPermissions(DjangoObjectPermissions):
"""RBAC Permissions"""
def has_object_permission(self, request: Request, view, obj: Model):
queryset = self._queryset(view)
model_cls = queryset.model
perms = self.get_required_object_permissions(request.method, model_cls)
# Rank global permissions higher than per-object permissions
if request.user.has_perms(perms):
return True
return super().has_object_permission(request, view, obj)
# pylint: disable=invalid-name
def HasPermission(*perm: str) -> type[BasePermission]:
"""Permission checker for any non-object permissions, returns
a BasePermission class that can be used with rest_framework"""
# pylint: disable=missing-class-docstring, invalid-name
class checker(BasePermission):
def has_permission(self, request: Request, view):
return bool(request.user and request.user.has_perms(perm))
return checker

67
authentik/rbac/signals.py Normal file
View file

@ -0,0 +1,67 @@
"""rbac signals"""
from django.contrib.auth.models import Group as DjangoGroup
from django.db.models.signals import m2m_changed, pre_save
from django.db.transaction import atomic
from django.dispatch import receiver
from structlog.stdlib import get_logger
from authentik.core.models import Group
from authentik.rbac.models import Role
LOGGER = get_logger()
@receiver(pre_save, sender=Role)
def rbac_role_pre_save(sender: type[Role], instance: Role, **_):
"""Ensure role has a group object created for it"""
if hasattr(instance, "group"):
return
group, _ = DjangoGroup.objects.get_or_create(name=instance.name)
instance.group = group
@receiver(m2m_changed, sender=Group.roles.through)
def rbac_group_role_m2m(sender: type[Group], action: str, instance: Group, reverse: bool, **_):
"""RBAC: Sync group members into roles when roles are assigned"""
if action not in ["post_add", "post_remove", "post_clear"]:
return
with atomic():
group_users = list(
instance.children_recursive()
.exclude(users__isnull=True)
.values_list("users", flat=True)
)
if not group_users:
return
for role in instance.roles.all():
role: Role
role.group.user_set.set(group_users)
LOGGER.debug("Updated users in group", group=instance)
# pylint: disable=no-member
@receiver(m2m_changed, sender=Group.users.through)
def rbac_group_users_m2m(
sender: type[Group], action: str, instance: Group, pk_set: set, reverse: bool, **_
):
"""Handle Group/User m2m and mirror it to roles"""
if action not in ["post_add", "post_remove"]:
return
# reverse: instance is a Group, pk_set is a list of user pks
# non-reverse: instance is a User, pk_set is a list of groups
with atomic():
if reverse:
for role in instance.roles.all():
role: Role
if action == "post_add":
role.group.user_set.add(*pk_set)
elif action == "post_remove":
role.group.user_set.remove(*pk_set)
else:
for group in Group.objects.filter(pk__in=pk_set):
for role in group.roles.all():
role: Role
if action == "post_add":
role.group.user_set.add(instance)
elif action == "post_remove":
role.group.user_set.remove(instance)

View file

View file

@ -0,0 +1,151 @@
"""Test RoleAssignedPermissionViewSet api"""
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_admin_user, create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.api.rbac_assigned_by_roles import RoleAssignedObjectPermissionSerializer
from authentik.rbac.models import Role
from authentik.stages.invitation.models import Invitation
class TestRBACRoleAPI(APITestCase):
"""Test RoleAssignedPermissionViewSet api"""
def setUp(self) -> None:
self.superuser = create_test_admin_user()
self.user = create_test_user()
self.role = Role.objects.create(name=generate_id())
self.group = Group.objects.create(name=generate_id())
self.group.roles.add(self.role)
self.group.users.add(self.user)
def test_filter_assigned(self):
"""Test RoleAssignedPermissionViewSet's filters"""
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
# self.user doesn't have permissions to see their (object) permissions
self.client.force_login(self.superuser)
res = self.client.get(
reverse("authentik_api:permissions-assigned-by-roles-list"),
{
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
"ordering": "pk",
},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"pagination": {
"next": 0,
"previous": 0,
"count": 1,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 1,
},
"results": [
RoleAssignedObjectPermissionSerializer(instance=self.role).data,
],
},
)
def test_assign_global(self):
"""Test permission assign"""
self.client.force_login(self.superuser)
res = self.client.post(
reverse(
"authentik_api:permissions-assigned-by-roles-assign",
kwargs={
"pk": self.role.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 204)
self.assertTrue(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_assign_object(self):
"""Test permission assign (object)"""
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
self.client.force_login(self.superuser)
res = self.client.post(
reverse(
"authentik_api:permissions-assigned-by-roles-assign",
kwargs={
"pk": self.role.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
},
)
self.assertEqual(res.status_code, 204)
self.assertTrue(
self.user.has_perm(
"authentik_stages_invitation.view_invitation",
inv,
)
)
def test_unassign_global(self):
"""Test permission unassign"""
self.role.assign_permission("authentik_stages_invitation.view_invitation")
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
"authentik_api:permissions-assigned-by-roles-unassign",
kwargs={
"pk": str(self.role.pk),
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 204)
self.assertFalse(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_unassign_object(self):
"""Test permission unassign (object)"""
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
"authentik_api:permissions-assigned-by-roles-unassign",
kwargs={
"pk": str(self.role.pk),
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
},
)
self.assertEqual(res.status_code, 204)
self.assertFalse(
self.user.has_perm(
"authentik_stages_invitation.view_invitation",
inv,
)
)

View file

@ -0,0 +1,196 @@
"""Test UserAssignedPermissionViewSet api"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group, UserTypes
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.api.rbac_assigned_by_users import UserAssignedObjectPermissionSerializer
from authentik.rbac.models import Role
from authentik.stages.invitation.models import Invitation
class TestRBACUserAPI(APITestCase):
"""Test UserAssignedPermissionViewSet api"""
def setUp(self) -> None:
self.superuser = create_test_admin_user()
self.user = create_test_user()
self.role = Role.objects.create(name=generate_id())
self.group = Group.objects.create(name=generate_id())
self.group.roles.add(self.role)
self.group.users.add(self.user)
def test_filter_assigned(self):
"""Test UserAssignedPermissionViewSet's filters"""
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
assign_perm("authentik_stages_invitation.view_invitation", self.user, inv)
# self.user doesn't have permissions to see their (object) permissions
self.client.force_login(self.superuser)
res = self.client.get(
reverse("authentik_api:permissions-assigned-by-users-list"),
{
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
"ordering": "pk",
},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"pagination": {
"next": 0,
"previous": 0,
"count": 2,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 2,
},
"results": sorted(
[
UserAssignedObjectPermissionSerializer(instance=self.user).data,
UserAssignedObjectPermissionSerializer(instance=self.superuser).data,
],
key=lambda u: u["pk"],
),
},
)
def test_assign_global(self):
"""Test permission assign"""
self.client.force_login(self.superuser)
res = self.client.post(
reverse(
"authentik_api:permissions-assigned-by-users-assign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 204)
self.assertTrue(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_assign_global_internal_sa(self):
"""Test permission assign (to internal service account)"""
self.client.force_login(self.superuser)
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save()
res = self.client.post(
reverse(
"authentik_api:permissions-assigned-by-users-assign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 400)
self.assertFalse(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_assign_object(self):
"""Test permission assign (object)"""
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
self.client.force_login(self.superuser)
res = self.client.post(
reverse(
"authentik_api:permissions-assigned-by-users-assign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
},
)
self.assertEqual(res.status_code, 204)
self.assertTrue(
self.user.has_perm(
"authentik_stages_invitation.view_invitation",
inv,
)
)
def test_unassign_global(self):
"""Test permission unassign"""
assign_perm("authentik_stages_invitation.view_invitation", self.user)
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
"authentik_api:permissions-assigned-by-users-unassign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 204)
self.assertFalse(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_unassign_global_internal_sa(self):
"""Test permission unassign (from internal service account)"""
self.client.force_login(self.superuser)
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save()
assign_perm("authentik_stages_invitation.view_invitation", self.user)
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
"authentik_api:permissions-assigned-by-users-unassign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 400)
self.assertTrue(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_unassign_object(self):
"""Test permission unassign (object)"""
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
assign_perm("authentik_stages_invitation.view_invitation", self.user, inv)
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
"authentik_api:permissions-assigned-by-users-unassign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
},
)
self.assertEqual(res.status_code, 204)
self.assertFalse(
self.user.has_perm(
"authentik_stages_invitation.view_invitation",
inv,
)
)

View file

@ -0,0 +1,122 @@
"""RBAC role tests"""
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_admin_user, create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.models import Role
from authentik.stages.invitation.api import InvitationSerializer
from authentik.stages.invitation.models import Invitation
class TestAPIPerms(APITestCase):
"""Test API Permission and filtering"""
def setUp(self) -> None:
self.superuser = create_test_admin_user()
self.user = create_test_user()
self.role = Role.objects.create(name=generate_id())
self.group = Group.objects.create(name=generate_id())
self.group.roles.add(self.role)
self.group.users.add(self.user)
def test_list_simple(self):
"""Test list (single object, role has global permission)"""
self.client.force_login(self.user)
self.role.assign_permission("authentik_stages_invitation.view_invitation")
Invitation.objects.all().delete()
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
res = self.client.get(reverse("authentik_api:invitation-list"))
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"pagination": {
"next": 0,
"previous": 0,
"count": 1,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 1,
},
"results": [
InvitationSerializer(instance=inv).data,
],
},
)
def test_list_object_perm(self):
"""Test list"""
self.client.force_login(self.user)
Invitation.objects.all().delete()
Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
inv2 = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv2)
res = self.client.get(reverse("authentik_api:invitation-list"))
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"pagination": {
"next": 0,
"previous": 0,
"count": 1,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 1,
},
"results": [
InvitationSerializer(instance=inv2).data,
],
},
)
def test_list_denied(self):
"""Test list without adding permission"""
self.client.force_login(self.user)
res = self.client.get(reverse("authentik_api:invitation-list"))
self.assertEqual(res.status_code, 403)
self.assertJSONEqual(
res.content.decode(),
{"detail": "You do not have permission to perform this action."},
)
def test_create_simple(self):
"""Test create with permission"""
self.client.force_login(self.user)
self.role.assign_permission("authentik_stages_invitation.add_invitation")
res = self.client.post(
reverse("authentik_api:invitation-list"),
data={
"name": generate_id(),
},
)
self.assertEqual(res.status_code, 201)
def test_create_simple_denied(self):
"""Test create without assigning permission"""
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:invitation-list"),
data={
"name": generate_id(),
},
)
self.assertEqual(res.status_code, 403)

View file

@ -0,0 +1,35 @@
"""RBAC role tests"""
from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.rbac.models import Role
class TestRoles(APITestCase):
"""Test roles"""
def test_role_create(self):
"""Test creation"""
user = create_test_admin_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
group.roles.add(role)
group.users.add(user)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
def test_role_create_remove(self):
"""Test creation and remove"""
user = create_test_admin_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
group.roles.add(role)
group.users.add(user)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
user.delete()
self.assertEqual(list(role.group.user_set.all()), [])

24
authentik/rbac/urls.py Normal file
View file

@ -0,0 +1,24 @@
"""RBAC API urls"""
from authentik.rbac.api.rbac import RBACPermissionViewSet
from authentik.rbac.api.rbac_assigned_by_roles import RoleAssignedPermissionViewSet
from authentik.rbac.api.rbac_assigned_by_users import UserAssignedPermissionViewSet
from authentik.rbac.api.rbac_roles import RolePermissionViewSet
from authentik.rbac.api.rbac_users import UserPermissionViewSet
from authentik.rbac.api.roles import RoleViewSet
api_urlpatterns = [
(
"rbac/permissions/assigned_by_users",
UserAssignedPermissionViewSet,
"permissions-assigned-by-users",
),
(
"rbac/permissions/assigned_by_roles",
RoleAssignedPermissionViewSet,
"permissions-assigned-by-roles",
),
("rbac/permissions/users", UserPermissionViewSet, "permissions-users"),
("rbac/permissions/roles", RolePermissionViewSet, "permissions-roles"),
("rbac/permissions", RBACPermissionViewSet),
("rbac/roles", RoleViewSet),
]

View file

@ -77,6 +77,7 @@ INSTALLED_APPS = [
"authentik.providers.radius",
"authentik.providers.saml",
"authentik.providers.scim",
"authentik.rbac",
"authentik.recovery",
"authentik.sources.ldap",
"authentik.sources.oauth",
@ -156,7 +157,7 @@ REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "authentik.api.pagination.Pagination",
"PAGE_SIZE": 100,
"DEFAULT_FILTER_BACKENDS": [
"rest_framework_guardian.filters.ObjectPermissionsFilter",
"authentik.rbac.filters.ObjectFilter",
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
"rest_framework.filters.SearchFilter",
@ -164,7 +165,7 @@ REST_FRAMEWORK = {
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
],
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.DjangoObjectPermissions",),
"DEFAULT_PERMISSION_CLASSES": ("authentik.rbac.permissions.ObjectPermissions",),
"DEFAULT_AUTHENTICATION_CLASSES": (
"authentik.api.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
@ -253,10 +254,10 @@ ASGI_APPLICATION = "authentik.root.asgi.application"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer",
"CONFIG": {
"hosts": [f"{_redis_url}/{CONFIG.get('redis.db')}"],
"prefix": "authentik_channels",
"prefix": "authentik_channels_",
},
},
}
@ -410,6 +411,9 @@ if DEBUG:
INSTALLED_APPS.append("silk")
SILKY_PYTHON_PROFILER = True
MIDDLEWARE = ["silk.middleware.SilkyMiddleware"] + MIDDLEWARE
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"].append(
"rest_framework.renderers.BrowsableAPIRenderer"
)
INSTALLED_APPS.append("authentik.core")

View file

@ -49,6 +49,11 @@ class LDAPPasswordChanger:
self._source = source
self._connection = source.connection()
@staticmethod
def should_check_user(user: User) -> bool:
"""Check if the user has LDAP parameters and needs to be checked"""
return LDAP_DISTINGUISHED_NAME in user.attributes
def get_domain_root_dn(self) -> str:
"""Attempt to get root DN via MS specific fields or generic LDAP fields"""
info = self._connection.server.info

View file

@ -41,11 +41,12 @@ def ldap_password_validate(sender, password: str, plan_context: dict[str, Any],
if not sources.exists():
return
source = sources.first()
user = plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
if user and not LDAPPasswordChanger.should_check_user(user):
return
changer = LDAPPasswordChanger(source)
if changer.check_ad_password_complexity_enabled():
passing = changer.ad_password_complexity(
password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
)
passing = changer.ad_password_complexity(password, user)
if not passing:
raise ValidationError(_("Password does not match Active Directory Complexity."))
@ -57,6 +58,8 @@ def ldap_sync_password(sender, user: User, password: str, **_):
if not sources.exists():
return
source = sources.first()
if not LDAPPasswordChanger.should_check_user(user):
return
try:
changer = LDAPPasswordChanger(source)
changer.change_password(user, password)

View file

@ -32,7 +32,7 @@ CACHE_KEY_PREFIX = "goauthentik.io/sources/ldap/page/"
def ldap_sync_all():
"""Sync all sources"""
for source in LDAPSource.objects.filter(enabled=True):
ldap_sync_single(source.pk)
ldap_sync_single.apply_async(args=[source.pk])
@CELERY_APP.task(

View file

@ -30,4 +30,12 @@ class Migration(migrations.Migration):
name="staticdevice",
options={"verbose_name": "Static device", "verbose_name_plural": "Static devices"},
),
migrations.AlterModelOptions(
name="staticdevice",
options={"verbose_name": "Static Device", "verbose_name_plural": "Static Devices"},
),
migrations.AlterModelOptions(
name="statictoken",
options={"verbose_name": "Static Token", "verbose_name_plural": "Static Tokens"},
),
]

View file

@ -95,8 +95,8 @@ class StaticDevice(SerializerModel, ThrottlingMixin, Device):
return match is not None
class Meta(Device.Meta):
verbose_name = _("Static device")
verbose_name_plural = _("Static devices")
verbose_name = _("Static Device")
verbose_name_plural = _("Static Devices")
class StaticToken(models.Model):
@ -124,3 +124,7 @@ class StaticToken(models.Model):
"""
return b32encode(urandom(5)).decode("utf-8").lower()
class Meta:
verbose_name = _("Static Token")
verbose_name_plural = _("Static Tokens")

View file

@ -25,4 +25,8 @@ class Migration(migrations.Migration):
name="totpdevice",
options={"verbose_name": "TOTP device", "verbose_name_plural": "TOTP devices"},
),
migrations.AlterModelOptions(
name="totpdevice",
options={"verbose_name": "TOTP Device", "verbose_name_plural": "TOTP Devices"},
),
]

View file

@ -241,5 +241,5 @@ class TOTPDevice(SerializerModel, ThrottlingMixin, Device):
return None
class Meta(Device.Meta):
verbose_name = _("TOTP device")
verbose_name_plural = _("TOTP devices")
verbose_name = _("TOTP Device")
verbose_name_plural = _("TOTP Devices")

View file

@ -6,11 +6,11 @@ from django.http import HttpRequest, HttpResponse
from django.utils.timezone import now
from rest_framework.fields import CharField
from authentik.core.api.utils import PassiveSerializer
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
ChallengeTypes,
PermissionSerializer,
WithUserInfoChallenge,
)
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
@ -25,12 +25,19 @@ PLAN_CONTEXT_CONSENT_EXTRA_PERMISSIONS = "consent_additional_permissions"
SESSION_KEY_CONSENT_TOKEN = "authentik/stages/consent/token" # nosec
class ConsentPermissionSerializer(PassiveSerializer):
"""Permission used for consent"""
name = CharField(allow_blank=True)
id = CharField()
class ConsentChallenge(WithUserInfoChallenge):
"""Challenge info for consent screens"""
header_text = CharField(required=False)
permissions = PermissionSerializer(many=True)
additional_permissions = PermissionSerializer(many=True)
permissions = ConsentPermissionSerializer(many=True)
additional_permissions = ConsentPermissionSerializer(many=True)
component = CharField(default="ak-stage-consent")
token = CharField(required=True)

View file

@ -11,7 +11,7 @@ class DenyStageSerializer(StageSerializer):
class Meta:
model = DenyStage
fields = StageSerializer.Meta.fields
fields = StageSerializer.Meta.fields + ["deny_message"]
class DenyStageViewSet(UsedByMixin, ModelViewSet):

View file

@ -0,0 +1,17 @@
# Generated by Django 4.2.6 on 2023-10-18 09:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_deny", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="denystage",
name="deny_message",
field=models.TextField(blank=True, default=""),
),
]

View file

@ -1,5 +1,5 @@
"""deny stage models"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
@ -10,6 +10,8 @@ from authentik.flows.models import Stage
class DenyStage(Stage):
"""Cancels the current flow."""
deny_message = models.TextField(blank=True, default="")
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.stages.deny.api import DenyStageSerializer

View file

@ -2,6 +2,7 @@
from django.http import HttpRequest, HttpResponse
from authentik.flows.stage import StageView
from authentik.stages.deny.models import DenyStage
class DenyStageView(StageView):
@ -9,4 +10,6 @@ class DenyStageView(StageView):
def dispatch(self, request: HttpRequest) -> HttpResponse:
"""Cancels the current flow"""
return self.executor.stage_invalid()
stage: DenyStage = self.executor.current_stage
message = self.executor.plan.context.get("deny_message", stage.deny_message)
return self.executor.stage_invalid(message)

View file

@ -45,3 +45,38 @@ class TestUserDenyStage(FlowTestCase):
)
self.assertStageResponse(response, self.flow, component="ak-stage-access-denied")
def test_message_static(self):
"""Test with a static error message"""
self.stage.deny_message = "foo"
self.stage.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertStageResponse(
response, self.flow, component="ak-stage-access-denied", error_message="foo"
)
def test_message_overwrite(self):
"""Test with an overwritten error message"""
self.stage.deny_message = "foo"
self.stage.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context["deny_message"] = "bar"
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertStageResponse(
response, self.flow, component="ak-stage-access-denied", error_message="bar"
)

View file

@ -3,8 +3,8 @@ from datetime import timedelta
from django.contrib import messages
from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext as _
@ -15,7 +15,7 @@ from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTyp
from authentik.flows.models import FlowDesignation, FlowToken
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
@ -51,8 +51,22 @@ class EmailStageView(ChallengeStageView):
"authentik_core:if-flow",
kwargs={"flow_slug": self.executor.flow.slug},
)
relative_url = f"{base_url}?{urlencode(kwargs)}"
return self.request.build_absolute_uri(relative_url)
# Parse query string from current URL (full query string)
query_params = QueryDict(self.request.META.get("QUERY_STRING", ""), mutable=True)
query_params.pop(QS_KEY_TOKEN, None)
# Check for nested query string used by flow executor, and remove any
# kind of flow token from that
if QS_QUERY in query_params:
inner_query_params = QueryDict(query_params.get(QS_QUERY), mutable=True)
inner_query_params.pop(QS_KEY_TOKEN, None)
query_params[QS_QUERY] = inner_query_params.urlencode()
query_params.update(kwargs)
full_url = base_url
if len(query_params) > 0:
full_url = f"{full_url}?{query_params.urlencode()}"
return self.request.build_absolute_uri(full_url)
def get_token(self) -> FlowToken:
"""Get token"""
@ -146,6 +160,7 @@ class EmailStageView(ChallengeStageView):
messages.error(self.request, _("No pending user."))
return super().challenge_invalid(response)
self.send_email()
messages.success(self.request, _("Email Successfully sent."))
# We can't call stage_ok yet, as we're still waiting
# for the user to click the link in the email
return super().challenge_invalid(response)

View file

@ -4,6 +4,7 @@ from unittest.mock import MagicMock, PropertyMock, patch
from django.core import mail
from django.core.mail.backends.locmem import EmailBackend
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
from django.test import RequestFactory
from django.urls import reverse
from django.utils.http import urlencode
@ -12,10 +13,11 @@ from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN, FlowExecutorView
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.stages.email.models import EmailStage
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, EmailStageView
class TestEmailStage(FlowTestCase):
@ -23,8 +25,8 @@ class TestEmailStage(FlowTestCase):
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.user = create_test_admin_user()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
self.stage = EmailStage.objects.create(
name="email",
@ -205,3 +207,97 @@ class TestEmailStage(FlowTestCase):
self.assertEqual(response.status_code, 200)
self.assertStageResponse(response, component="ak-stage-access-denied")
def test_url_no_params(self):
"""Test generation of the URL in the EMail"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
request = self.factory.get(url)
stage_view = EmailStageView(
FlowExecutorView(
request=request,
flow=self.flow,
),
request=request,
)
self.assertEqual(stage_view.get_full_url(), f"http://testserver/if/flow/{self.flow.slug}/")
def test_url_our_params(self):
"""Test that all of our parameters are passed to the URL correctly"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
request = self.factory.get(url)
stage_view = EmailStageView(
FlowExecutorView(
request=request,
flow=self.flow,
),
request=request,
)
token = generate_id()
self.assertEqual(
stage_view.get_full_url(**{QS_KEY_TOKEN: token}),
f"http://testserver/if/flow/{self.flow.slug}/?flow_token={token}",
)
def test_url_existing_params(self):
"""Test to ensure that URL params are preserved in the URL being sent"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
url += "?foo=bar"
request = self.factory.get(url)
stage_view = EmailStageView(
FlowExecutorView(
request=request,
flow=self.flow,
),
request=request,
)
token = generate_id()
self.assertEqual(
stage_view.get_full_url(**{QS_KEY_TOKEN: token}),
f"http://testserver/if/flow/{self.flow.slug}/?foo=bar&flow_token={token}",
)
def test_url_existing_params_nested(self):
"""Test to ensure that URL params are preserved in the URL being sent (including nested)"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
url += "?foo=bar&"
url += "query=" + urlencode({"nested": "value"})
request = self.factory.get(url)
stage_view = EmailStageView(
FlowExecutorView(
request=request,
flow=self.flow,
),
request=request,
)
token = generate_id()
self.assertEqual(
stage_view.get_full_url(**{QS_KEY_TOKEN: token}),
(
f"http://testserver/if/flow/{self.flow.slug}"
f"/?foo=bar&query=nested%3Dvalue&flow_token={token}"
),
)

View file

@ -71,6 +71,7 @@ class PromptViewSet(UsedByMixin, ModelViewSet):
queryset = Prompt.objects.all().prefetch_related("promptstage_set")
serializer_class = PromptSerializer
ordering = ["field_key"]
filterset_fields = ["field_key", "name", "label", "type", "placeholder"]
search_fields = ["field_key", "name", "label", "type", "placeholder"]

View file

@ -1188,6 +1188,43 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_rbac.role"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created",
"must_created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_rbac.role"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_rbac.role"
}
}
},
{
"type": "object",
"required": [
@ -2705,6 +2742,43 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_enterprise.license"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created",
"must_created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_enterprise.license"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_enterprise.license"
}
}
},
{
"type": "object",
"required": [
@ -3372,6 +3446,7 @@
"authentik.providers.radius",
"authentik.providers.saml",
"authentik.providers.scim",
"authentik.rbac",
"authentik.recovery",
"authentik.sources.ldap",
"authentik.sources.oauth",
@ -3443,6 +3518,7 @@
"authentik_providers_saml.samlpropertymapping",
"authentik_providers_scim.scimprovider",
"authentik_providers_scim.scimmapping",
"authentik_rbac.role",
"authentik_sources_ldap.ldapsource",
"authentik_sources_ldap.ldappropertymapping",
"authentik_sources_oauth.oauthsource",
@ -3483,7 +3559,8 @@
"authentik_core.group",
"authentik_core.user",
"authentik_core.application",
"authentik_core.token"
"authentik_core.token",
"authentik_enterprise.license"
],
"title": "Model",
"description": "Match events created by selected model. When left empty, all models are matched. When an app is selected, all the application's models are matched."
@ -4717,6 +4794,11 @@
"minLength": 1,
"title": "Shared secret",
"description": "Shared secret between clients and server to hash packets."
},
"mfa_support": {
"type": "boolean",
"title": "MFA Support",
"description": "When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon."
}
},
"required": []
@ -4944,6 +5026,18 @@
},
"required": []
},
"model_authentik_rbac.role": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 150,
"minLength": 1,
"title": "Name"
}
},
"required": []
},
"model_authentik_sources_ldap.ldapsource": {
"type": "object",
"properties": {
@ -6920,6 +7014,10 @@
]
},
"title": "Flow set"
},
"deny_message": {
"type": "string",
"title": "Deny message"
}
},
"required": []
@ -8405,6 +8503,13 @@
"type": "object",
"additionalProperties": true,
"title": "Attributes"
},
"roles": {
"type": "array",
"items": {
"type": "integer"
},
"title": "Roles"
}
},
"required": []
@ -8599,6 +8704,17 @@
},
"required": []
},
"model_authentik_enterprise.license": {
"type": "object",
"properties": {
"key": {
"type": "string",
"minLength": 1,
"title": "Key"
}
},
"required": []
},
"model_authentik_blueprints.metaapplyblueprint": {
"type": "object",
"properties": {

2
go.mod
View file

@ -27,7 +27,7 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.0
github.com/stretchr/testify v1.8.4
goauthentik.io/api/v3 v3.2023083.6
goauthentik.io/api/v3 v3.2023083.9
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.13.0
golang.org/x/sync v0.4.0

4
go.sum
View file

@ -355,8 +355,8 @@ go.opentelemetry.io/otel/trace v1.14.0 h1:wp2Mmvj41tDsyAJXiWDWpfNsOiIyd38fy85pyK
go.opentelemetry.io/otel/trace v1.14.0/go.mod h1:8avnQLK+CG77yNLUae4ea2JDQ6iT+gozhnZjy/rw9G8=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
goauthentik.io/api/v3 v3.2023083.6 h1:VYVnE/3CYhggmobeZ+V3ka0TwswrUhKasxwGPmXTq0M=
goauthentik.io/api/v3 v3.2023083.6/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2023083.9 h1:23tCiPYFpxfElH7LQKiVL2zEigTefM8s4CJOlytgiAs=
goauthentik.io/api/v3 v3.2023083.9/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=

View file

@ -0,0 +1,48 @@
package flow
import (
"regexp"
"strconv"
"strings"
)
const CodePasswordSeparator = ";"
var alphaNum = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
// CheckPasswordInlineMFA For protocols that only support username/password, check if the password
// contains the TOTP code
func (fe *FlowExecutor) CheckPasswordInlineMFA() {
password := fe.Answers[StagePassword]
// We already have an authenticator answer
if fe.Answers[StageAuthenticatorValidate] != "" {
return
}
// password doesn't contain the separator
if !strings.Contains(password, CodePasswordSeparator) {
return
}
// password ends with the separator, so it won't contain an answer
if strings.HasSuffix(password, CodePasswordSeparator) {
return
}
idx := strings.LastIndex(password, CodePasswordSeparator)
authenticator := password[idx+1:]
// Authenticator is either 6 chars (totp code) or 8 chars (long totp or static)
if len(authenticator) == 6 {
// authenticator answer isn't purely numerical, so won't be value
if _, err := strconv.Atoi(authenticator); err != nil {
return
}
} else if len(authenticator) == 8 {
// 8 chars can be a long totp or static token, so it needs to be alphanumerical
if !alphaNum.MatchString(authenticator) {
return
}
} else {
// Any other length, doesn't contain an answer
return
}
fe.Answers[StagePassword] = password[:idx]
fe.Answers[StageAuthenticatorValidate] = authenticator
}

View file

@ -2,9 +2,6 @@ package direct
import (
"context"
"regexp"
"strconv"
"strings"
"beryju.io/ldap"
"github.com/getsentry/sentry-go"
@ -16,10 +13,6 @@ import (
"goauthentik.io/internal/outpost/ldap/metrics"
)
const CodePasswordSeparator = ";"
var alphaNum = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResultCode, error) {
fe := flow.NewFlowExecutor(req.Context(), db.si.GetAuthenticationFlowSlug(), db.si.GetAPIClient().GetConfig(), log.Fields{
"bindDN": req.BindDN,
@ -31,7 +24,9 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
fe.Answers[flow.StageIdentification] = username
fe.Answers[flow.StagePassword] = req.BindPW
db.CheckPasswordMFA(fe)
if db.si.GetMFASupport() {
fe.CheckPasswordInlineMFA()
}
passed, err := fe.Execute()
flags := flags.UserFlags{
@ -141,41 +136,3 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
uisp.Finish()
return ldap.LDAPResultSuccess, nil
}
func (db *DirectBinder) CheckPasswordMFA(fe *flow.FlowExecutor) {
if !db.si.GetMFASupport() {
return
}
password := fe.Answers[flow.StagePassword]
// We already have an authenticator answer
if fe.Answers[flow.StageAuthenticatorValidate] != "" {
return
}
// password doesn't contain the separator
if !strings.Contains(password, CodePasswordSeparator) {
return
}
// password ends with the separator, so it won't contain an answer
if strings.HasSuffix(password, CodePasswordSeparator) {
return
}
idx := strings.LastIndex(password, CodePasswordSeparator)
authenticator := password[idx+1:]
// Authenticator is either 6 chars (totp code) or 8 chars (long totp or static)
if len(authenticator) == 6 {
// authenticator answer isn't purely numerical, so won't be value
if _, err := strconv.Atoi(authenticator); err != nil {
return
}
} else if len(authenticator) == 8 {
// 8 chars can be a long totp or static token, so it needs to be alphanumerical
if !alphaNum.MatchString(authenticator) {
return
}
} else {
// Any other length, doesn't contain an answer
return
}
fe.Answers[flow.StagePassword] = password[:idx]
fe.Answers[flow.StageAuthenticatorValidate] = authenticator
}

View file

@ -162,7 +162,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
for _, u := range g.UsersObj {
if flag.UserPk == u.Pk {
//TODO: Is there a better way to clone this object?
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.GroupMember{u})
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.GroupMember{u}, []api.Role{})
fg.SetUsers([]int32{flag.UserPk})
if g.Parent.IsSet() {
fg.SetParent(*g.Parent.Get())

View file

@ -40,11 +40,10 @@ func (rs *RadiusServer) Refresh() error {
providers := make([]*ProviderInstance, len(outposts.Results))
for idx, provider := range outposts.Results {
logger := log.WithField("logger", "authentik.outpost.radius").WithField("provider", provider.Name)
s := *provider.SharedSecret
c := *provider.ClientNetworks
providers[idx] = &ProviderInstance{
SharedSecret: []byte(s),
ClientNetworks: parseCIDRs(c),
SharedSecret: []byte(provider.GetSharedSecret()),
ClientNetworks: parseCIDRs(provider.GetClientNetworks()),
MFASupport: provider.GetMfaSupport(),
appSlug: provider.ApplicationSlug,
flowSlug: provider.AuthFlowSlug,
s: rs,

View file

@ -22,6 +22,9 @@ func (rs *RadiusServer) Handle_AccessRequest(w radius.ResponseWriter, r *RadiusR
fe.Answers[flow.StageIdentification] = username
fe.Answers[flow.StagePassword] = rfc2865.UserPassword_GetString(r.Packet)
if r.pi.MFASupport {
fe.CheckPasswordInlineMFA()
}
passed, err := fe.Execute()

View file

@ -17,6 +17,7 @@ import (
type ProviderInstance struct {
ClientNetworks []*net.IPNet
SharedSecret []byte
MFASupport bool
appSlug string
flowSlug string

View file

@ -81,8 +81,8 @@ if __name__ == "__main__":
)
curr = conn.cursor()
try:
for migration in Path(__file__).parent.absolute().glob("system_migrations/*.py"):
spec = spec_from_file_location("lifecycle.system_migrations", migration)
for migration_path in Path(__file__).parent.absolute().glob("system_migrations/*.py"):
spec = spec_from_file_location("lifecycle.system_migrations", migration_path)
if not spec:
continue
mod = module_from_spec(spec)
@ -94,9 +94,9 @@ if __name__ == "__main__":
migration = sub(curr, conn)
if migration.needs_migration():
wait_for_lock()
LOGGER.info("Migration needs to be applied", migration=sub)
LOGGER.info("Migration needs to be applied", migration=migration_path.name)
migration.run()
LOGGER.info("Migration finished applying", migration=sub)
LOGGER.info("Migration finished applying", migration=migration_path.name)
release_lock()
LOGGER.info("applying django migrations")
environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")

View file

@ -2,6 +2,7 @@
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """
BEGIN TRANSACTION;
DELETE FROM django_migrations WHERE app = 'passbook_stages_prompt';
DROP TABLE passbook_stages_prompt_prompt cascade;
DROP TABLE passbook_stages_prompt_promptstage cascade;
@ -22,6 +23,7 @@ DELETE FROM django_migrations WHERE app = 'passbook_flows' AND name = '0008_defa
DELETE FROM django_migrations WHERE app = 'passbook_flows' AND name = '0009_source_flows';
DELETE FROM django_migrations WHERE app = 'passbook_flows' AND name = '0010_provider_flows';
DELETE FROM django_migrations WHERE app = 'passbook_stages_password' AND name = '0002_passwordstage_change_flow';
COMMIT;
"""

View file

@ -4,7 +4,7 @@ from redis import Redis
from authentik.lib.config import CONFIG
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """
SQL_STATEMENT = """BEGIN TRANSACTION;
ALTER TABLE passbook_audit_event RENAME TO authentik_audit_event;
ALTER TABLE passbook_core_application RENAME TO authentik_core_application;
ALTER TABLE passbook_core_group RENAME TO authentik_core_group;
@ -92,6 +92,7 @@ ALTER SEQUENCE passbook_stages_prompt_promptstage_validation_policies_id_seq REN
UPDATE django_migrations SET app = replace(app, 'passbook', 'authentik');
UPDATE django_content_type SET app_label = replace(app_label, 'passbook', 'authentik');
COMMIT;
"""

View file

@ -1,9 +1,12 @@
# flake8: noqa
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """ALTER TABLE authentik_audit_event RENAME TO authentik_events_event;
SQL_STATEMENT = """BEGIN TRANSACTION;
ALTER TABLE authentik_audit_event RENAME TO authentik_events_event;
UPDATE django_migrations SET app = replace(app, 'authentik_audit', 'authentik_events');
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_audit', 'authentik_events');"""
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_audit', 'authentik_events');
COMMIT;"""
class Migration(BaseMigration):

View file

@ -2,6 +2,7 @@
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """
BEGIN TRANSACTION;
ALTER TABLE authentik_stages_otp_static_otpstaticstage RENAME TO authentik_stages_authenticator_static_otpstaticstage;
UPDATE django_migrations SET app = replace(app, 'authentik_stages_otp_static', 'authentik_stages_authenticator_static');
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_otp_static', 'authentik_stages_authenticator_static');
@ -13,6 +14,7 @@ UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_
ALTER TABLE authentik_stages_otp_validate_otpvalidatestage RENAME TO authentik_stages_authenticator_validate_otpvalidatestage;
UPDATE django_migrations SET app = replace(app, 'authentik_stages_otp_validate', 'authentik_stages_authenticator_validate');
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_otp_validate', 'authentik_stages_authenticator_validate');
COMMIT;
"""

View file

@ -1,8 +1,11 @@
# flake8: noqa
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """DROP TABLE "authentik_policies_hibp_haveibeenpwendpolicy";
DELETE FROM django_migrations WHERE app = 'authentik_policies_hibp';"""
SQL_STATEMENT = """
BEGIN TRANSACTION;
DROP TABLE "authentik_policies_hibp_haveibeenpwendpolicy";
DELETE FROM django_migrations WHERE app = 'authentik_policies_hibp';
COMMIT;"""
class Migration(BaseMigration):

84
poetry.lock generated
View file

@ -437,33 +437,29 @@ files = [
[[package]]
name = "black"
version = "23.9.1"
version = "23.10.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
{file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"},
{file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"},
{file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"},
{file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"},
{file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"},
{file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"},
{file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"},
{file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"},
{file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"},
{file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"},
{file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"},
{file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"},
{file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"},
{file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"},
{file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"},
{file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"},
{file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"},
{file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"},
{file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"},
{file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"},
{file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"},
{file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"},
{file = "black-23.10.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:f8dc7d50d94063cdfd13c82368afd8588bac4ce360e4224ac399e769d6704e98"},
{file = "black-23.10.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:f20ff03f3fdd2fd4460b4f631663813e57dc277e37fb216463f3b907aa5a9bdd"},
{file = "black-23.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3d9129ce05b0829730323bdcb00f928a448a124af5acf90aa94d9aba6969604"},
{file = "black-23.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:960c21555be135c4b37b7018d63d6248bdae8514e5c55b71e994ad37407f45b8"},
{file = "black-23.10.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:30b78ac9b54cf87bcb9910ee3d499d2bc893afd52495066c49d9ee6b21eee06e"},
{file = "black-23.10.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:0e232f24a337fed7a82c1185ae46c56c4a6167fb0fe37411b43e876892c76699"},
{file = "black-23.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31946ec6f9c54ed7ba431c38bc81d758970dd734b96b8e8c2b17a367d7908171"},
{file = "black-23.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:c870bee76ad5f7a5ea7bd01dc646028d05568d33b0b09b7ecfc8ec0da3f3f39c"},
{file = "black-23.10.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:6901631b937acbee93c75537e74f69463adaf34379a04eef32425b88aca88a23"},
{file = "black-23.10.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:481167c60cd3e6b1cb8ef2aac0f76165843a374346aeeaa9d86765fe0dd0318b"},
{file = "black-23.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f74892b4b836e5162aa0452393112a574dac85e13902c57dfbaaf388e4eda37c"},
{file = "black-23.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:47c4510f70ec2e8f9135ba490811c071419c115e46f143e4dce2ac45afdcf4c9"},
{file = "black-23.10.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:76baba9281e5e5b230c9b7f83a96daf67a95e919c2dfc240d9e6295eab7b9204"},
{file = "black-23.10.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:a3c2ddb35f71976a4cfeca558848c2f2f89abc86b06e8dd89b5a65c1e6c0f22a"},
{file = "black-23.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db451a3363b1e765c172c3fd86213a4ce63fb8524c938ebd82919bf2a6e28c6a"},
{file = "black-23.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:7fb5fc36bb65160df21498d5a3dd330af8b6401be3f25af60c6ebfe23753f747"},
{file = "black-23.10.0-py3-none-any.whl", hash = "sha256:e223b731a0e025f8ef427dd79d8cd69c167da807f5710add30cdf131f13dd62e"},
{file = "black-23.10.0.tar.gz", hash = "sha256:31b9f87b277a68d0e99d2905edae08807c007973eaa609da5f0c62def6b7c0bd"},
]
[package.dependencies]
@ -3425,28 +3421,28 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.0.292"
version = "0.1.0"
description = "An extremely fast Python linter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.0.292-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:02f29db018c9d474270c704e6c6b13b18ed0ecac82761e4fcf0faa3728430c96"},
{file = "ruff-0.0.292-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:69654e564342f507edfa09ee6897883ca76e331d4bbc3676d8a8403838e9fade"},
{file = "ruff-0.0.292-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c3c91859a9b845c33778f11902e7b26440d64b9d5110edd4e4fa1726c41e0a4"},
{file = "ruff-0.0.292-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4476f1243af2d8c29da5f235c13dca52177117935e1f9393f9d90f9833f69e4"},
{file = "ruff-0.0.292-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be8eb50eaf8648070b8e58ece8e69c9322d34afe367eec4210fdee9a555e4ca7"},
{file = "ruff-0.0.292-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9889bac18a0c07018aac75ef6c1e6511d8411724d67cb879103b01758e110a81"},
{file = "ruff-0.0.292-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bdfabd4334684a4418b99b3118793f2c13bb67bf1540a769d7816410402a205"},
{file = "ruff-0.0.292-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7c77c53bfcd75dbcd4d1f42d6cabf2485d2e1ee0678da850f08e1ab13081a8"},
{file = "ruff-0.0.292-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e087b24d0d849c5c81516ec740bf4fd48bf363cfb104545464e0fca749b6af9"},
{file = "ruff-0.0.292-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f160b5ec26be32362d0774964e218f3fcf0a7da299f7e220ef45ae9e3e67101a"},
{file = "ruff-0.0.292-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ac153eee6dd4444501c4bb92bff866491d4bfb01ce26dd2fff7ca472c8df9ad0"},
{file = "ruff-0.0.292-py3-none-musllinux_1_2_i686.whl", hash = "sha256:87616771e72820800b8faea82edd858324b29bb99a920d6aa3d3949dd3f88fb0"},
{file = "ruff-0.0.292-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b76deb3bdbea2ef97db286cf953488745dd6424c122d275f05836c53f62d4016"},
{file = "ruff-0.0.292-py3-none-win32.whl", hash = "sha256:e854b05408f7a8033a027e4b1c7f9889563dd2aca545d13d06711e5c39c3d003"},
{file = "ruff-0.0.292-py3-none-win_amd64.whl", hash = "sha256:f27282bedfd04d4c3492e5c3398360c9d86a295be00eccc63914438b4ac8a83c"},
{file = "ruff-0.0.292-py3-none-win_arm64.whl", hash = "sha256:7f67a69c8f12fbc8daf6ae6d36705037bde315abf8b82b6e1f4c9e74eb750f68"},
{file = "ruff-0.0.292.tar.gz", hash = "sha256:1093449e37dd1e9b813798f6ad70932b57cf614e5c2b5c51005bf67d55db33ac"},
{file = "ruff-0.1.0-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:87114e254dee35e069e1b922d85d4b21a5b61aec759849f393e1dbb308a00439"},
{file = "ruff-0.1.0-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:764f36d2982cc4a703e69fb73a280b7c539fd74b50c9ee531a4e3fe88152f521"},
{file = "ruff-0.1.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65f4b7fb539e5cf0f71e9bd74f8ddab74cabdd673c6fb7f17a4dcfd29f126255"},
{file = "ruff-0.1.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:299fff467a0f163baa282266b310589b21400de0a42d8f68553422fa6bf7ee01"},
{file = "ruff-0.1.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d412678bf205787263bb702c984012a4f97e460944c072fd7cfa2bd084857c4"},
{file = "ruff-0.1.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a5391b49b1669b540924640587d8d24128e45be17d1a916b1801d6645e831581"},
{file = "ruff-0.1.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee8cd57f454cdd77bbcf1e11ff4e0046fb6547cac1922cc6e3583ce4b9c326d1"},
{file = "ruff-0.1.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa7aeed7bc23861a2b38319b636737bf11cfa55d2109620b49cf995663d3e888"},
{file = "ruff-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b04cd4298b43b16824d9a37800e4c145ba75c29c43ce0d74cad1d66d7ae0a4c5"},
{file = "ruff-0.1.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7186ccf54707801d91e6314a016d1c7895e21d2e4cd614500d55870ed983aa9f"},
{file = "ruff-0.1.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d88adfd93849bc62449518228581d132e2023e30ebd2da097f73059900d8dce3"},
{file = "ruff-0.1.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ad2ccdb3bad5a61013c76a9c1240fdfadf2c7103a2aeebd7bcbbed61f363138f"},
{file = "ruff-0.1.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b77f6cfa72c6eb19b5cac967cc49762ae14d036db033f7d97a72912770fd8e1c"},
{file = "ruff-0.1.0-py3-none-win32.whl", hash = "sha256:480bd704e8af1afe3fd444cc52e3c900b936e6ca0baf4fb0281124330b6ceba2"},
{file = "ruff-0.1.0-py3-none-win_amd64.whl", hash = "sha256:a76ba81860f7ee1f2d5651983f87beb835def94425022dc5f0803108f1b8bfa2"},
{file = "ruff-0.1.0-py3-none-win_arm64.whl", hash = "sha256:45abdbdab22509a2c6052ecf7050b3f5c7d6b7898dc07e82869401b531d46da4"},
{file = "ruff-0.1.0.tar.gz", hash = "sha256:ad6b13824714b19c5f8225871cf532afb994470eecb74631cd3500fe817e6b3f"},
]
[[package]]
@ -3871,13 +3867,13 @@ files = [
[[package]]
name = "urllib3"
version = "2.0.6"
version = "2.0.7"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.7"
files = [
{file = "urllib3-2.0.6-py3-none-any.whl", hash = "sha256:7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2"},
{file = "urllib3-2.0.6.tar.gz", hash = "sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564"},
{file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"},
{file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"},
]
[package.dependencies]

Some files were not shown because too many files have changed in this diff Show more