Compare commits
	
		
			72 Commits
		
	
	
		
			web/sideba
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d6904b6aa1 | |||
| cd581efacd | |||
| 6c159d120b | |||
| 4ddd4e7f88 | |||
| 441912414f | |||
| 9e177ed5c0 | |||
| 881548176f | |||
| 56739d0dc4 | |||
| b23972e9c9 | |||
| 0a9595089e | |||
| 72c22b5fab | |||
| 84cdbb0a03 | |||
| 9fc659f121 | |||
| db6abf61b8 | |||
| 6426a1d177 | |||
| 9075270b01 | |||
| d17a39a431 | |||
| db1d091d2e | |||
| f98204e78e | |||
| 3f663cab0f | |||
| 3fe129e107 | |||
| f26d41aef9 | |||
| 5d8b5998ae | |||
| 7a5e136346 | |||
| bfbab6357a | |||
| 5997b93f15 | |||
| 6cdae09dc0 | |||
| ff0ef7a2b3 | |||
| 3986104a20 | |||
| 1aa60e7864 | |||
| 045578dd07 | |||
| f23d70dc75 | |||
| 496f3426d9 | |||
| 17acc9457d | |||
| 2996f20b74 | |||
| dd86a90225 | |||
| 3b1034b9a2 | |||
| ba87fd8714 | |||
| ccebe355aa | |||
| 49fe670932 | |||
| f1d173f94e | |||
| 19e0a282c6 | |||
| 234f06a362 | |||
| 0bbbc7def2 | |||
| 43fd3eecda | |||
| 631b120e4f | |||
| 9ea517d606 | |||
| 7b7a7e3073 | |||
| ca3cdc3fd2 | |||
| 6e12277903 | |||
| 2f42144b33 | |||
| eef02f2892 | |||
| b6157ecaf1 | |||
| 35cd126406 | |||
| f89a4fc276 | |||
| 4d7f380b2d | |||
| cb8379031a | |||
| 0c604ceba4 | |||
| 30e39c75ff | |||
| 6d7bebbcc3 | |||
| dc332ec7b0 | |||
| 31e94a2814 | |||
| eb08214f0e | |||
| a5ab8a618e | |||
| b8cbdcae22 | |||
| ae86184511 | |||
| b704388c2f | |||
| a35f9fdd7b | |||
| d95220be0e | |||
| ba1b86efa1 | |||
| cd93de1141 | |||
| cc148bd552 | 
@ -1,5 +1,5 @@
 | 
			
		||||
[bumpversion]
 | 
			
		||||
current_version = 2024.4.2
 | 
			
		||||
current_version = 2024.6.2
 | 
			
		||||
tag = True
 | 
			
		||||
commit = True
 | 
			
		||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
 | 
			
		||||
@ -17,6 +17,8 @@ optional_value = final
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:pyproject.toml]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:package.json]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:docker-compose.yml]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:schema.yml]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@ -21,7 +21,10 @@ updates:
 | 
			
		||||
    labels:
 | 
			
		||||
      - dependencies
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: "/web"
 | 
			
		||||
    directories:
 | 
			
		||||
      - "/web"
 | 
			
		||||
      - "/tests/wdio"
 | 
			
		||||
      - "/web/sfe"
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: daily
 | 
			
		||||
      time: "04:00"
 | 
			
		||||
@ -30,7 +33,6 @@ updates:
 | 
			
		||||
    open-pull-requests-limit: 10
 | 
			
		||||
    commit-message:
 | 
			
		||||
      prefix: "web:"
 | 
			
		||||
    # TODO: deduplicate these groups
 | 
			
		||||
    groups:
 | 
			
		||||
      sentry:
 | 
			
		||||
        patterns:
 | 
			
		||||
@ -56,38 +58,6 @@ updates:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "@rollup/*"
 | 
			
		||||
          - "rollup-*"
 | 
			
		||||
  - package-ecosystem: npm
 | 
			
		||||
    directory: "/tests/wdio"
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: daily
 | 
			
		||||
      time: "04:00"
 | 
			
		||||
    labels:
 | 
			
		||||
      - dependencies
 | 
			
		||||
    open-pull-requests-limit: 10
 | 
			
		||||
    commit-message:
 | 
			
		||||
      prefix: "web:"
 | 
			
		||||
    # TODO: deduplicate these groups
 | 
			
		||||
    groups:
 | 
			
		||||
      sentry:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "@sentry/*"
 | 
			
		||||
          - "@spotlightjs/*"
 | 
			
		||||
      babel:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "@babel/*"
 | 
			
		||||
          - "babel-*"
 | 
			
		||||
      eslint:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "@typescript-eslint/*"
 | 
			
		||||
          - "eslint"
 | 
			
		||||
          - "eslint-*"
 | 
			
		||||
      storybook:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "@storybook/*"
 | 
			
		||||
          - "*storybook*"
 | 
			
		||||
      esbuild:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "@esbuild/*"
 | 
			
		||||
      wdio:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "@wdio/*"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -31,7 +31,12 @@ jobs:
 | 
			
		||||
        env:
 | 
			
		||||
          NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
 | 
			
		||||
      - name: Upgrade /web
 | 
			
		||||
        working-directory: web/
 | 
			
		||||
        working-directory: web
 | 
			
		||||
        run: |
 | 
			
		||||
          export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
 | 
			
		||||
          npm i @goauthentik/api@$VERSION
 | 
			
		||||
      - name: Upgrade /web/sfe
 | 
			
		||||
        working-directory: web/sfe
 | 
			
		||||
        run: |
 | 
			
		||||
          export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
 | 
			
		||||
          npm i @goauthentik/api@$VERSION
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							@ -20,6 +20,16 @@ jobs:
 | 
			
		||||
        project:
 | 
			
		||||
          - web
 | 
			
		||||
          - tests/wdio
 | 
			
		||||
        include:
 | 
			
		||||
          - command: tsc
 | 
			
		||||
            project: web
 | 
			
		||||
            extra_setup: |
 | 
			
		||||
              cd sfe/ && npm ci
 | 
			
		||||
        exclude:
 | 
			
		||||
          - command: lint:lockfile
 | 
			
		||||
            project: tests/wdio
 | 
			
		||||
          - command: tsc
 | 
			
		||||
            project: tests/wdio
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v4
 | 
			
		||||
      - uses: actions/setup-node@v4
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							@ -22,20 +22,30 @@ RUN npm run build-bundled
 | 
			
		||||
# Stage 2: Build webui
 | 
			
		||||
FROM --platform=${BUILDPLATFORM} docker.io/node:22 as web-builder
 | 
			
		||||
 | 
			
		||||
ARG GIT_BUILD_HASH
 | 
			
		||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
 | 
			
		||||
ENV NODE_ENV=production
 | 
			
		||||
 | 
			
		||||
WORKDIR /work/web
 | 
			
		||||
 | 
			
		||||
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
 | 
			
		||||
    --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
 | 
			
		||||
    --mount=type=bind,target=/work/web/sfe/package.json,src=./web/sfe/package.json \
 | 
			
		||||
    --mount=type=bind,target=/work/web/sfe/package-lock.json,src=./web/sfe/package-lock.json \
 | 
			
		||||
    --mount=type=bind,target=/work/web/scripts,src=./web/scripts \
 | 
			
		||||
    --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
 | 
			
		||||
    npm ci --include=dev && \
 | 
			
		||||
    cd sfe && \
 | 
			
		||||
    npm ci --include=dev
 | 
			
		||||
 | 
			
		||||
COPY ./package.json /work
 | 
			
		||||
COPY ./web /work/web/
 | 
			
		||||
COPY ./website /work/website/
 | 
			
		||||
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
 | 
			
		||||
 | 
			
		||||
RUN npm run build
 | 
			
		||||
RUN npm run build && \
 | 
			
		||||
    cd sfe && \
 | 
			
		||||
    npm run build
 | 
			
		||||
 | 
			
		||||
# Stage 3: Build go proxy
 | 
			
		||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder
 | 
			
		||||
 | 
			
		||||
@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
 | 
			
		||||
 | 
			
		||||
(.x being the latest patch release for each version)
 | 
			
		||||
 | 
			
		||||
| Version   | Supported |
 | 
			
		||||
| --------- | --------- |
 | 
			
		||||
| 2023.10.x | ✅        |
 | 
			
		||||
| 2024.2.x  | ✅        |
 | 
			
		||||
| Version  | Supported |
 | 
			
		||||
| -------- | --------- |
 | 
			
		||||
| 2024.4.x | ✅        |
 | 
			
		||||
| 2024.6.x | ✅        |
 | 
			
		||||
 | 
			
		||||
## Reporting a Vulnerability
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
from os import environ
 | 
			
		||||
 | 
			
		||||
__version__ = "2024.4.2"
 | 
			
		||||
__version__ = "2024.6.2"
 | 
			
		||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -16,6 +16,7 @@ from rest_framework.views import APIView
 | 
			
		||||
 | 
			
		||||
from authentik import get_full_version
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.enterprise.license import LicenseKey
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.utils.reflection import get_env
 | 
			
		||||
from authentik.outposts.apps import MANAGED_OUTPOST
 | 
			
		||||
@ -32,7 +33,7 @@ class RuntimeDict(TypedDict):
 | 
			
		||||
    platform: str
 | 
			
		||||
    uname: str
 | 
			
		||||
    openssl_version: str
 | 
			
		||||
    openssl_fips_mode: bool
 | 
			
		||||
    openssl_fips_enabled: bool | None
 | 
			
		||||
    authentik_version: str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -71,7 +72,9 @@ class SystemInfoSerializer(PassiveSerializer):
 | 
			
		||||
            "architecture": platform.machine(),
 | 
			
		||||
            "authentik_version": get_full_version(),
 | 
			
		||||
            "environment": get_env(),
 | 
			
		||||
            "openssl_fips_enabled": backend._fips_enabled,
 | 
			
		||||
            "openssl_fips_enabled": (
 | 
			
		||||
                backend._fips_enabled if LicenseKey.get_total().is_valid() else None
 | 
			
		||||
            ),
 | 
			
		||||
            "openssl_version": OPENSSL_VERSION,
 | 
			
		||||
            "platform": platform.platform(),
 | 
			
		||||
            "python_version": python_version,
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,13 @@
 | 
			
		||||
{% extends "base/skeleton.html" %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
API Browser - {{ brand.branding_title }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% static 'dist/standalone/api-browser/index.js' %}?version={{ version }}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/standalone/api-browser/index-%v.js" %}
 | 
			
		||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
 | 
			
		||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@ -11,21 +11,20 @@ from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.permissions import AllowAny
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.validators import UniqueValidator
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.api.authorization import SecretKeyFilter
 | 
			
		||||
from authentik.brands.models import Brand
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.tenants.utils import get_current_tenant
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FooterLinkSerializer(PassiveSerializer):
 | 
			
		||||
    """Links returned in Config API"""
 | 
			
		||||
 | 
			
		||||
    href = CharField(read_only=True)
 | 
			
		||||
    href = CharField(read_only=True, allow_null=True)
 | 
			
		||||
    name = CharField(read_only=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -17,7 +17,6 @@ from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodFiel
 | 
			
		||||
from rest_framework.parsers import MultiPartParser
 | 
			
		||||
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 structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
@ -26,6 +25,7 @@ from authentik.api.pagination import Pagination
 | 
			
		||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
			
		||||
from authentik.core.api.providers import ProviderSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.core.models import Application, User
 | 
			
		||||
from authentik.events.logs import LogEventSerializer, capture_logs
 | 
			
		||||
from authentik.events.models import EventAction
 | 
			
		||||
 | 
			
		||||
@ -8,12 +8,12 @@ from rest_framework import mixins
 | 
			
		||||
from rest_framework.fields import SerializerMethodField
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
from ua_parser import user_agent_parser
 | 
			
		||||
 | 
			
		||||
from authentik.api.authorization import OwnerSuperuserPermissions
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.core.models import AuthenticatedSession
 | 
			
		||||
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
 | 
			
		||||
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict
 | 
			
		||||
 | 
			
		||||
@ -17,12 +17,12 @@ from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
 | 
			
		||||
from rest_framework.serializers import ListSerializer, ValidationError
 | 
			
		||||
from rest_framework.validators import UniqueValidator
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import JSONDictField, PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.core.models import Group, User
 | 
			
		||||
from authentik.rbac.api.roles import RoleSerializer
 | 
			
		||||
from authentik.rbac.decorators import permission_required
 | 
			
		||||
 | 
			
		||||
@ -8,11 +8,10 @@ from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.exceptions import PermissionDenied
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField, SerializerMethodField
 | 
			
		||||
from rest_framework.relations import PrimaryKeyRelatedField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.blueprints.api import ManagedSerializer
 | 
			
		||||
@ -20,6 +19,7 @@ from authentik.core.api.object_types import TypesMixin
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import (
 | 
			
		||||
    MetaNameSerializer,
 | 
			
		||||
    ModelSerializer,
 | 
			
		||||
    PassiveSerializer,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.expression.evaluator import PropertyMappingEvaluator
 | 
			
		||||
 | 
			
		||||
@ -6,13 +6,12 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django_filters.filters import BooleanFilter
 | 
			
		||||
from django_filters.filterset import FilterSet
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.fields import ReadOnlyField
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
			
		||||
from rest_framework.fields import ReadOnlyField, SerializerMethodField
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.object_types import TypesMixin
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
 | 
			
		||||
from authentik.core.models import Provider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -11,7 +11,6 @@ from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.parsers import MultiPartParser
 | 
			
		||||
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 structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
@ -19,7 +18,7 @@ from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
 | 
			
		||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
			
		||||
from authentik.core.api.object_types import TypesMixin
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
 | 
			
		||||
from authentik.core.models import Source, UserSourceConnection
 | 
			
		||||
from authentik.core.types import UserSettingSerializer
 | 
			
		||||
from authentik.lib.utils.file import (
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,6 @@ from rest_framework.fields import CharField
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
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 authentik.api.authorization import OwnerSuperuserPermissions
 | 
			
		||||
@ -20,7 +19,7 @@ from authentik.blueprints.api import ManagedSerializer
 | 
			
		||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.users import UserSerializer
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.core.models import (
 | 
			
		||||
    USER_ATTRIBUTE_TOKEN_EXPIRING,
 | 
			
		||||
    USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
 | 
			
		||||
@ -45,6 +44,13 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
 | 
			
		||||
        if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
 | 
			
		||||
            self.fields["key"] = CharField(required=False)
 | 
			
		||||
 | 
			
		||||
    def validate_user(self, user: User):
 | 
			
		||||
        """Ensure user of token cannot be changed"""
 | 
			
		||||
        if self.instance and self.instance.user_id:
 | 
			
		||||
            if user.pk != self.instance.user_id:
 | 
			
		||||
                raise ValidationError("User cannot be changed")
 | 
			
		||||
        return user
 | 
			
		||||
 | 
			
		||||
    def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
 | 
			
		||||
        """Ensure only API or App password tokens are created."""
 | 
			
		||||
        request: Request = self.context.get("request")
 | 
			
		||||
 | 
			
		||||
@ -40,7 +40,6 @@ from rest_framework.serializers import (
 | 
			
		||||
    BooleanField,
 | 
			
		||||
    DateTimeField,
 | 
			
		||||
    ListSerializer,
 | 
			
		||||
    ModelSerializer,
 | 
			
		||||
    PrimaryKeyRelatedField,
 | 
			
		||||
    ValidationError,
 | 
			
		||||
)
 | 
			
		||||
@ -52,7 +51,12 @@ from authentik.admin.api.metrics import CoordinateSerializer
 | 
			
		||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
			
		||||
from authentik.brands.models import Brand
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import (
 | 
			
		||||
    JSONDictField,
 | 
			
		||||
    LinkSerializer,
 | 
			
		||||
    ModelSerializer,
 | 
			
		||||
    PassiveSerializer,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.middleware import (
 | 
			
		||||
    SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
 | 
			
		||||
    SESSION_KEY_IMPERSONATE_USER,
 | 
			
		||||
 | 
			
		||||
@ -12,9 +12,12 @@ from rest_framework.fields import (
 | 
			
		||||
    JSONField,
 | 
			
		||||
    SerializerMethodField,
 | 
			
		||||
)
 | 
			
		||||
from rest_framework.serializers import ModelSerializer as BaseModelSerializer
 | 
			
		||||
from rest_framework.serializers import (
 | 
			
		||||
    Serializer,
 | 
			
		||||
    ValidationError,
 | 
			
		||||
    model_meta,
 | 
			
		||||
    raise_errors_on_nested_writes,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -25,6 +28,39 @@ def is_dict(value: Any):
 | 
			
		||||
    raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ModelSerializer(BaseModelSerializer):
 | 
			
		||||
 | 
			
		||||
    def update(self, instance: Model, validated_data):
 | 
			
		||||
        raise_errors_on_nested_writes("update", self, validated_data)
 | 
			
		||||
        info = model_meta.get_field_info(instance)
 | 
			
		||||
 | 
			
		||||
        # Simply set each attribute on the instance, and then save it.
 | 
			
		||||
        # Note that unlike `.create()` we don't need to treat many-to-many
 | 
			
		||||
        # relationships as being a special case. During updates we already
 | 
			
		||||
        # have an instance pk for the relationships to be associated with.
 | 
			
		||||
        m2m_fields = []
 | 
			
		||||
        for attr, value in validated_data.items():
 | 
			
		||||
            if attr in info.relations and info.relations[attr].to_many:
 | 
			
		||||
                m2m_fields.append((attr, value))
 | 
			
		||||
            else:
 | 
			
		||||
                setattr(instance, attr, value)
 | 
			
		||||
 | 
			
		||||
        instance.save()
 | 
			
		||||
 | 
			
		||||
        # Note that many-to-many fields are set after updating instance.
 | 
			
		||||
        # Setting m2m fields triggers signals which could potentially change
 | 
			
		||||
        # updated instance and we do not want it to collide with .update()
 | 
			
		||||
        for attr, value in m2m_fields:
 | 
			
		||||
            field = getattr(instance, attr)
 | 
			
		||||
            # We can't check for inheritance here as m2m managers are generated dynamically
 | 
			
		||||
            if field.__class__.__name__ == "RelatedManager":
 | 
			
		||||
                field.set(value, bulk=False)
 | 
			
		||||
            else:
 | 
			
		||||
                field.set(value)
 | 
			
		||||
 | 
			
		||||
        return instance
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JSONDictField(JSONField):
 | 
			
		||||
    """JSON Field which only allows dictionaries"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -76,8 +76,11 @@ class PropertyMappingEvaluator(BaseEvaluator):
 | 
			
		||||
        )
 | 
			
		||||
        if "request" in self._context:
 | 
			
		||||
            req: PolicyRequest = self._context["request"]
 | 
			
		||||
            event.from_http(req.http_request, req.user)
 | 
			
		||||
            return
 | 
			
		||||
            if req.http_request:
 | 
			
		||||
                event.from_http(req.http_request, req.user)
 | 
			
		||||
                return
 | 
			
		||||
            elif req.user:
 | 
			
		||||
                event.set_user(req.user)
 | 
			
		||||
        event.save()
 | 
			
		||||
 | 
			
		||||
    def evaluate(self, *args, **kwargs) -> Any:
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
"""authentik core exceptions"""
 | 
			
		||||
 | 
			
		||||
from authentik.lib.expression.exceptions import ControlFlowException
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,7 +13,7 @@ class PropertyMappingExpressionException(SentryIgnoredException):
 | 
			
		||||
        self.mapping = mapping
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SkipObjectException(PropertyMappingExpressionException):
 | 
			
		||||
class SkipObjectException(ControlFlowException):
 | 
			
		||||
    """Exception which can be raised in a property mapping to skip syncing an object.
 | 
			
		||||
    Only applies to Property mappings which sync objects, and not on mappings which transitively
 | 
			
		||||
    apply to a single user"""
 | 
			
		||||
 | 
			
		||||
@ -7,12 +7,13 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    from authentik.providers.ldap.models import LDAPProvider
 | 
			
		||||
    from authentik.providers.scim.models import SCIMProvider
 | 
			
		||||
 | 
			
		||||
    for model in [LDAPProvider, SCIMProvider]:
 | 
			
		||||
        try:
 | 
			
		||||
            for obj in model.objects.only("is_backchannel"):
 | 
			
		||||
            for obj in model.objects.using(db_alias).only("is_backchannel"):
 | 
			
		||||
                obj.is_backchannel = True
 | 
			
		||||
                obj.save()
 | 
			
		||||
        except (DatabaseError, InternalError, ProgrammingError):
 | 
			
		||||
 | 
			
		||||
@ -26,6 +26,7 @@ from authentik.blueprints.models import ManagedModel
 | 
			
		||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
 | 
			
		||||
from authentik.core.types import UILoginButton, UserSettingSerializer
 | 
			
		||||
from authentik.lib.avatars import get_avatar
 | 
			
		||||
from authentik.lib.expression.exceptions import ControlFlowException
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.lib.models import (
 | 
			
		||||
    CreatedUpdatedModel,
 | 
			
		||||
@ -783,6 +784,8 @@ class PropertyMapping(SerializerModel, ManagedModel):
 | 
			
		||||
        evaluator = PropertyMappingEvaluator(self, user, request, **kwargs)
 | 
			
		||||
        try:
 | 
			
		||||
            return evaluator.evaluate(self.expression)
 | 
			
		||||
        except ControlFlowException as exc:
 | 
			
		||||
            raise exc
 | 
			
		||||
        except Exception as exc:
 | 
			
		||||
            raise PropertyMappingExpressionException(self, exc) from exc
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -212,7 +212,7 @@ class SourceFlowManager:
 | 
			
		||||
 | 
			
		||||
    def _prepare_flow(
 | 
			
		||||
        self,
 | 
			
		||||
        flow: Flow,
 | 
			
		||||
        flow: Flow | None,
 | 
			
		||||
        connection: UserSourceConnection,
 | 
			
		||||
        stages: list[StageView] | None = None,
 | 
			
		||||
        **kwargs,
 | 
			
		||||
@ -309,7 +309,9 @@ class SourceFlowManager:
 | 
			
		||||
        # When request isn't authenticated we jump straight to auth
 | 
			
		||||
        if not self.request.user.is_authenticated:
 | 
			
		||||
            return self.handle_auth(connection)
 | 
			
		||||
        # Connection has already been saved
 | 
			
		||||
        if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
 | 
			
		||||
            return self._prepare_flow(None, connection)
 | 
			
		||||
        connection.save()
 | 
			
		||||
        Event.new(
 | 
			
		||||
            EventAction.SOURCE_LINKED,
 | 
			
		||||
            message="Linked Source",
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@
 | 
			
		||||
        versionSubdomain: "{{ version_subdomain }}",
 | 
			
		||||
        build: "{{ build }}",
 | 
			
		||||
    };
 | 
			
		||||
    window.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    window.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
        {% for message in messages %}
 | 
			
		||||
        window.dispatchEvent(
 | 
			
		||||
            new CustomEvent("ak-message", {
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,10 @@
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<html>
 | 
			
		||||
    <head>
 | 
			
		||||
        <meta charset="UTF-8">
 | 
			
		||||
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
 | 
			
		||||
@ -14,8 +15,8 @@
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
 | 
			
		||||
        <script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script>
 | 
			
		||||
        <script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script>
 | 
			
		||||
        {% versioned_script "dist/poly-%v.js" %}
 | 
			
		||||
        {% versioned_script "dist/standalone/loading/index-%v.js" %}
 | 
			
		||||
        {% block head %}
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
        <meta name="sentry-trace" content="{{ sentry_trace }}" />
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
{% extends "base/skeleton.html" %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/admin/AdminInterface-%v.js" %}
 | 
			
		||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
 | 
			
		||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
 | 
			
		||||
{% include "base/header_js.html" %}
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
{% extends "base/skeleton.html" %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/user/UserInterface-%v.js" %}
 | 
			
		||||
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)">
 | 
			
		||||
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)">
 | 
			
		||||
{% include "base/header_js.html" %}
 | 
			
		||||
 | 
			
		||||
@ -71,9 +71,9 @@
 | 
			
		||||
                </li>
 | 
			
		||||
                {% endfor %}
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a href="https://goauthentik.io?utm_source=authentik">
 | 
			
		||||
                    <span>
 | 
			
		||||
                        {% trans 'Powered by authentik' %}
 | 
			
		||||
                    </a>
 | 
			
		||||
                    </span>
 | 
			
		||||
                </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
        </footer>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/core/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/core/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										21
									
								
								authentik/core/templatetags/authentik_core.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								authentik/core/templatetags/authentik_core.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
"""authentik core tags"""
 | 
			
		||||
 | 
			
		||||
from django import template
 | 
			
		||||
from django.templatetags.static import static as static_loader
 | 
			
		||||
from django.utils.safestring import mark_safe
 | 
			
		||||
 | 
			
		||||
from authentik import get_full_version
 | 
			
		||||
 | 
			
		||||
register = template.Library()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.simple_tag()
 | 
			
		||||
def versioned_script(path: str) -> str:
 | 
			
		||||
    """Wrapper around {% static %} tag that supports setting the version"""
 | 
			
		||||
    returned_lines = [
 | 
			
		||||
        (
 | 
			
		||||
            f'<script src="{static_loader(path.replace("%v", get_full_version()))}'
 | 
			
		||||
            '" type="module"></script>'
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
    return mark_safe("".join(returned_lines))  # nosec
 | 
			
		||||
@ -3,7 +3,10 @@
 | 
			
		||||
from django.test import RequestFactory, TestCase
 | 
			
		||||
from guardian.shortcuts import get_anonymous_user
 | 
			
		||||
 | 
			
		||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
 | 
			
		||||
from authentik.core.expression.exceptions import (
 | 
			
		||||
    PropertyMappingExpressionException,
 | 
			
		||||
    SkipObjectException,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.models import PropertyMapping
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
@ -42,6 +45,17 @@ class TestPropertyMappings(TestCase):
 | 
			
		||||
        self.assertTrue(events.exists())
 | 
			
		||||
        self.assertEqual(len(events), 1)
 | 
			
		||||
 | 
			
		||||
    def test_expression_skip(self):
 | 
			
		||||
        """Test expression error"""
 | 
			
		||||
        expr = "raise SkipObject"
 | 
			
		||||
        mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr)
 | 
			
		||||
        with self.assertRaises(SkipObjectException):
 | 
			
		||||
            mapping.evaluate(None, None)
 | 
			
		||||
        events = Event.objects.filter(
 | 
			
		||||
            action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
 | 
			
		||||
        )
 | 
			
		||||
        self.assertFalse(events.exists())
 | 
			
		||||
 | 
			
		||||
    def test_expression_error_extended(self):
 | 
			
		||||
        """Test expression error (with user and http request"""
 | 
			
		||||
        expr = "return aaa"
 | 
			
		||||
 | 
			
		||||
@ -13,9 +13,8 @@ from authentik.core.models import (
 | 
			
		||||
    USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
 | 
			
		||||
    Token,
 | 
			
		||||
    TokenIntents,
 | 
			
		||||
    User,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -24,7 +23,7 @@ class TestTokenAPI(APITestCase):
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.user = User.objects.create(username="testuser")
 | 
			
		||||
        self.user = create_test_user()
 | 
			
		||||
        self.admin = create_test_admin_user()
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
@ -154,6 +153,24 @@ class TestTokenAPI(APITestCase):
 | 
			
		||||
        self.assertEqual(token.expiring, True)
 | 
			
		||||
        self.assertNotEqual(token.expires.timestamp(), expires.timestamp())
 | 
			
		||||
 | 
			
		||||
    def test_token_change_user(self):
 | 
			
		||||
        """Test creating a token and then changing the user"""
 | 
			
		||||
        ident = generate_id()
 | 
			
		||||
        response = self.client.post(reverse("authentik_api:token-list"), {"identifier": ident})
 | 
			
		||||
        self.assertEqual(response.status_code, 201)
 | 
			
		||||
        token = Token.objects.get(identifier=ident)
 | 
			
		||||
        self.assertEqual(token.user, self.user)
 | 
			
		||||
        self.assertEqual(token.intent, TokenIntents.INTENT_API)
 | 
			
		||||
        self.assertEqual(token.expiring, True)
 | 
			
		||||
        self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
 | 
			
		||||
        response = self.client.put(
 | 
			
		||||
            reverse("authentik_api:token-detail", kwargs={"identifier": ident}),
 | 
			
		||||
            data={"identifier": "user_token_poc_v3", "intent": "api", "user": self.admin.pk},
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
        token.refresh_from_db()
 | 
			
		||||
        self.assertEqual(token.user, self.user)
 | 
			
		||||
 | 
			
		||||
    def test_list(self):
 | 
			
		||||
        """Test Token List (Test normal authentication)"""
 | 
			
		||||
        Token.objects.all().delete()
 | 
			
		||||
 | 
			
		||||
@ -20,8 +20,9 @@ from authentik.core.api.transactional_applications import TransactionalApplicati
 | 
			
		||||
from authentik.core.api.users import UserViewSet
 | 
			
		||||
from authentik.core.views import apps
 | 
			
		||||
from authentik.core.views.debug import AccessDeniedView
 | 
			
		||||
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
 | 
			
		||||
from authentik.core.views.interface import InterfaceView
 | 
			
		||||
from authentik.core.views.session import EndSessionView
 | 
			
		||||
from authentik.flows.views.interface import FlowInterfaceView
 | 
			
		||||
from authentik.root.asgi_middleware import SessionMiddleware
 | 
			
		||||
from authentik.root.messages.consumer import MessageConsumer
 | 
			
		||||
from authentik.root.middleware import ChannelsLoggingMiddleware
 | 
			
		||||
@ -53,6 +54,8 @@ urlpatterns = [
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "if/flow/<slug:flow_slug>/",
 | 
			
		||||
        # FIXME: move this url to the flows app...also will cause all
 | 
			
		||||
        # of the reverse calls to be adjusted
 | 
			
		||||
        ensure_csrf_cookie(FlowInterfaceView.as_view()),
 | 
			
		||||
        name="if-flow",
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,6 @@
 | 
			
		||||
from json import dumps
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.views.generic.base import TemplateView
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
 | 
			
		||||
@ -11,7 +10,6 @@ from authentik import get_build_hash
 | 
			
		||||
from authentik.admin.tasks import LOCAL_VERSION
 | 
			
		||||
from authentik.api.v3.config import ConfigView
 | 
			
		||||
from authentik.brands.api import CurrentBrandSerializer
 | 
			
		||||
from authentik.flows.models import Flow
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InterfaceView(TemplateView):
 | 
			
		||||
@ -25,14 +23,3 @@ class InterfaceView(TemplateView):
 | 
			
		||||
        kwargs["build"] = get_build_hash()
 | 
			
		||||
        kwargs["url_kwargs"] = self.kwargs
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FlowInterfaceView(InterfaceView):
 | 
			
		||||
    """Flow interface"""
 | 
			
		||||
 | 
			
		||||
    template_name = "if/flow.html"
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
 | 
			
		||||
        kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
 | 
			
		||||
        kwargs["inspector"] = "inspector" in self.request.GET
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
@ -24,13 +24,12 @@ from rest_framework.fields import (
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
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 structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.api.authorization import SecretKeyFilter
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.crypto.apps import MANAGED_KEY
 | 
			
		||||
from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
 | 
			
		||||
from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
 | 
			
		||||
@ -13,11 +13,10 @@ from rest_framework.fields import CharField, IntegerField
 | 
			
		||||
from rest_framework.permissions import IsAuthenticated
 | 
			
		||||
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 authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.core.models import User, UserTypes
 | 
			
		||||
from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer
 | 
			
		||||
from authentik.enterprise.models import License
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,13 @@
 | 
			
		||||
"""GoogleWorkspaceProviderGroup API Views"""
 | 
			
		||||
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.users import UserGroupSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderGroup
 | 
			
		||||
from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProviderGroupSerializer(ModelSerializer):
 | 
			
		||||
@ -30,6 +31,7 @@ class GoogleWorkspaceProviderGroupSerializer(ModelSerializer):
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProviderGroupViewSet(
 | 
			
		||||
    mixins.CreateModelMixin,
 | 
			
		||||
    OutgoingSyncConnectionCreateMixin,
 | 
			
		||||
    mixins.RetrieveModelMixin,
 | 
			
		||||
    mixins.DestroyModelMixin,
 | 
			
		||||
    UsedByMixin,
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,13 @@
 | 
			
		||||
"""GoogleWorkspaceProviderUser API Views"""
 | 
			
		||||
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.groups import GroupMemberSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderUser
 | 
			
		||||
from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProviderUserSerializer(ModelSerializer):
 | 
			
		||||
@ -30,6 +31,7 @@ class GoogleWorkspaceProviderUserSerializer(ModelSerializer):
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProviderUserViewSet(
 | 
			
		||||
    mixins.CreateModelMixin,
 | 
			
		||||
    OutgoingSyncConnectionCreateMixin,
 | 
			
		||||
    mixins.RetrieveModelMixin,
 | 
			
		||||
    mixins.DestroyModelMixin,
 | 
			
		||||
    UsedByMixin,
 | 
			
		||||
 | 
			
		||||
@ -214,3 +214,7 @@ class GoogleWorkspaceGroupClient(
 | 
			
		||||
            google_id=google_id,
 | 
			
		||||
            attributes=group,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def update_single_attribute(self, connection: GoogleWorkspaceProviderUser):
 | 
			
		||||
        group = self.directory_service.groups().get(connection.google_id)
 | 
			
		||||
        connection.attributes = group
 | 
			
		||||
 | 
			
		||||
@ -119,3 +119,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
 | 
			
		||||
            google_id=email,
 | 
			
		||||
            attributes=user,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def update_single_attribute(self, connection: GoogleWorkspaceProviderUser):
 | 
			
		||||
        user = self.directory_service.users().get(connection.google_id)
 | 
			
		||||
        connection.attributes = user
 | 
			
		||||
 | 
			
		||||
@ -31,6 +31,58 @@ def default_scopes() -> list[str]:
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProviderUser(SerializerModel):
 | 
			
		||||
    """Mapping of a user and provider to a Google user ID"""
 | 
			
		||||
 | 
			
		||||
    id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
			
		||||
    google_id = models.TextField()
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
			
		||||
    provider = models.ForeignKey("GoogleWorkspaceProvider", on_delete=models.CASCADE)
 | 
			
		||||
    attributes = models.JSONField(default=dict)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[Serializer]:
 | 
			
		||||
        from authentik.enterprise.providers.google_workspace.api.users import (
 | 
			
		||||
            GoogleWorkspaceProviderUserSerializer,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return GoogleWorkspaceProviderUserSerializer
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Google Workspace Provider User")
 | 
			
		||||
        verbose_name_plural = _("Google Workspace Provider Users")
 | 
			
		||||
        unique_together = (("google_id", "user", "provider"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Google Workspace Provider User {self.user_id} to {self.provider_id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProviderGroup(SerializerModel):
 | 
			
		||||
    """Mapping of a group and provider to a Google group ID"""
 | 
			
		||||
 | 
			
		||||
    id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
			
		||||
    google_id = models.TextField()
 | 
			
		||||
    group = models.ForeignKey(Group, on_delete=models.CASCADE)
 | 
			
		||||
    provider = models.ForeignKey("GoogleWorkspaceProvider", on_delete=models.CASCADE)
 | 
			
		||||
    attributes = models.JSONField(default=dict)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[Serializer]:
 | 
			
		||||
        from authentik.enterprise.providers.google_workspace.api.groups import (
 | 
			
		||||
            GoogleWorkspaceProviderGroupSerializer,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return GoogleWorkspaceProviderGroupSerializer
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Google Workspace Provider Group")
 | 
			
		||||
        verbose_name_plural = _("Google Workspace Provider Groups")
 | 
			
		||||
        unique_together = (("google_id", "group", "provider"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
 | 
			
		||||
    """Sync users from authentik into Google Workspace."""
 | 
			
		||||
 | 
			
		||||
@ -59,15 +111,16 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def client_for_model(
 | 
			
		||||
        self, model: type[User | Group]
 | 
			
		||||
        self,
 | 
			
		||||
        model: type[User | Group | GoogleWorkspaceProviderUser | GoogleWorkspaceProviderGroup],
 | 
			
		||||
    ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]:
 | 
			
		||||
        if issubclass(model, User):
 | 
			
		||||
        if issubclass(model, User | GoogleWorkspaceProviderUser):
 | 
			
		||||
            from authentik.enterprise.providers.google_workspace.clients.users import (
 | 
			
		||||
                GoogleWorkspaceUserClient,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            return GoogleWorkspaceUserClient(self)
 | 
			
		||||
        if issubclass(model, Group):
 | 
			
		||||
        if issubclass(model, Group | GoogleWorkspaceProviderGroup):
 | 
			
		||||
            from authentik.enterprise.providers.google_workspace.clients.groups import (
 | 
			
		||||
                GoogleWorkspaceGroupClient,
 | 
			
		||||
            )
 | 
			
		||||
@ -144,55 +197,3 @@ class GoogleWorkspaceProviderMapping(PropertyMapping):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Google Workspace Provider Mapping")
 | 
			
		||||
        verbose_name_plural = _("Google Workspace Provider Mappings")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProviderUser(SerializerModel):
 | 
			
		||||
    """Mapping of a user and provider to a Google user ID"""
 | 
			
		||||
 | 
			
		||||
    id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
			
		||||
    google_id = models.TextField()
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
			
		||||
    provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE)
 | 
			
		||||
    attributes = models.JSONField(default=dict)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[Serializer]:
 | 
			
		||||
        from authentik.enterprise.providers.google_workspace.api.users import (
 | 
			
		||||
            GoogleWorkspaceProviderUserSerializer,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return GoogleWorkspaceProviderUserSerializer
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Google Workspace Provider User")
 | 
			
		||||
        verbose_name_plural = _("Google Workspace Provider Users")
 | 
			
		||||
        unique_together = (("google_id", "user", "provider"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Google Workspace Provider User {self.user_id} to {self.provider_id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GoogleWorkspaceProviderGroup(SerializerModel):
 | 
			
		||||
    """Mapping of a group and provider to a Google group ID"""
 | 
			
		||||
 | 
			
		||||
    id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
			
		||||
    google_id = models.TextField()
 | 
			
		||||
    group = models.ForeignKey(Group, on_delete=models.CASCADE)
 | 
			
		||||
    provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE)
 | 
			
		||||
    attributes = models.JSONField(default=dict)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[Serializer]:
 | 
			
		||||
        from authentik.enterprise.providers.google_workspace.api.groups import (
 | 
			
		||||
            GoogleWorkspaceProviderGroupSerializer,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return GoogleWorkspaceProviderGroupSerializer
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Google Workspace Provider Group")
 | 
			
		||||
        verbose_name_plural = _("Google Workspace Provider Groups")
 | 
			
		||||
        unique_together = (("google_id", "group", "provider"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}"
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,13 @@
 | 
			
		||||
"""MicrosoftEntraProviderGroup API Views"""
 | 
			
		||||
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.users import UserGroupSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderGroup
 | 
			
		||||
from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProviderGroupSerializer(ModelSerializer):
 | 
			
		||||
@ -30,6 +31,7 @@ class MicrosoftEntraProviderGroupSerializer(ModelSerializer):
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProviderGroupViewSet(
 | 
			
		||||
    mixins.CreateModelMixin,
 | 
			
		||||
    OutgoingSyncConnectionCreateMixin,
 | 
			
		||||
    mixins.RetrieveModelMixin,
 | 
			
		||||
    mixins.DestroyModelMixin,
 | 
			
		||||
    UsedByMixin,
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,13 @@
 | 
			
		||||
"""MicrosoftEntraProviderUser API Views"""
 | 
			
		||||
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.groups import GroupMemberSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderUser
 | 
			
		||||
from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProviderUserSerializer(ModelSerializer):
 | 
			
		||||
@ -29,6 +30,7 @@ class MicrosoftEntraProviderUserSerializer(ModelSerializer):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProviderUserViewSet(
 | 
			
		||||
    OutgoingSyncConnectionCreateMixin,
 | 
			
		||||
    mixins.CreateModelMixin,
 | 
			
		||||
    mixins.RetrieveModelMixin,
 | 
			
		||||
    mixins.DestroyModelMixin,
 | 
			
		||||
 | 
			
		||||
@ -226,3 +226,7 @@ class MicrosoftEntraGroupClient(
 | 
			
		||||
            microsoft_id=group.id,
 | 
			
		||||
            attributes=self.entity_as_dict(group),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def update_single_attribute(self, connection: MicrosoftEntraProviderGroup):
 | 
			
		||||
        data = self._request(self.client.groups.by_group_id(connection.microsoft_id).get())
 | 
			
		||||
        connection.attributes = self.entity_as_dict(data)
 | 
			
		||||
 | 
			
		||||
@ -66,6 +66,26 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
 | 
			
		||||
            microsoft_user.delete()
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    def get_select_fields(self) -> list[str]:
 | 
			
		||||
        """All fields that should be selected when we fetch user data."""
 | 
			
		||||
        # TODO: Make this customizable in the future
 | 
			
		||||
        return [
 | 
			
		||||
            # Default fields
 | 
			
		||||
            "businessPhones",
 | 
			
		||||
            "displayName",
 | 
			
		||||
            "givenName",
 | 
			
		||||
            "jobTitle",
 | 
			
		||||
            "mail",
 | 
			
		||||
            "mobilePhone",
 | 
			
		||||
            "officeLocation",
 | 
			
		||||
            "preferredLanguage",
 | 
			
		||||
            "surname",
 | 
			
		||||
            "userPrincipalName",
 | 
			
		||||
            "id",
 | 
			
		||||
            # Required for logging into M365 using authentik
 | 
			
		||||
            "onPremisesImmutableId",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def create(self, user: User):
 | 
			
		||||
        """Create user from scratch and create a connection object"""
 | 
			
		||||
        microsoft_user = self.to_schema(user, None)
 | 
			
		||||
@ -75,12 +95,12 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
 | 
			
		||||
                response = self._request(self.client.users.post(microsoft_user))
 | 
			
		||||
            except ObjectExistsSyncException:
 | 
			
		||||
                # user already exists in microsoft entra, so we can connect them manually
 | 
			
		||||
                query_params = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters()(
 | 
			
		||||
                    filter=f"mail eq '{microsoft_user.mail}'",
 | 
			
		||||
                )
 | 
			
		||||
                request_configuration = (
 | 
			
		||||
                    UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(
 | 
			
		||||
                        query_parameters=query_params,
 | 
			
		||||
                        query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
 | 
			
		||||
                            filter=f"mail eq '{microsoft_user.mail}'",
 | 
			
		||||
                            select=self.get_select_fields(),
 | 
			
		||||
                        ),
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
                user_data = self._request(self.client.users.get(request_configuration))
 | 
			
		||||
@ -99,7 +119,6 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
 | 
			
		||||
            except TransientSyncException as exc:
 | 
			
		||||
                raise exc
 | 
			
		||||
            else:
 | 
			
		||||
                print(self.entity_as_dict(response))
 | 
			
		||||
                return MicrosoftEntraProviderUser.objects.create(
 | 
			
		||||
                    provider=self.provider,
 | 
			
		||||
                    user=user,
 | 
			
		||||
@ -120,7 +139,12 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
 | 
			
		||||
 | 
			
		||||
    def discover(self):
 | 
			
		||||
        """Iterate through all users and connect them with authentik users if possible"""
 | 
			
		||||
        users = self._request(self.client.users.get())
 | 
			
		||||
        request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(
 | 
			
		||||
            query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
 | 
			
		||||
                select=self.get_select_fields(),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        users = self._request(self.client.users.get(request_configuration))
 | 
			
		||||
        next_link = True
 | 
			
		||||
        while next_link:
 | 
			
		||||
            for user in users.value:
 | 
			
		||||
@ -141,3 +165,14 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
 | 
			
		||||
            microsoft_id=user.id,
 | 
			
		||||
            attributes=self.entity_as_dict(user),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def update_single_attribute(self, connection: MicrosoftEntraProviderUser):
 | 
			
		||||
        request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(
 | 
			
		||||
            query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
 | 
			
		||||
                select=self.get_select_fields(),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        data = self._request(
 | 
			
		||||
            self.client.users.by_user_id(connection.microsoft_id).get(request_configuration)
 | 
			
		||||
        )
 | 
			
		||||
        connection.attributes = self.entity_as_dict(data)
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,58 @@ from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
 | 
			
		||||
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProviderUser(SerializerModel):
 | 
			
		||||
    """Mapping of a user and provider to a Microsoft user ID"""
 | 
			
		||||
 | 
			
		||||
    id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
			
		||||
    microsoft_id = models.TextField()
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
			
		||||
    provider = models.ForeignKey("MicrosoftEntraProvider", on_delete=models.CASCADE)
 | 
			
		||||
    attributes = models.JSONField(default=dict)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[Serializer]:
 | 
			
		||||
        from authentik.enterprise.providers.microsoft_entra.api.users import (
 | 
			
		||||
            MicrosoftEntraProviderUserSerializer,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return MicrosoftEntraProviderUserSerializer
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Microsoft Entra Provider User")
 | 
			
		||||
        verbose_name_plural = _("Microsoft Entra Provider User")
 | 
			
		||||
        unique_together = (("microsoft_id", "user", "provider"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProviderGroup(SerializerModel):
 | 
			
		||||
    """Mapping of a group and provider to a Microsoft group ID"""
 | 
			
		||||
 | 
			
		||||
    id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
			
		||||
    microsoft_id = models.TextField()
 | 
			
		||||
    group = models.ForeignKey(Group, on_delete=models.CASCADE)
 | 
			
		||||
    provider = models.ForeignKey("MicrosoftEntraProvider", on_delete=models.CASCADE)
 | 
			
		||||
    attributes = models.JSONField(default=dict)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[Serializer]:
 | 
			
		||||
        from authentik.enterprise.providers.microsoft_entra.api.groups import (
 | 
			
		||||
            MicrosoftEntraProviderGroupSerializer,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return MicrosoftEntraProviderGroupSerializer
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Microsoft Entra Provider Group")
 | 
			
		||||
        verbose_name_plural = _("Microsoft Entra Provider Groups")
 | 
			
		||||
        unique_together = (("microsoft_id", "group", "provider"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Microsoft Entra Provider Group {self.group_id} to {self.provider_id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
 | 
			
		||||
    """Sync users from authentik into Microsoft Entra."""
 | 
			
		||||
 | 
			
		||||
@ -48,15 +100,16 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def client_for_model(
 | 
			
		||||
        self, model: type[User | Group]
 | 
			
		||||
        self,
 | 
			
		||||
        model: type[User | Group | MicrosoftEntraProviderUser | MicrosoftEntraProviderGroup],
 | 
			
		||||
    ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]:
 | 
			
		||||
        if issubclass(model, User):
 | 
			
		||||
        if issubclass(model, User | MicrosoftEntraProviderUser):
 | 
			
		||||
            from authentik.enterprise.providers.microsoft_entra.clients.users import (
 | 
			
		||||
                MicrosoftEntraUserClient,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            return MicrosoftEntraUserClient(self)
 | 
			
		||||
        if issubclass(model, Group):
 | 
			
		||||
        if issubclass(model, Group | MicrosoftEntraProviderGroup):
 | 
			
		||||
            from authentik.enterprise.providers.microsoft_entra.clients.groups import (
 | 
			
		||||
                MicrosoftEntraGroupClient,
 | 
			
		||||
            )
 | 
			
		||||
@ -133,55 +186,3 @@ class MicrosoftEntraProviderMapping(PropertyMapping):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Microsoft Entra Provider Mapping")
 | 
			
		||||
        verbose_name_plural = _("Microsoft Entra Provider Mappings")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProviderUser(SerializerModel):
 | 
			
		||||
    """Mapping of a user and provider to a Microsoft user ID"""
 | 
			
		||||
 | 
			
		||||
    id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
			
		||||
    microsoft_id = models.TextField()
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
			
		||||
    provider = models.ForeignKey(MicrosoftEntraProvider, on_delete=models.CASCADE)
 | 
			
		||||
    attributes = models.JSONField(default=dict)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[Serializer]:
 | 
			
		||||
        from authentik.enterprise.providers.microsoft_entra.api.users import (
 | 
			
		||||
            MicrosoftEntraProviderUserSerializer,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return MicrosoftEntraProviderUserSerializer
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Microsoft Entra Provider User")
 | 
			
		||||
        verbose_name_plural = _("Microsoft Entra Provider User")
 | 
			
		||||
        unique_together = (("microsoft_id", "user", "provider"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraProviderGroup(SerializerModel):
 | 
			
		||||
    """Mapping of a group and provider to a Microsoft group ID"""
 | 
			
		||||
 | 
			
		||||
    id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
			
		||||
    microsoft_id = models.TextField()
 | 
			
		||||
    group = models.ForeignKey(Group, on_delete=models.CASCADE)
 | 
			
		||||
    provider = models.ForeignKey(MicrosoftEntraProvider, on_delete=models.CASCADE)
 | 
			
		||||
    attributes = models.JSONField(default=dict)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[Serializer]:
 | 
			
		||||
        from authentik.enterprise.providers.microsoft_entra.api.groups import (
 | 
			
		||||
            MicrosoftEntraProviderGroupSerializer,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return MicrosoftEntraProviderGroupSerializer
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Microsoft Entra Provider Group")
 | 
			
		||||
        verbose_name_plural = _("Microsoft Entra Provider Groups")
 | 
			
		||||
        unique_together = (("microsoft_id", "group", "provider"),)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Microsoft Entra Provider Group {self.group_id} to {self.provider_id}"
 | 
			
		||||
 | 
			
		||||
@ -3,16 +3,18 @@
 | 
			
		||||
from unittest.mock import AsyncMock, MagicMock, patch
 | 
			
		||||
 | 
			
		||||
from azure.identity.aio import ClientSecretCredential
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from msgraph.generated.models.group_collection_response import GroupCollectionResponse
 | 
			
		||||
from msgraph.generated.models.organization import Organization
 | 
			
		||||
from msgraph.generated.models.organization_collection_response import OrganizationCollectionResponse
 | 
			
		||||
from msgraph.generated.models.user import User as MSUser
 | 
			
		||||
from msgraph.generated.models.user_collection_response import UserCollectionResponse
 | 
			
		||||
from msgraph.generated.models.verified_domain import VerifiedDomain
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
 | 
			
		||||
from authentik.blueprints.tests import apply_blueprint
 | 
			
		||||
from authentik.core.models import Application, Group, User
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user
 | 
			
		||||
from authentik.enterprise.providers.microsoft_entra.models import (
 | 
			
		||||
    MicrosoftEntraProvider,
 | 
			
		||||
    MicrosoftEntraProviderMapping,
 | 
			
		||||
@ -25,11 +27,12 @@ from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
 | 
			
		||||
from authentik.tenants.models import Tenant
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MicrosoftEntraUserTests(TestCase):
 | 
			
		||||
class MicrosoftEntraUserTests(APITestCase):
 | 
			
		||||
    """Microsoft Entra User tests"""
 | 
			
		||||
 | 
			
		||||
    @apply_blueprint("system/providers-microsoft-entra.yaml")
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
 | 
			
		||||
        # Delete all users and groups as the mocked HTTP responses only return one ID
 | 
			
		||||
        # which will cause errors with multiple users
 | 
			
		||||
        Tenant.objects.update(avatars="none")
 | 
			
		||||
@ -371,3 +374,45 @@ class MicrosoftEntraUserTests(TestCase):
 | 
			
		||||
            )
 | 
			
		||||
            self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
 | 
			
		||||
            user_list.assert_called_once()
 | 
			
		||||
 | 
			
		||||
    def test_connect_manual(self):
 | 
			
		||||
        """test manual user connection"""
 | 
			
		||||
        uid = generate_id()
 | 
			
		||||
        self.app.backchannel_providers.remove(self.provider)
 | 
			
		||||
        admin = create_test_admin_user()
 | 
			
		||||
        different_user = User.objects.create(
 | 
			
		||||
            username=uid,
 | 
			
		||||
            email=f"{uid}@goauthentik.io",
 | 
			
		||||
        )
 | 
			
		||||
        self.app.backchannel_providers.add(self.provider)
 | 
			
		||||
        with (
 | 
			
		||||
            patch(
 | 
			
		||||
                "authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
 | 
			
		||||
                MagicMock(return_value={"credentials": self.creds}),
 | 
			
		||||
            ),
 | 
			
		||||
            patch(
 | 
			
		||||
                "msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
 | 
			
		||||
                AsyncMock(
 | 
			
		||||
                    return_value=OrganizationCollectionResponse(
 | 
			
		||||
                        value=[
 | 
			
		||||
                            Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
 | 
			
		||||
                        ]
 | 
			
		||||
                    )
 | 
			
		||||
                ),
 | 
			
		||||
            ),
 | 
			
		||||
            patch(
 | 
			
		||||
                "authentik.enterprise.providers.microsoft_entra.clients.users.MicrosoftEntraUserClient.update_single_attribute",
 | 
			
		||||
                MagicMock(),
 | 
			
		||||
            ) as user_get,
 | 
			
		||||
        ):
 | 
			
		||||
            self.client.force_login(admin)
 | 
			
		||||
            response = self.client.post(
 | 
			
		||||
                reverse("authentik_api:microsoftentraprovideruser-list"),
 | 
			
		||||
                data={
 | 
			
		||||
                    "microsoft_id": generate_id(),
 | 
			
		||||
                    "user": different_user.pk,
 | 
			
		||||
                    "provider": self.provider.pk,
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
            self.assertEqual(response.status_code, 201)
 | 
			
		||||
            user_get.assert_called_once()
 | 
			
		||||
 | 
			
		||||
@ -3,12 +3,12 @@
 | 
			
		||||
from django_filters.rest_framework.backends import DjangoFilterBackend
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
 | 
			
		||||
from authentik.core.api.groups import GroupMemberSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.enterprise.api import EnterpriseRequiredMixin
 | 
			
		||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
 | 
			
		||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
 | 
			
		||||
 | 
			
		||||
@ -8,11 +8,11 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche
 | 
			
		||||
from rest_framework.fields import SerializerMethodField
 | 
			
		||||
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 structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.core.models import Provider
 | 
			
		||||
from authentik.enterprise.api import EnterpriseRequiredMixin
 | 
			
		||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
{% extends "base/skeleton.html" %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% static 'dist/enterprise/rac/index.js' %}?version={{ version }}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/enterprise/rac/index-%v.js" %}
 | 
			
		||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
 | 
			
		||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
 | 
			
		||||
<link rel="icon" href="{{ tenant.branding_favicon }}">
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,6 @@ from channels.sessions import CookieMiddleware
 | 
			
		||||
from django.urls import path
 | 
			
		||||
from django.views.decorators.csrf import ensure_csrf_cookie
 | 
			
		||||
 | 
			
		||||
from authentik.core.channels import TokenOutpostMiddleware
 | 
			
		||||
from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet
 | 
			
		||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet
 | 
			
		||||
from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet
 | 
			
		||||
@ -13,6 +12,7 @@ from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet
 | 
			
		||||
from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer
 | 
			
		||||
from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer
 | 
			
		||||
from authentik.enterprise.providers.rac.views import RACInterface, RACStartView
 | 
			
		||||
from authentik.outposts.channels import TokenOutpostMiddleware
 | 
			
		||||
from authentik.root.asgi_middleware import SessionMiddleware
 | 
			
		||||
from authentik.root.middleware import ChannelsLoggingMiddleware
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -15,12 +15,11 @@ from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import DictField, IntegerField
 | 
			
		||||
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 authentik.admin.api.metrics import CoordinateSerializer
 | 
			
		||||
from authentik.core.api.object_types import TypeCreateSerializer
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
"""NotificationWebhookMapping API Views"""
 | 
			
		||||
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.events.models import NotificationWebhookMapping
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
"""NotificationRule API Views"""
 | 
			
		||||
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.groups import GroupSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.events.models import NotificationRule
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -9,11 +9,10 @@ from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.fields import CharField, ListField, SerializerMethodField
 | 
			
		||||
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 authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.events.models import (
 | 
			
		||||
    Event,
 | 
			
		||||
    Notification,
 | 
			
		||||
 | 
			
		||||
@ -9,11 +9,11 @@ from rest_framework.fields import ReadOnlyField
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
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.authorization import OwnerFilter, OwnerPermissions
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.events.api.events import EventSerializer
 | 
			
		||||
from authentik.events.models import Notification
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -16,10 +16,10 @@ from rest_framework.fields import (
 | 
			
		||||
)
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ReadOnlyModelViewSet
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.events.logs import LogEventSerializer
 | 
			
		||||
from authentik.events.models import SystemTask, TaskStatus
 | 
			
		||||
from authentik.rbac.decorators import permission_required
 | 
			
		||||
 | 
			
		||||
@ -35,6 +35,7 @@ IGNORED_MODELS = tuple(
 | 
			
		||||
 | 
			
		||||
_CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None)
 | 
			
		||||
_CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False)
 | 
			
		||||
_CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_events_log_request", default=None)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def should_log_model(model: Model) -> bool:
 | 
			
		||||
@ -149,11 +150,13 @@ class AuditMiddleware:
 | 
			
		||||
        m2m_changed.disconnect(dispatch_uid=request.request_id)
 | 
			
		||||
 | 
			
		||||
    def __call__(self, request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        _CTX_REQUEST.set(request)
 | 
			
		||||
        self.connect(request)
 | 
			
		||||
 | 
			
		||||
        response = self.get_response(request)
 | 
			
		||||
 | 
			
		||||
        self.disconnect(request)
 | 
			
		||||
        _CTX_REQUEST.set(None)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    def process_exception(self, request: HttpRequest, exception: Exception):
 | 
			
		||||
@ -167,7 +170,7 @@ class AuditMiddleware:
 | 
			
		||||
            thread = EventNewThread(
 | 
			
		||||
                EventAction.SUSPICIOUS_REQUEST,
 | 
			
		||||
                request,
 | 
			
		||||
                message=str(exception),
 | 
			
		||||
                message=exception_to_string(exception),
 | 
			
		||||
            )
 | 
			
		||||
            thread.run()
 | 
			
		||||
        elif before_send({}, {"exc_info": (None, exception, None)}) is not None:
 | 
			
		||||
@ -192,6 +195,8 @@ class AuditMiddleware:
 | 
			
		||||
            return
 | 
			
		||||
        if _CTX_IGNORE.get():
 | 
			
		||||
            return
 | 
			
		||||
        if request.request_id != _CTX_REQUEST.get().request_id:
 | 
			
		||||
            return
 | 
			
		||||
        user = self.get_user(request)
 | 
			
		||||
 | 
			
		||||
        action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
 | 
			
		||||
@ -205,6 +210,8 @@ class AuditMiddleware:
 | 
			
		||||
            return
 | 
			
		||||
        if _CTX_IGNORE.get():
 | 
			
		||||
            return
 | 
			
		||||
        if request.request_id != _CTX_REQUEST.get().request_id:
 | 
			
		||||
            return
 | 
			
		||||
        user = self.get_user(request)
 | 
			
		||||
 | 
			
		||||
        EventNewThread(
 | 
			
		||||
@ -230,6 +237,8 @@ class AuditMiddleware:
 | 
			
		||||
            return
 | 
			
		||||
        if _CTX_IGNORE.get():
 | 
			
		||||
            return
 | 
			
		||||
        if request.request_id != _CTX_REQUEST.get().request_id:
 | 
			
		||||
            return
 | 
			
		||||
        user = self.get_user(request)
 | 
			
		||||
 | 
			
		||||
        EventNewThread(
 | 
			
		||||
 | 
			
		||||
@ -238,6 +238,8 @@ class Event(SerializerModel, ExpiringModel):
 | 
			
		||||
                "args": cleanse_dict(QueryDict(request.META.get("QUERY_STRING", ""))),
 | 
			
		||||
                "user_agent": request.META.get("HTTP_USER_AGENT", ""),
 | 
			
		||||
            }
 | 
			
		||||
            if hasattr(request, "request_id"):
 | 
			
		||||
                self.context["http_request"]["request_id"] = request.request_id
 | 
			
		||||
            # Special case for events created during flow execution
 | 
			
		||||
            # since they keep the http query within a wrapped query
 | 
			
		||||
            if QS_QUERY in self.context["http_request"]["args"]:
 | 
			
		||||
 | 
			
		||||
@ -75,7 +75,10 @@ def on_login_failed(
 | 
			
		||||
    **kwargs,
 | 
			
		||||
):
 | 
			
		||||
    """Failed Login, authentik custom event"""
 | 
			
		||||
    Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http(request)
 | 
			
		||||
    user = User.objects.filter(username=credentials.get("username")).first()
 | 
			
		||||
    Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http(
 | 
			
		||||
        request, user
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(invitation_used)
 | 
			
		||||
 | 
			
		||||
@ -3,10 +3,10 @@
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.flows.api.stages import StageSerializer
 | 
			
		||||
from authentik.flows.models import FlowStageBinding
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,18 +7,22 @@ from django.utils.translation import gettext as _
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField, ReadOnlyField
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField, ReadOnlyField, SerializerMethodField
 | 
			
		||||
from rest_framework.parsers import MultiPartParser
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.blueprints.v1.exporter import FlowExporter
 | 
			
		||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, Importer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import CacheSerializer, LinkSerializer, PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import (
 | 
			
		||||
    CacheSerializer,
 | 
			
		||||
    LinkSerializer,
 | 
			
		||||
    ModelSerializer,
 | 
			
		||||
    PassiveSerializer,
 | 
			
		||||
)
 | 
			
		||||
from authentik.events.logs import LogEventSerializer
 | 
			
		||||
from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer
 | 
			
		||||
from authentik.flows.exceptions import FlowNonApplicableException
 | 
			
		||||
 | 
			
		||||
@ -4,15 +4,15 @@ from django.urls.base import reverse
 | 
			
		||||
from drf_spectacular.utils import extend_schema
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import SerializerMethodField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.object_types import TypesMixin
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
 | 
			
		||||
from authentik.core.types import UserSettingSerializer
 | 
			
		||||
from authentik.flows.api.flows import FlowSetSerializer
 | 
			
		||||
from authentik.flows.models import ConfigurableStage, Stage
 | 
			
		||||
 | 
			
		||||
@ -21,7 +21,9 @@ def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEd
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    if users.exists():
 | 
			
		||||
        Flow.objects.filter(slug="initial-setup").update(authentication="require_superuser")
 | 
			
		||||
        Flow.objects.using(db_alias).filter(slug="initial-setup").update(
 | 
			
		||||
            authentication="require_superuser"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										54
									
								
								authentik/flows/templates/if/flow-sfe.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								authentik/flows/templates/if/flow-sfe.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
 | 
			
		||||
<html lang="en">
 | 
			
		||||
    <head>
 | 
			
		||||
        <meta charset="UTF-8">
 | 
			
		||||
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
 | 
			
		||||
        <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
 | 
			
		||||
        <link rel="icon" href="{{ brand.branding_favicon }}">
 | 
			
		||||
        <link rel="shortcut icon" href="{{ brand.branding_favicon }}">
 | 
			
		||||
        {% block head_before %}
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}">
 | 
			
		||||
        <meta name="sentry-trace" content="{{ sentry_trace }}" />
 | 
			
		||||
        {% include "base/header_js.html" %}
 | 
			
		||||
        <style>
 | 
			
		||||
          html,
 | 
			
		||||
          body {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
          }
 | 
			
		||||
          body {
 | 
			
		||||
            background-image: url("{{ flow.background_url }}");
 | 
			
		||||
            background-repeat: no-repeat;
 | 
			
		||||
            background-size: cover;
 | 
			
		||||
          }
 | 
			
		||||
          .card {
 | 
			
		||||
            padding: 3rem;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          .form-signin {
 | 
			
		||||
            max-width: 330px;
 | 
			
		||||
            padding: 1rem;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          .form-signin .form-floating:focus-within {
 | 
			
		||||
            z-index: 2;
 | 
			
		||||
          }
 | 
			
		||||
          .brand-icon {
 | 
			
		||||
            max-width: 100%;
 | 
			
		||||
          }
 | 
			
		||||
        </style>
 | 
			
		||||
    </head>
 | 
			
		||||
    <body class="d-flex align-items-center py-4 bg-body-tertiary">
 | 
			
		||||
      <div class="card m-auto">
 | 
			
		||||
        <main class="form-signin w-100 m-auto" id="flow-sfe-container">
 | 
			
		||||
        </main>
 | 
			
		||||
        <span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      <script src="{% static 'dist/sfe/index.js' %}"></script>
 | 
			
		||||
    </body>
 | 
			
		||||
</html>
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
{% extends "base/skeleton.html" %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
{% block head_before %}
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
@ -17,7 +18,7 @@ window.authentik.flow = {
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% static 'dist/flow/FlowInterface.js' %}?version={{ version }}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/flow/FlowInterface-%v.js" %}
 | 
			
		||||
<style>
 | 
			
		||||
:root {
 | 
			
		||||
    --ak-flow-background: url("{{ flow.background_url }}");
 | 
			
		||||
							
								
								
									
										41
									
								
								authentik/flows/views/interface.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								authentik/flows/views/interface.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,41 @@
 | 
			
		||||
"""Interface views"""
 | 
			
		||||
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from ua_parser.user_agent_parser import Parse
 | 
			
		||||
 | 
			
		||||
from authentik.core.views.interface import InterfaceView
 | 
			
		||||
from authentik.flows.models import Flow
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FlowInterfaceView(InterfaceView):
 | 
			
		||||
    """Flow interface"""
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
 | 
			
		||||
        kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
 | 
			
		||||
        kwargs["inspector"] = "inspector" in self.request.GET
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
    def compat_needs_sfe(self) -> bool:
 | 
			
		||||
        """Check if we need to use the simplified flow executor for compatibility"""
 | 
			
		||||
        ua = Parse(self.request.META.get("HTTP_USER_AGENT", ""))
 | 
			
		||||
        if ua["user_agent"]["family"] == "IE":
 | 
			
		||||
            return True
 | 
			
		||||
        # Only use SFE for Edge 18 and older, after Edge 18 MS switched to chromium which supports
 | 
			
		||||
        # the default flow executor
 | 
			
		||||
        if (
 | 
			
		||||
            ua["user_agent"]["family"] == "Edge"
 | 
			
		||||
            and int(ua["user_agent"]["major"]) <= 18  # noqa: PLR2004
 | 
			
		||||
        ):  # noqa: PLR2004
 | 
			
		||||
            return True
 | 
			
		||||
        # https://github.com/AzureAD/microsoft-authentication-library-for-objc
 | 
			
		||||
        # Used by Microsoft Teams/Office on macOS, and also uses a very outdated browser engine
 | 
			
		||||
        if "PKeyAuth" in ua["string"]:
 | 
			
		||||
            return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def get_template_names(self) -> list[str]:
 | 
			
		||||
        if self.compat_needs_sfe() or "sfe" in self.request.GET:
 | 
			
		||||
            return ["if/flow-sfe.html"]
 | 
			
		||||
        return ["if/flow.html"]
 | 
			
		||||
@ -50,7 +50,6 @@ cache:
 | 
			
		||||
  timeout: 300
 | 
			
		||||
  timeout_flows: 300
 | 
			
		||||
  timeout_policies: 300
 | 
			
		||||
  timeout_reputation: 300
 | 
			
		||||
 | 
			
		||||
# channel:
 | 
			
		||||
#   url: ""
 | 
			
		||||
@ -116,6 +115,9 @@ events:
 | 
			
		||||
  context_processors:
 | 
			
		||||
    geoip: "/geoip/GeoLite2-City.mmdb"
 | 
			
		||||
    asn: "/geoip/GeoLite2-ASN.mmdb"
 | 
			
		||||
compliance:
 | 
			
		||||
  fips:
 | 
			
		||||
    enabled: false
 | 
			
		||||
 | 
			
		||||
cert_discovery_dir: /certs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -19,6 +19,7 @@ from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.events.models import Event
 | 
			
		||||
from authentik.lib.expression.exceptions import ControlFlowException
 | 
			
		||||
from authentik.lib.utils.http import get_http_session
 | 
			
		||||
from authentik.policies.models import Policy, PolicyBinding
 | 
			
		||||
from authentik.policies.process import PolicyProcess
 | 
			
		||||
@ -216,7 +217,8 @@ class BaseEvaluator:
 | 
			
		||||
                # so the user only sees information relevant to them
 | 
			
		||||
                # and none of our surrounding error handling
 | 
			
		||||
                exc.__traceback__ = exc.__traceback__.tb_next
 | 
			
		||||
                self.handle_error(exc, expression_source)
 | 
			
		||||
                if not isinstance(exc, ControlFlowException):
 | 
			
		||||
                    self.handle_error(exc, expression_source)
 | 
			
		||||
                raise exc
 | 
			
		||||
            return result
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								authentik/lib/expression/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								authentik/lib/expression/exceptions.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ControlFlowException(SentryIgnoredException):
 | 
			
		||||
    """Exceptions used to control the flow from exceptions, not reported as a warning/
 | 
			
		||||
    error in logs"""
 | 
			
		||||
@ -4,8 +4,11 @@ from django.db.models import QuerySet
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
 | 
			
		||||
from authentik.core.expression.evaluator import PropertyMappingEvaluator
 | 
			
		||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
 | 
			
		||||
from authentik.core.expression.exceptions import (
 | 
			
		||||
    PropertyMappingExpressionException,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.models import PropertyMapping, User
 | 
			
		||||
from authentik.lib.expression.exceptions import ControlFlowException
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PropertyMappingManager:
 | 
			
		||||
@ -57,7 +60,7 @@ class PropertyMappingManager:
 | 
			
		||||
            mapping.set_context(user, request, **kwargs)
 | 
			
		||||
            try:
 | 
			
		||||
                value = mapping.evaluate(mapping.model.expression)
 | 
			
		||||
            except PropertyMappingExpressionException as exc:
 | 
			
		||||
            except (PropertyMappingExpressionException, ControlFlowException) as exc:
 | 
			
		||||
                raise exc from exc
 | 
			
		||||
            except Exception as exc:
 | 
			
		||||
                raise PropertyMappingExpressionException(exc, mapping.model) from exc
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,7 @@ from rest_framework.fields import BooleanField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.events.api.tasks import SystemTaskSerializer
 | 
			
		||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
 | 
			
		||||
 | 
			
		||||
@ -54,3 +54,17 @@ class OutgoingSyncProviderStatusMixin:
 | 
			
		||||
                "is_running": not lock_acquired,
 | 
			
		||||
            }
 | 
			
		||||
        return Response(SyncStatusSerializer(status).data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OutgoingSyncConnectionCreateMixin:
 | 
			
		||||
    """Mixin for connection objects that fetches remote data upon creation"""
 | 
			
		||||
 | 
			
		||||
    def perform_create(self, serializer: ModelSerializer):
 | 
			
		||||
        super().perform_create(serializer)
 | 
			
		||||
        try:
 | 
			
		||||
            instance = serializer.instance
 | 
			
		||||
            client = instance.provider.client_for_model(instance.__class__)
 | 
			
		||||
            client.update_single_attribute(instance)
 | 
			
		||||
            instance.save()
 | 
			
		||||
        except NotImplementedError:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
@ -9,9 +9,9 @@ from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.expression.exceptions import (
 | 
			
		||||
    PropertyMappingExpressionException,
 | 
			
		||||
    SkipObjectException,
 | 
			
		||||
)
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.lib.expression.exceptions import ControlFlowException
 | 
			
		||||
from authentik.lib.sync.mapper import PropertyMappingManager
 | 
			
		||||
from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, StopSync
 | 
			
		||||
from authentik.lib.utils.errors import exception_to_string
 | 
			
		||||
@ -92,7 +92,7 @@ class BaseOutgoingSyncClient[
 | 
			
		||||
            eval_kwargs.setdefault("user", None)
 | 
			
		||||
            for value in self.mapper.iter_eval(**eval_kwargs):
 | 
			
		||||
                always_merger.merge(raw_final_object, value)
 | 
			
		||||
        except SkipObjectException as exc:
 | 
			
		||||
        except ControlFlowException as exc:
 | 
			
		||||
            raise exc from exc
 | 
			
		||||
        except PropertyMappingExpressionException as exc:
 | 
			
		||||
            # Value error can be raised when assigning invalid data to an attribute
 | 
			
		||||
@ -114,3 +114,8 @@ class BaseOutgoingSyncClient[
 | 
			
		||||
        pre-link any users/groups in the remote system with the respective
 | 
			
		||||
        object in authentik based on a common identifier"""
 | 
			
		||||
        raise NotImplementedError()
 | 
			
		||||
 | 
			
		||||
    def update_single_attribute(self, connection: TConnection):
 | 
			
		||||
        """Update connection attributes on a connection object, when the connection
 | 
			
		||||
        is manually created"""
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ from collections.abc import Callable
 | 
			
		||||
 | 
			
		||||
from django.core.paginator import Paginator
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
from django.db.models.query import Q
 | 
			
		||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Group, User
 | 
			
		||||
@ -34,7 +35,9 @@ def register_signals(
 | 
			
		||||
 | 
			
		||||
    def model_post_save(sender: type[Model], instance: User | Group, created: bool, **_):
 | 
			
		||||
        """Post save handler"""
 | 
			
		||||
        if not provider_type.objects.filter(backchannel_application__isnull=False).exists():
 | 
			
		||||
        if not provider_type.objects.filter(
 | 
			
		||||
            Q(backchannel_application__isnull=False) | Q(application__isnull=False)
 | 
			
		||||
        ).exists():
 | 
			
		||||
            return
 | 
			
		||||
        task_sync_direct.delay(class_to_path(instance.__class__), instance.pk, Direction.add.value)
 | 
			
		||||
 | 
			
		||||
@ -43,7 +46,9 @@ def register_signals(
 | 
			
		||||
 | 
			
		||||
    def model_pre_delete(sender: type[Model], instance: User | Group, **_):
 | 
			
		||||
        """Pre-delete handler"""
 | 
			
		||||
        if not provider_type.objects.filter(backchannel_application__isnull=False).exists():
 | 
			
		||||
        if not provider_type.objects.filter(
 | 
			
		||||
            Q(backchannel_application__isnull=False) | Q(application__isnull=False)
 | 
			
		||||
        ).exists():
 | 
			
		||||
            return
 | 
			
		||||
        task_sync_direct.delay(
 | 
			
		||||
            class_to_path(instance.__class__), instance.pk, Direction.remove.value
 | 
			
		||||
@ -58,7 +63,9 @@ def register_signals(
 | 
			
		||||
        """Sync group membership"""
 | 
			
		||||
        if action not in ["post_add", "post_remove"]:
 | 
			
		||||
            return
 | 
			
		||||
        if not provider_type.objects.filter(backchannel_application__isnull=False).exists():
 | 
			
		||||
        if not provider_type.objects.filter(
 | 
			
		||||
            Q(backchannel_application__isnull=False) | Q(application__isnull=False)
 | 
			
		||||
        ).exists():
 | 
			
		||||
            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
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,7 @@ from celery.exceptions import Retry
 | 
			
		||||
from celery.result import allow_join_result
 | 
			
		||||
from django.core.paginator import Paginator
 | 
			
		||||
from django.db.models import Model, QuerySet
 | 
			
		||||
from django.db.models.query import Q
 | 
			
		||||
from django.utils.text import slugify
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from structlog.stdlib import BoundLogger, get_logger
 | 
			
		||||
@ -37,7 +38,9 @@ class SyncTasks:
 | 
			
		||||
        self._provider_model = provider_model
 | 
			
		||||
 | 
			
		||||
    def sync_all(self, single_sync: Callable[[int], None]):
 | 
			
		||||
        for provider in self._provider_model.objects.filter(backchannel_application__isnull=False):
 | 
			
		||||
        for provider in self._provider_model.objects.filter(
 | 
			
		||||
            Q(backchannel_application__isnull=False) | Q(application__isnull=False)
 | 
			
		||||
        ):
 | 
			
		||||
            self.trigger_single_task(provider, single_sync)
 | 
			
		||||
 | 
			
		||||
    def trigger_single_task(self, provider: OutgoingSyncProvider, sync_task: Callable[[int], None]):
 | 
			
		||||
@ -62,7 +65,8 @@ class SyncTasks:
 | 
			
		||||
            provider_pk=provider_pk,
 | 
			
		||||
        )
 | 
			
		||||
        provider = self._provider_model.objects.filter(
 | 
			
		||||
            pk=provider_pk, backchannel_application__isnull=False
 | 
			
		||||
            Q(backchannel_application__isnull=False) | Q(application__isnull=False),
 | 
			
		||||
            pk=provider_pk,
 | 
			
		||||
        ).first()
 | 
			
		||||
        if not provider:
 | 
			
		||||
            return
 | 
			
		||||
@ -204,7 +208,9 @@ class SyncTasks:
 | 
			
		||||
        if not instance:
 | 
			
		||||
            return
 | 
			
		||||
        operation = Direction(raw_op)
 | 
			
		||||
        for provider in self._provider_model.objects.filter(backchannel_application__isnull=False):
 | 
			
		||||
        for provider in self._provider_model.objects.filter(
 | 
			
		||||
            Q(backchannel_application__isnull=False) | Q(application__isnull=False)
 | 
			
		||||
        ):
 | 
			
		||||
            client = provider.client_for_model(instance.__class__)
 | 
			
		||||
            # Check if the object is allowed within the provider's restrictions
 | 
			
		||||
            queryset = provider.get_object_qs(instance.__class__)
 | 
			
		||||
@ -223,6 +229,8 @@ class SyncTasks:
 | 
			
		||||
                    client.delete(instance)
 | 
			
		||||
            except TransientSyncException as exc:
 | 
			
		||||
                raise Retry() from exc
 | 
			
		||||
            except SkipObjectException:
 | 
			
		||||
                continue
 | 
			
		||||
            except StopSync as exc:
 | 
			
		||||
                self.logger.warning(exc, provider_pk=provider.pk)
 | 
			
		||||
 | 
			
		||||
@ -233,7 +241,9 @@ class SyncTasks:
 | 
			
		||||
        group = Group.objects.filter(pk=group_pk).first()
 | 
			
		||||
        if not group:
 | 
			
		||||
            return
 | 
			
		||||
        for provider in self._provider_model.objects.filter(backchannel_application__isnull=False):
 | 
			
		||||
        for provider in self._provider_model.objects.filter(
 | 
			
		||||
            Q(backchannel_application__isnull=False) | Q(application__isnull=False)
 | 
			
		||||
        ):
 | 
			
		||||
            # Check if the object is allowed within the provider's restrictions
 | 
			
		||||
            queryset: QuerySet = provider.get_object_qs(Group)
 | 
			
		||||
            # The queryset we get from the provider must include the instance we've got given
 | 
			
		||||
@ -251,5 +261,7 @@ class SyncTasks:
 | 
			
		||||
                client.update_group(group, operation, pk_set)
 | 
			
		||||
            except TransientSyncException as exc:
 | 
			
		||||
                raise Retry() from exc
 | 
			
		||||
            except SkipObjectException:
 | 
			
		||||
                continue
 | 
			
		||||
            except StopSync as exc:
 | 
			
		||||
                self.logger.warning(exc, provider_pk=provider.pk)
 | 
			
		||||
 | 
			
		||||
@ -6,19 +6,21 @@ from django_filters.filters import ModelMultipleChoiceFilter
 | 
			
		||||
from django_filters.filterset import FilterSet
 | 
			
		||||
from drf_spectacular.utils import extend_schema
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField, DateTimeField
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField, DateTimeField, SerializerMethodField
 | 
			
		||||
from rest_framework.relations import PrimaryKeyRelatedField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, ValidationError
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik import get_build_hash
 | 
			
		||||
from authentik.core.api.providers import ProviderSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import JSONDictField, PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.core.models import Provider
 | 
			
		||||
from authentik.enterprise.license import LicenseKey
 | 
			
		||||
from authentik.enterprise.providers.rac.models import RACProvider
 | 
			
		||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
 | 
			
		||||
from authentik.outposts.api.service_connections import ServiceConnectionSerializer
 | 
			
		||||
from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME
 | 
			
		||||
from authentik.outposts.models import (
 | 
			
		||||
@ -48,6 +50,10 @@ class OutpostSerializer(ModelSerializer):
 | 
			
		||||
    service_connection_obj = ServiceConnectionSerializer(
 | 
			
		||||
        source="service_connection", read_only=True
 | 
			
		||||
    )
 | 
			
		||||
    refresh_interval_s = SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    def get_refresh_interval_s(self, obj: Outpost) -> int:
 | 
			
		||||
        return int(timedelta_from_string(obj.config.refresh_interval).total_seconds())
 | 
			
		||||
 | 
			
		||||
    def validate_name(self, name: str) -> str:
 | 
			
		||||
        """Validate name (especially for embedded outpost)"""
 | 
			
		||||
@ -83,7 +89,8 @@ class OutpostSerializer(ModelSerializer):
 | 
			
		||||
    def validate_config(self, config) -> dict:
 | 
			
		||||
        """Check that the config has all required fields"""
 | 
			
		||||
        try:
 | 
			
		||||
            from_dict(OutpostConfig, config)
 | 
			
		||||
            parsed = from_dict(OutpostConfig, config)
 | 
			
		||||
            timedelta_string_validator(parsed.refresh_interval)
 | 
			
		||||
        except DaciteError as exc:
 | 
			
		||||
            raise ValidationError(f"Failed to validate config: {str(exc)}") from exc
 | 
			
		||||
        return config
 | 
			
		||||
@ -98,6 +105,7 @@ class OutpostSerializer(ModelSerializer):
 | 
			
		||||
            "providers_obj",
 | 
			
		||||
            "service_connection",
 | 
			
		||||
            "service_connection_obj",
 | 
			
		||||
            "refresh_interval_s",
 | 
			
		||||
            "token_identifier",
 | 
			
		||||
            "config",
 | 
			
		||||
            "managed",
 | 
			
		||||
@ -120,7 +128,7 @@ class OutpostHealthSerializer(PassiveSerializer):
 | 
			
		||||
    golang_version = CharField(read_only=True)
 | 
			
		||||
    openssl_enabled = BooleanField(read_only=True)
 | 
			
		||||
    openssl_version = CharField(read_only=True)
 | 
			
		||||
    fips_enabled = BooleanField(read_only=True)
 | 
			
		||||
    fips_enabled = SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    version_should = CharField(read_only=True)
 | 
			
		||||
    version_outdated = BooleanField(read_only=True)
 | 
			
		||||
@ -130,6 +138,12 @@ class OutpostHealthSerializer(PassiveSerializer):
 | 
			
		||||
 | 
			
		||||
    hostname = CharField(read_only=True, required=False)
 | 
			
		||||
 | 
			
		||||
    def get_fips_enabled(self, obj: dict) -> bool | None:
 | 
			
		||||
        """Get FIPS enabled"""
 | 
			
		||||
        if not LicenseKey.get_total().is_valid():
 | 
			
		||||
            return None
 | 
			
		||||
        return obj["fips_enabled"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OutpostFilter(FilterSet):
 | 
			
		||||
    """Filter for Outposts"""
 | 
			
		||||
 | 
			
		||||
@ -12,13 +12,13 @@ from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField, ReadOnlyField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.object_types import TypesMixin
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import (
 | 
			
		||||
    MetaNameSerializer,
 | 
			
		||||
    ModelSerializer,
 | 
			
		||||
    PassiveSerializer,
 | 
			
		||||
)
 | 
			
		||||
from authentik.outposts.models import (
 | 
			
		||||
 | 
			
		||||
@ -13,16 +13,17 @@ import authentik.outposts.models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    User = apps.get_model("authentik_core", "User")
 | 
			
		||||
    Token = apps.get_model("authentik_core", "Token")
 | 
			
		||||
    from authentik.outposts.models import Outpost
 | 
			
		||||
 | 
			
		||||
    for outpost in Outpost.objects.using(schema_editor.connection.alias).all().only("pk"):
 | 
			
		||||
    for outpost in Outpost.objects.using(db_alias).all().only("pk"):
 | 
			
		||||
        user_identifier = outpost.user_identifier
 | 
			
		||||
        users = User.objects.filter(username=user_identifier)
 | 
			
		||||
        users = User.objects.using(db_alias).filter(username=user_identifier)
 | 
			
		||||
        if not users.exists():
 | 
			
		||||
            continue
 | 
			
		||||
        tokens = Token.objects.filter(user=users.first())
 | 
			
		||||
        tokens = Token.objects.using(db_alias).filter(user=users.first())
 | 
			
		||||
        for token in tokens:
 | 
			
		||||
            if token.identifier != outpost.token_identifier:
 | 
			
		||||
                token.identifier = outpost.token_identifier
 | 
			
		||||
@ -37,8 +38,8 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE
 | 
			
		||||
        "authentik_outposts", "KubernetesServiceConnection"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    docker = DockerServiceConnection.objects.filter(local=True).first()
 | 
			
		||||
    k8s = KubernetesServiceConnection.objects.filter(local=True).first()
 | 
			
		||||
    docker = DockerServiceConnection.objects.using(db_alias).filter(local=True).first()
 | 
			
		||||
    k8s = KubernetesServiceConnection.objects.using(db_alias).filter(local=True).first()
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        for outpost in Outpost.objects.using(db_alias).all().exclude(deployment_type="custom"):
 | 
			
		||||
@ -54,21 +55,21 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
			
		||||
    alias = schema_editor.connection.alias
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    User = apps.get_model("authentik_core", "User")
 | 
			
		||||
    Outpost = apps.get_model("authentik_outposts", "Outpost")
 | 
			
		||||
 | 
			
		||||
    for outpost in Outpost.objects.using(alias).all():
 | 
			
		||||
        matching = User.objects.using(alias).filter(username=f"pb-outpost-{outpost.uuid.hex}")
 | 
			
		||||
    for outpost in Outpost.objects.using(db_alias).all():
 | 
			
		||||
        matching = User.objects.using(db_alias).filter(username=f"pb-outpost-{outpost.uuid.hex}")
 | 
			
		||||
        if matching.exists():
 | 
			
		||||
            matching.delete()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_config_prefix(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
			
		||||
    alias = schema_editor.connection.alias
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    Outpost = apps.get_model("authentik_outposts", "Outpost")
 | 
			
		||||
 | 
			
		||||
    for outpost in Outpost.objects.using(alias).all():
 | 
			
		||||
    for outpost in Outpost.objects.using(db_alias).all():
 | 
			
		||||
        config = outpost._config
 | 
			
		||||
        for key in list(config):
 | 
			
		||||
            if "passbook" in key:
 | 
			
		||||
 | 
			
		||||
@ -61,6 +61,7 @@ class OutpostConfig:
 | 
			
		||||
 | 
			
		||||
    log_level: str = CONFIG.get("log_level")
 | 
			
		||||
    object_naming_template: str = field(default="ak-outpost-%(name)s")
 | 
			
		||||
    refresh_interval: str = "minutes=5"
 | 
			
		||||
 | 
			
		||||
    container_image: str | None = field(default=None)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,6 @@
 | 
			
		||||
 | 
			
		||||
from dataclasses import asdict
 | 
			
		||||
 | 
			
		||||
from channels.exceptions import DenyConnection
 | 
			
		||||
from channels.routing import URLRouter
 | 
			
		||||
from channels.testing import WebsocketCommunicator
 | 
			
		||||
from django.test import TransactionTestCase
 | 
			
		||||
@ -37,9 +36,8 @@ class TestOutpostWS(TransactionTestCase):
 | 
			
		||||
        communicator = WebsocketCommunicator(
 | 
			
		||||
            URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/"
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(DenyConnection):
 | 
			
		||||
            connected, _ = await communicator.connect()
 | 
			
		||||
            self.assertFalse(connected)
 | 
			
		||||
        connected, _ = await communicator.connect()
 | 
			
		||||
        self.assertFalse(connected)
 | 
			
		||||
 | 
			
		||||
    async def test_auth_valid(self):
 | 
			
		||||
        """Test auth with token"""
 | 
			
		||||
 | 
			
		||||
@ -2,13 +2,13 @@
 | 
			
		||||
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 | 
			
		||||
from authentik.core.channels import TokenOutpostMiddleware
 | 
			
		||||
from authentik.outposts.api.outposts import OutpostViewSet
 | 
			
		||||
from authentik.outposts.api.service_connections import (
 | 
			
		||||
    DockerServiceConnectionViewSet,
 | 
			
		||||
    KubernetesServiceConnectionViewSet,
 | 
			
		||||
    ServiceConnectionViewSet,
 | 
			
		||||
)
 | 
			
		||||
from authentik.outposts.channels import TokenOutpostMiddleware
 | 
			
		||||
from authentik.outposts.consumer import OutpostConsumer
 | 
			
		||||
from authentik.root.middleware import ChannelsLoggingMiddleware
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,13 +5,15 @@ from collections import OrderedDict
 | 
			
		||||
from django.core.exceptions import ObjectDoesNotExist
 | 
			
		||||
from django_filters.filters import BooleanFilter, ModelMultipleChoiceFilter
 | 
			
		||||
from django_filters.filterset import FilterSet
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.serializers import PrimaryKeyRelatedField
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.groups import GroupSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.users import UserSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.policies.api.policies import PolicySerializer
 | 
			
		||||
from authentik.policies.models import PolicyBinding, PolicyBindingModel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,9 +6,9 @@ from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
			
		||||
from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import SerializerMethodField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import (
 | 
			
		||||
    CacheSerializer,
 | 
			
		||||
    MetaNameSerializer,
 | 
			
		||||
    ModelSerializer,
 | 
			
		||||
)
 | 
			
		||||
from authentik.events.logs import LogEventSerializer, capture_logs
 | 
			
		||||
from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer
 | 
			
		||||
 | 
			
		||||
@ -1,16 +1,22 @@
 | 
			
		||||
"""Reputation policy API Views"""
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django_filters.filters import BaseInFilter, CharFilter
 | 
			
		||||
from django_filters.filterset import FilterSet
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.policies.api.policies import PolicySerializer
 | 
			
		||||
from authentik.policies.reputation.models import Reputation, ReputationPolicy
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CharInFilter(BaseInFilter, CharFilter):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReputationPolicySerializer(PolicySerializer):
 | 
			
		||||
    """Reputation Policy Serializer"""
 | 
			
		||||
 | 
			
		||||
@ -38,6 +44,16 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
    ordering = ["name"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReputationFilter(FilterSet):
 | 
			
		||||
    """Filter for reputation"""
 | 
			
		||||
 | 
			
		||||
    identifier_in = CharInFilter(field_name="identifier", lookup_expr="in")
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Reputation
 | 
			
		||||
        fields = ["identifier", "ip", "score"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReputationSerializer(ModelSerializer):
 | 
			
		||||
    """Reputation Serializer"""
 | 
			
		||||
 | 
			
		||||
@ -66,5 +82,5 @@ class ReputationViewSet(
 | 
			
		||||
    queryset = Reputation.objects.all()
 | 
			
		||||
    serializer_class = ReputationSerializer
 | 
			
		||||
    search_fields = ["identifier", "ip", "score"]
 | 
			
		||||
    filterset_fields = ["identifier", "ip", "score"]
 | 
			
		||||
    filterset_class = ReputationFilter
 | 
			
		||||
    ordering = ["ip"]
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,6 @@
 | 
			
		||||
 | 
			
		||||
from authentik.blueprints.apps import ManagedAppConfig
 | 
			
		||||
 | 
			
		||||
CACHE_KEY_PREFIX = "goauthentik.io/policies/reputation/scores/"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthentikPolicyReputationConfig(ManagedAppConfig):
 | 
			
		||||
    """Authentik reputation app config"""
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,25 @@
 | 
			
		||||
# Generated by Django 5.0.6 on 2024-06-11 08:50
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies_reputation", "0006_reputation_ip_asn_data"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="reputation",
 | 
			
		||||
            index=models.Index(fields=["identifier"], name="authentik_p_identif_9434d7_idx"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="reputation",
 | 
			
		||||
            index=models.Index(fields=["ip"], name="authentik_p_ip_7ad0df_idx"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="reputation",
 | 
			
		||||
            index=models.Index(fields=["ip", "identifier"], name="authentik_p_ip_d779aa_idx"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -96,3 +96,8 @@ class Reputation(ExpiringModel, SerializerModel):
 | 
			
		||||
        verbose_name = _("Reputation Score")
 | 
			
		||||
        verbose_name_plural = _("Reputation Scores")
 | 
			
		||||
        unique_together = ("identifier", "ip")
 | 
			
		||||
        indexes = [
 | 
			
		||||
            models.Index(fields=["identifier"]),
 | 
			
		||||
            models.Index(fields=["ip"]),
 | 
			
		||||
            models.Index(fields=["ip", "identifier"]),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
@ -1,11 +0,0 @@
 | 
			
		||||
"""Reputation Settings"""
 | 
			
		||||
 | 
			
		||||
from celery.schedules import crontab
 | 
			
		||||
 | 
			
		||||
CELERY_BEAT_SCHEDULE = {
 | 
			
		||||
    "policies_reputation_save": {
 | 
			
		||||
        "task": "authentik.policies.reputation.tasks.save_reputation",
 | 
			
		||||
        "schedule": crontab(minute="1-59/5"),
 | 
			
		||||
        "options": {"queue": "authentik_scheduled"},
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
@ -1,40 +1,42 @@
 | 
			
		||||
"""authentik reputation request signals"""
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.signals import user_logged_in
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.db.models import F
 | 
			
		||||
from django.dispatch import receiver
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.signals import login_failed
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.policies.reputation.apps import CACHE_KEY_PREFIX
 | 
			
		||||
from authentik.policies.reputation.tasks import save_reputation
 | 
			
		||||
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR
 | 
			
		||||
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
 | 
			
		||||
from authentik.policies.reputation.models import Reputation, reputation_expiry
 | 
			
		||||
from authentik.root.middleware import ClientIPMiddleware
 | 
			
		||||
from authentik.stages.identification.signals import identification_failed
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_reputation")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_score(request: HttpRequest, identifier: str, amount: int):
 | 
			
		||||
    """Update score for IP and User"""
 | 
			
		||||
    remote_ip = ClientIPMiddleware.get_client_ip(request)
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        # We only update the cache here, as its faster than writing to the DB
 | 
			
		||||
        score = cache.get_or_set(
 | 
			
		||||
            CACHE_KEY_PREFIX + remote_ip + "/" + identifier,
 | 
			
		||||
            {"ip": remote_ip, "identifier": identifier, "score": 0},
 | 
			
		||||
            CACHE_TIMEOUT,
 | 
			
		||||
    with transaction.atomic():
 | 
			
		||||
        reputation, created = Reputation.objects.select_for_update().get_or_create(
 | 
			
		||||
            ip=remote_ip,
 | 
			
		||||
            identifier=identifier,
 | 
			
		||||
            defaults={
 | 
			
		||||
                "score": amount,
 | 
			
		||||
                "ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {},
 | 
			
		||||
                "ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {},
 | 
			
		||||
                "expires": reputation_expiry(),
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        score["score"] += amount
 | 
			
		||||
        cache.set(CACHE_KEY_PREFIX + remote_ip + "/" + identifier, score)
 | 
			
		||||
    except ValueError as exc:
 | 
			
		||||
        LOGGER.warning("failed to set reputation", exc=exc)
 | 
			
		||||
 | 
			
		||||
        if not created:
 | 
			
		||||
            reputation.score = F("score") + amount
 | 
			
		||||
            reputation.save()
 | 
			
		||||
    LOGGER.debug("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip)
 | 
			
		||||
    save_reputation.delay()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(login_failed)
 | 
			
		||||
 | 
			
		||||
@ -1,32 +0,0 @@
 | 
			
		||||
"""Reputation tasks"""
 | 
			
		||||
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR
 | 
			
		||||
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
 | 
			
		||||
from authentik.events.models import TaskStatus
 | 
			
		||||
from authentik.events.system_tasks import SystemTask, prefill_task
 | 
			
		||||
from authentik.policies.reputation.apps import CACHE_KEY_PREFIX
 | 
			
		||||
from authentik.policies.reputation.models import Reputation
 | 
			
		||||
from authentik.root.celery import CELERY_APP
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@CELERY_APP.task(bind=True, base=SystemTask)
 | 
			
		||||
@prefill_task
 | 
			
		||||
def save_reputation(self: SystemTask):
 | 
			
		||||
    """Save currently cached reputation to database"""
 | 
			
		||||
    objects_to_update = []
 | 
			
		||||
    for _, score in cache.get_many(cache.keys(CACHE_KEY_PREFIX + "*")).items():
 | 
			
		||||
        rep, _ = Reputation.objects.get_or_create(
 | 
			
		||||
            ip=score["ip"],
 | 
			
		||||
            identifier=score["identifier"],
 | 
			
		||||
        )
 | 
			
		||||
        rep.ip_geo_data = GEOIP_CONTEXT_PROCESSOR.city_dict(score["ip"]) or {}
 | 
			
		||||
        rep.ip_asn_data = ASN_CONTEXT_PROCESSOR.asn_dict(score["ip"]) or {}
 | 
			
		||||
        rep.score = score["score"]
 | 
			
		||||
        objects_to_update.append(rep)
 | 
			
		||||
    Reputation.objects.bulk_update(objects_to_update, ["score", "ip_geo_data"])
 | 
			
		||||
    self.set_status(TaskStatus.SUCCESSFUL, "Successfully updated Reputation")
 | 
			
		||||
@ -1,14 +1,11 @@
 | 
			
		||||
"""test reputation signals and policy"""
 | 
			
		||||
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.test import RequestFactory, TestCase
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.policies.reputation.api import ReputationPolicySerializer
 | 
			
		||||
from authentik.policies.reputation.apps import CACHE_KEY_PREFIX
 | 
			
		||||
from authentik.policies.reputation.models import Reputation, ReputationPolicy
 | 
			
		||||
from authentik.policies.reputation.tasks import save_reputation
 | 
			
		||||
from authentik.policies.types import PolicyRequest
 | 
			
		||||
from authentik.stages.password import BACKEND_INBUILT
 | 
			
		||||
from authentik.stages.password.stage import authenticate
 | 
			
		||||
@ -22,8 +19,6 @@ class TestReputationPolicy(TestCase):
 | 
			
		||||
        self.request = self.request_factory.get("/")
 | 
			
		||||
        self.test_ip = "127.0.0.1"
 | 
			
		||||
        self.test_username = "test"
 | 
			
		||||
        keys = cache.keys(CACHE_KEY_PREFIX + "*")
 | 
			
		||||
        cache.delete_many(keys)
 | 
			
		||||
        # We need a user for the one-to-one in userreputation
 | 
			
		||||
        self.user = User.objects.create(username=self.test_username)
 | 
			
		||||
        self.backends = [BACKEND_INBUILT]
 | 
			
		||||
@ -34,13 +29,6 @@ class TestReputationPolicy(TestCase):
 | 
			
		||||
        authenticate(
 | 
			
		||||
            self.request, self.backends, username=self.test_username, password=self.test_username
 | 
			
		||||
        )
 | 
			
		||||
        # Test value in cache
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            cache.get(CACHE_KEY_PREFIX + self.test_ip + "/" + self.test_username),
 | 
			
		||||
            {"ip": "127.0.0.1", "identifier": "test", "score": -1},
 | 
			
		||||
        )
 | 
			
		||||
        # Save cache and check db values
 | 
			
		||||
        save_reputation.delay().get()
 | 
			
		||||
        self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1)
 | 
			
		||||
 | 
			
		||||
    def test_user_reputation(self):
 | 
			
		||||
@ -49,15 +37,17 @@ class TestReputationPolicy(TestCase):
 | 
			
		||||
        authenticate(
 | 
			
		||||
            self.request, self.backends, username=self.test_username, password=self.test_username
 | 
			
		||||
        )
 | 
			
		||||
        # Test value in cache
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            cache.get(CACHE_KEY_PREFIX + self.test_ip + "/" + self.test_username),
 | 
			
		||||
            {"ip": "127.0.0.1", "identifier": "test", "score": -1},
 | 
			
		||||
        )
 | 
			
		||||
        # Save cache and check db values
 | 
			
		||||
        save_reputation.delay().get()
 | 
			
		||||
        self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1)
 | 
			
		||||
 | 
			
		||||
    def test_update_reputation(self):
 | 
			
		||||
        """test reputation update"""
 | 
			
		||||
        Reputation.objects.create(identifier=self.test_username, ip=self.test_ip, score=43)
 | 
			
		||||
        # Trigger negative reputation
 | 
			
		||||
        authenticate(
 | 
			
		||||
            self.request, self.backends, username=self.test_username, password=self.test_username
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, 42)
 | 
			
		||||
 | 
			
		||||
    def test_policy(self):
 | 
			
		||||
        """Test Policy"""
 | 
			
		||||
        request = PolicyRequest(user=self.user)
 | 
			
		||||
 | 
			
		||||
@ -5,11 +5,11 @@ from django.db.models.query import Q
 | 
			
		||||
from django_filters.filters import BooleanFilter
 | 
			
		||||
from django_filters.filterset import FilterSet
 | 
			
		||||
from rest_framework.fields import CharField, ListField, SerializerMethodField
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.providers import ProviderSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.providers.ldap.models import LDAPProvider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,12 +7,11 @@ from guardian.utils import get_anonymous_user
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.fields import CharField, ListField, SerializerMethodField
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.users import UserSerializer
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
 | 
			
		||||
from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer
 | 
			
		||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,9 +4,10 @@ from urllib.parse import urlencode
 | 
			
		||||
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.core.models import Application, Group
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.policies.models import PolicyBinding
 | 
			
		||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
 | 
			
		||||
@ -77,3 +78,23 @@ class TesOAuth2DeviceInit(OAuthTestCase):
 | 
			
		||||
            + "?"
 | 
			
		||||
            + urlencode({QS_KEY_CODE: token.user_code}),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_device_init_denied(self):
 | 
			
		||||
        """Test device init"""
 | 
			
		||||
        group = Group.objects.create(name="foo")
 | 
			
		||||
        PolicyBinding.objects.create(
 | 
			
		||||
            group=group,
 | 
			
		||||
            target=self.application,
 | 
			
		||||
            order=0,
 | 
			
		||||
        )
 | 
			
		||||
        token = DeviceToken.objects.create(
 | 
			
		||||
            user_code="foo",
 | 
			
		||||
            provider=self.provider,
 | 
			
		||||
        )
 | 
			
		||||
        res = self.client.get(
 | 
			
		||||
            reverse("authentik_providers_oauth2_root:device-login")
 | 
			
		||||
            + "?"
 | 
			
		||||
            + urlencode({QS_KEY_CODE: token.user_code})
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(res.status_code, 200)
 | 
			
		||||
        self.assertIn(b"Permission denied", res.content)
 | 
			
		||||
 | 
			
		||||
@ -11,10 +11,11 @@ from django.views.decorators.csrf import csrf_exempt
 | 
			
		||||
from rest_framework.throttling import AnonRateThrottle
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.utils.time import timedelta_from_string
 | 
			
		||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
 | 
			
		||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE, get_application
 | 
			
		||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
@ -37,7 +38,9 @@ class DeviceView(View):
 | 
			
		||||
        ).first()
 | 
			
		||||
        if not provider:
 | 
			
		||||
            return HttpResponseBadRequest()
 | 
			
		||||
        if not get_application(provider):
 | 
			
		||||
        try:
 | 
			
		||||
            _ = provider.application
 | 
			
		||||
        except Application.DoesNotExist:
 | 
			
		||||
            return HttpResponseBadRequest()
 | 
			
		||||
        self.provider = provider
 | 
			
		||||
        self.client_id = client_id
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,9 @@
 | 
			
		||||
"""Device flow views"""
 | 
			
		||||
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.views import View
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.fields import CharField, IntegerField
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
@ -16,7 +17,8 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
 | 
			
		||||
from authentik.flows.stage import ChallengeStageView
 | 
			
		||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
 | 
			
		||||
from authentik.lib.utils.urls import redirect_with_qs
 | 
			
		||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
 | 
			
		||||
from authentik.policies.views import PolicyAccessView
 | 
			
		||||
from authentik.providers.oauth2.models import DeviceToken
 | 
			
		||||
from authentik.providers.oauth2.views.device_finish import (
 | 
			
		||||
    PLAN_CONTEXT_DEVICE,
 | 
			
		||||
    OAuthDeviceCodeFinishStage,
 | 
			
		||||
@ -31,60 +33,52 @@ LOGGER = get_logger()
 | 
			
		||||
QS_KEY_CODE = "code"  # nosec
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_application(provider: OAuth2Provider) -> Application | None:
 | 
			
		||||
    """Get application from provider"""
 | 
			
		||||
    try:
 | 
			
		||||
        app = provider.application
 | 
			
		||||
        if not app:
 | 
			
		||||
class CodeValidatorView(PolicyAccessView):
 | 
			
		||||
    """Helper to validate frontside token"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, code: str, **kwargs: Any) -> None:
 | 
			
		||||
        super().__init__(**kwargs)
 | 
			
		||||
        self.code = code
 | 
			
		||||
 | 
			
		||||
    def resolve_provider_application(self):
 | 
			
		||||
        self.token = DeviceToken.objects.filter(user_code=self.code).first()
 | 
			
		||||
        if not self.token:
 | 
			
		||||
            raise Application.DoesNotExist
 | 
			
		||||
        self.provider = self.token.provider
 | 
			
		||||
        self.application = self.token.provider.application
 | 
			
		||||
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs):
 | 
			
		||||
        scope_descriptions = UserInfoView().get_scope_descriptions(self.token.scope, self.provider)
 | 
			
		||||
        planner = FlowPlanner(self.provider.authorization_flow)
 | 
			
		||||
        planner.allow_empty_flows = True
 | 
			
		||||
        planner.use_cache = False
 | 
			
		||||
        try:
 | 
			
		||||
            plan = planner.plan(
 | 
			
		||||
                request,
 | 
			
		||||
                {
 | 
			
		||||
                    PLAN_CONTEXT_SSO: True,
 | 
			
		||||
                    PLAN_CONTEXT_APPLICATION: self.application,
 | 
			
		||||
                    # OAuth2 related params
 | 
			
		||||
                    PLAN_CONTEXT_DEVICE: self.token,
 | 
			
		||||
                    # Consent related params
 | 
			
		||||
                    PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
 | 
			
		||||
                    % {"application": self.application.name},
 | 
			
		||||
                    PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
        except FlowNonApplicableException:
 | 
			
		||||
            LOGGER.warning("Flow not applicable to user")
 | 
			
		||||
            return None
 | 
			
		||||
        return app
 | 
			
		||||
    except Application.DoesNotExist:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def validate_code(code: int, request: HttpRequest) -> HttpResponse | None:
 | 
			
		||||
    """Validate user token"""
 | 
			
		||||
    token = DeviceToken.objects.filter(
 | 
			
		||||
        user_code=code,
 | 
			
		||||
    ).first()
 | 
			
		||||
    if not token:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    app = get_application(token.provider)
 | 
			
		||||
    if not app:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    scope_descriptions = UserInfoView().get_scope_descriptions(token.scope, token.provider)
 | 
			
		||||
    planner = FlowPlanner(token.provider.authorization_flow)
 | 
			
		||||
    planner.allow_empty_flows = True
 | 
			
		||||
    planner.use_cache = False
 | 
			
		||||
    try:
 | 
			
		||||
        plan = planner.plan(
 | 
			
		||||
            request,
 | 
			
		||||
            {
 | 
			
		||||
                PLAN_CONTEXT_SSO: True,
 | 
			
		||||
                PLAN_CONTEXT_APPLICATION: app,
 | 
			
		||||
                # OAuth2 related params
 | 
			
		||||
                PLAN_CONTEXT_DEVICE: token,
 | 
			
		||||
                # Consent related params
 | 
			
		||||
                PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
 | 
			
		||||
                % {"application": app.name},
 | 
			
		||||
                PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
 | 
			
		||||
            },
 | 
			
		||||
        plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
 | 
			
		||||
        request.session[SESSION_KEY_PLAN] = plan
 | 
			
		||||
        return redirect_with_qs(
 | 
			
		||||
            "authentik_core:if-flow",
 | 
			
		||||
            request.GET,
 | 
			
		||||
            flow_slug=self.token.provider.authorization_flow.slug,
 | 
			
		||||
        )
 | 
			
		||||
    except FlowNonApplicableException:
 | 
			
		||||
        LOGGER.warning("Flow not applicable to user")
 | 
			
		||||
        return None
 | 
			
		||||
    plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
 | 
			
		||||
    request.session[SESSION_KEY_PLAN] = plan
 | 
			
		||||
    return redirect_with_qs(
 | 
			
		||||
        "authentik_core:if-flow",
 | 
			
		||||
        request.GET,
 | 
			
		||||
        flow_slug=token.provider.authorization_flow.slug,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeviceEntryView(View):
 | 
			
		||||
class DeviceEntryView(PolicyAccessView):
 | 
			
		||||
    """View used to initiate the device-code flow, url entered by endusers"""
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request: HttpRequest) -> HttpResponse:
 | 
			
		||||
@ -94,7 +88,9 @@ class DeviceEntryView(View):
 | 
			
		||||
            LOGGER.info("Brand has no device code flow configured", brand=brand)
 | 
			
		||||
            return HttpResponse(status=404)
 | 
			
		||||
        if QS_KEY_CODE in request.GET:
 | 
			
		||||
            validation = validate_code(request.GET[QS_KEY_CODE], request)
 | 
			
		||||
            validation = CodeValidatorView(request.GET[QS_KEY_CODE], request=request).dispatch(
 | 
			
		||||
                request
 | 
			
		||||
            )
 | 
			
		||||
            if validation:
 | 
			
		||||
                return validation
 | 
			
		||||
            LOGGER.info("Got code from query parameter but no matching token found")
 | 
			
		||||
@ -131,7 +127,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
 | 
			
		||||
 | 
			
		||||
    def validate_code(self, code: int) -> HttpResponse | None:
 | 
			
		||||
        """Validate code and save the returned http response"""
 | 
			
		||||
        response = validate_code(code, self.stage.request)
 | 
			
		||||
        response = CodeValidatorView(code, request=self.stage.request).dispatch(self.stage.request)
 | 
			
		||||
        if not response:
 | 
			
		||||
            raise ValidationError(_("Invalid code"), "invalid")
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
@ -6,12 +6,11 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from drf_spectacular.utils import extend_schema_field
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.fields import CharField, ListField, ReadOnlyField, SerializerMethodField
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.providers import ProviderSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.lib.utils.time import timedelta_from_string
 | 
			
		||||
from authentik.providers.oauth2.models import ScopeMapping
 | 
			
		||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,11 @@
 | 
			
		||||
"""RadiusProvider API Views"""
 | 
			
		||||
 | 
			
		||||
from rest_framework.fields import CharField, ListField
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.providers import ProviderSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer
 | 
			
		||||
from authentik.providers.radius.models import RadiusProvider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user