Compare commits
	
		
			2 Commits
		
	
	
		
			admin/add-
			...
			blueprint-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 563c274d70 | |||
| a9fee67b44 | 
@ -1,5 +1,5 @@
 | 
				
			|||||||
[bumpversion]
 | 
					[bumpversion]
 | 
				
			||||||
current_version = 2024.12.3
 | 
					current_version = 2024.10.5
 | 
				
			||||||
tag = True
 | 
					tag = True
 | 
				
			||||||
commit = 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*))?
 | 
					parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
 | 
				
			||||||
@ -31,4 +31,4 @@ optional_value = final
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:web/src/common/constants.ts]
 | 
					[bumpversion:file:web/src/common/constants.ts]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:lifecycle/aws/template.yaml]
 | 
					[bumpversion:file:website/docs/install-config/install/aws/template.yaml]
 | 
				
			||||||
 | 
				
			|||||||
@ -35,6 +35,14 @@ runs:
 | 
				
			|||||||
            AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
 | 
					            AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
 | 
				
			||||||
            ```
 | 
					            ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            For arm64, use these values:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            ```shell
 | 
				
			||||||
 | 
					            AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
 | 
				
			||||||
 | 
					            AUTHENTIK_TAG=${{ inputs.tag }}-arm64
 | 
				
			||||||
 | 
					            AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
 | 
				
			||||||
 | 
					            ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            Afterwards, run the upgrade commands from the latest release notes.
 | 
					            Afterwards, run the upgrade commands from the latest release notes.
 | 
				
			||||||
          </details>
 | 
					          </details>
 | 
				
			||||||
          <details>
 | 
					          <details>
 | 
				
			||||||
@ -52,6 +60,18 @@ runs:
 | 
				
			|||||||
                    tag: ${{ inputs.tag }}
 | 
					                    tag: ${{ inputs.tag }}
 | 
				
			||||||
            ```
 | 
					            ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            For arm64, use these values:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            ```yaml
 | 
				
			||||||
 | 
					            authentik:
 | 
				
			||||||
 | 
					                outposts:
 | 
				
			||||||
 | 
					                    container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
 | 
				
			||||||
 | 
					            global:
 | 
				
			||||||
 | 
					                image:
 | 
				
			||||||
 | 
					                    repository: ghcr.io/goauthentik/dev-server
 | 
				
			||||||
 | 
					                    tag: ${{ inputs.tag }}-arm64
 | 
				
			||||||
 | 
					            ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            Afterwards, run the upgrade commands from the latest release notes.
 | 
					            Afterwards, run the upgrade commands from the latest release notes.
 | 
				
			||||||
          </details>
 | 
					          </details>
 | 
				
			||||||
        edit-mode: replace
 | 
					        edit-mode: replace
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								.github/actions/docker-push-variables/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/actions/docker-push-variables/action.yml
									
									
									
									
										vendored
									
									
								
							@ -9,9 +9,6 @@ inputs:
 | 
				
			|||||||
  image-arch:
 | 
					  image-arch:
 | 
				
			||||||
    required: false
 | 
					    required: false
 | 
				
			||||||
    description: "Docker image arch"
 | 
					    description: "Docker image arch"
 | 
				
			||||||
  release:
 | 
					 | 
				
			||||||
    required: true
 | 
					 | 
				
			||||||
    description: "True if this is a release build, false if this is a dev/PR build"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
outputs:
 | 
					outputs:
 | 
				
			||||||
  shouldPush:
 | 
					  shouldPush:
 | 
				
			||||||
@ -32,24 +29,15 @@ outputs:
 | 
				
			|||||||
  imageTags:
 | 
					  imageTags:
 | 
				
			||||||
    description: "Docker image tags"
 | 
					    description: "Docker image tags"
 | 
				
			||||||
    value: ${{ steps.ev.outputs.imageTags }}
 | 
					    value: ${{ steps.ev.outputs.imageTags }}
 | 
				
			||||||
  imageTagsJSON:
 | 
					 | 
				
			||||||
    description: "Docker image tags, as a JSON array"
 | 
					 | 
				
			||||||
    value: ${{ steps.ev.outputs.imageTagsJSON }}
 | 
					 | 
				
			||||||
  attestImageNames:
 | 
					  attestImageNames:
 | 
				
			||||||
    description: "Docker image names used for attestation"
 | 
					    description: "Docker image names used for attestation"
 | 
				
			||||||
    value: ${{ steps.ev.outputs.attestImageNames }}
 | 
					    value: ${{ steps.ev.outputs.attestImageNames }}
 | 
				
			||||||
  cacheTo:
 | 
					 | 
				
			||||||
    description: "cache-to value for the docker build step"
 | 
					 | 
				
			||||||
    value: ${{ steps.ev.outputs.cacheTo }}
 | 
					 | 
				
			||||||
  imageMainTag:
 | 
					  imageMainTag:
 | 
				
			||||||
    description: "Docker image main tag"
 | 
					    description: "Docker image main tag"
 | 
				
			||||||
    value: ${{ steps.ev.outputs.imageMainTag }}
 | 
					    value: ${{ steps.ev.outputs.imageMainTag }}
 | 
				
			||||||
  imageMainName:
 | 
					  imageMainName:
 | 
				
			||||||
    description: "Docker image main name"
 | 
					    description: "Docker image main name"
 | 
				
			||||||
    value: ${{ steps.ev.outputs.imageMainName }}
 | 
					    value: ${{ steps.ev.outputs.imageMainName }}
 | 
				
			||||||
  imageBuildArgs:
 | 
					 | 
				
			||||||
    description: "Docker image build args"
 | 
					 | 
				
			||||||
    value: ${{ steps.ev.outputs.imageBuildArgs }}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
runs:
 | 
					runs:
 | 
				
			||||||
  using: "composite"
 | 
					  using: "composite"
 | 
				
			||||||
@ -60,8 +48,6 @@ runs:
 | 
				
			|||||||
      env:
 | 
					      env:
 | 
				
			||||||
        IMAGE_NAME: ${{ inputs.image-name }}
 | 
					        IMAGE_NAME: ${{ inputs.image-name }}
 | 
				
			||||||
        IMAGE_ARCH: ${{ inputs.image-arch }}
 | 
					        IMAGE_ARCH: ${{ inputs.image-arch }}
 | 
				
			||||||
        RELEASE: ${{ inputs.release }}
 | 
					 | 
				
			||||||
        PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
 | 
					        PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
 | 
				
			||||||
        REF: ${{ github.ref }}
 | 
					 | 
				
			||||||
      run: |
 | 
					      run: |
 | 
				
			||||||
        python3 ${{ github.action_path }}/push_vars.py
 | 
					        python3 ${{ github.action_path }}/push_vars.py
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import configparser
 | 
					import configparser
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
from json import dumps
 | 
					 | 
				
			||||||
from time import time
 | 
					from time import time
 | 
				
			||||||
 | 
					
 | 
				
			||||||
parser = configparser.ConfigParser()
 | 
					parser = configparser.ConfigParser()
 | 
				
			||||||
@ -49,7 +48,7 @@ if is_release:
 | 
				
			|||||||
            ]
 | 
					            ]
 | 
				
			||||||
else:
 | 
					else:
 | 
				
			||||||
    suffix = ""
 | 
					    suffix = ""
 | 
				
			||||||
    if image_arch:
 | 
					    if image_arch and image_arch != "amd64":
 | 
				
			||||||
        suffix = f"-{image_arch}"
 | 
					        suffix = f"-{image_arch}"
 | 
				
			||||||
    for name in image_names:
 | 
					    for name in image_names:
 | 
				
			||||||
        image_tags += [
 | 
					        image_tags += [
 | 
				
			||||||
@ -71,31 +70,12 @@ def get_attest_image_names(image_with_tags: list[str]):
 | 
				
			|||||||
    return ",".join(set(image_tags))
 | 
					    return ",".join(set(image_tags))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Generate `cache-to` param
 | 
					 | 
				
			||||||
cache_to = ""
 | 
					 | 
				
			||||||
if should_push:
 | 
					 | 
				
			||||||
    _cache_tag = "buildcache"
 | 
					 | 
				
			||||||
    if image_arch:
 | 
					 | 
				
			||||||
        _cache_tag += f"-{image_arch}"
 | 
					 | 
				
			||||||
    cache_to = f"type=registry,ref={get_attest_image_names(image_tags)}:{_cache_tag},mode=max"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
image_build_args = []
 | 
					 | 
				
			||||||
if os.getenv("RELEASE", "false").lower() == "true":
 | 
					 | 
				
			||||||
    image_build_args = [f"VERSION={os.getenv('REF')}"]
 | 
					 | 
				
			||||||
else:
 | 
					 | 
				
			||||||
    image_build_args = [f"GIT_BUILD_HASH={sha}"]
 | 
					 | 
				
			||||||
image_build_args = "\n".join(image_build_args)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
 | 
					with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
 | 
				
			||||||
    print(f"shouldPush={str(should_push).lower()}", file=_output)
 | 
					    print(f"shouldPush={str(should_push).lower()}", file=_output)
 | 
				
			||||||
    print(f"sha={sha}", file=_output)
 | 
					    print(f"sha={sha}", file=_output)
 | 
				
			||||||
    print(f"version={version}", file=_output)
 | 
					    print(f"version={version}", file=_output)
 | 
				
			||||||
    print(f"prerelease={prerelease}", file=_output)
 | 
					    print(f"prerelease={prerelease}", file=_output)
 | 
				
			||||||
    print(f"imageTags={','.join(image_tags)}", file=_output)
 | 
					    print(f"imageTags={','.join(image_tags)}", file=_output)
 | 
				
			||||||
    print(f"imageTagsJSON={dumps(image_tags)}", file=_output)
 | 
					 | 
				
			||||||
    print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output)
 | 
					    print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output)
 | 
				
			||||||
    print(f"imageMainTag={image_main_tag}", file=_output)
 | 
					    print(f"imageMainTag={image_main_tag}", file=_output)
 | 
				
			||||||
    print(f"imageMainName={image_tags[0]}", file=_output)
 | 
					    print(f"imageMainName={image_tags[0]}", file=_output)
 | 
				
			||||||
    print(f"cacheTo={cache_to}", file=_output)
 | 
					 | 
				
			||||||
    print(f"imageBuildArgs={image_build_args}", file=_output)
 | 
					 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										11
									
								
								.github/actions/docker-push-variables/test.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/actions/docker-push-variables/test.sh
									
									
									
									
										vendored
									
									
								
							@ -1,18 +1,7 @@
 | 
				
			|||||||
#!/bin/bash -x
 | 
					#!/bin/bash -x
 | 
				
			||||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
 | 
					SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
 | 
				
			||||||
# Non-pushing PR
 | 
					 | 
				
			||||||
GITHUB_OUTPUT=/dev/stdout \
 | 
					GITHUB_OUTPUT=/dev/stdout \
 | 
				
			||||||
    GITHUB_REF=ref \
 | 
					    GITHUB_REF=ref \
 | 
				
			||||||
    GITHUB_SHA=sha \
 | 
					    GITHUB_SHA=sha \
 | 
				
			||||||
    IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
 | 
					    IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
 | 
				
			||||||
    GITHUB_REPOSITORY=goauthentik/authentik \
 | 
					 | 
				
			||||||
    python $SCRIPT_DIR/push_vars.py
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Pushing PR/main
 | 
					 | 
				
			||||||
GITHUB_OUTPUT=/dev/stdout \
 | 
					 | 
				
			||||||
    GITHUB_REF=ref \
 | 
					 | 
				
			||||||
    GITHUB_SHA=sha \
 | 
					 | 
				
			||||||
    IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
 | 
					 | 
				
			||||||
    GITHUB_REPOSITORY=goauthentik/authentik \
 | 
					 | 
				
			||||||
    DOCKER_USERNAME=foo \
 | 
					 | 
				
			||||||
    python $SCRIPT_DIR/push_vars.py
 | 
					    python $SCRIPT_DIR/push_vars.py
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										10
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@ -82,16 +82,6 @@ updates:
 | 
				
			|||||||
      docusaurus:
 | 
					      docusaurus:
 | 
				
			||||||
        patterns:
 | 
					        patterns:
 | 
				
			||||||
          - "@docusaurus/*"
 | 
					          - "@docusaurus/*"
 | 
				
			||||||
  - package-ecosystem: npm
 | 
					 | 
				
			||||||
    directory: "/lifecycle/aws"
 | 
					 | 
				
			||||||
    schedule:
 | 
					 | 
				
			||||||
      interval: daily
 | 
					 | 
				
			||||||
      time: "04:00"
 | 
					 | 
				
			||||||
    open-pull-requests-limit: 10
 | 
					 | 
				
			||||||
    commit-message:
 | 
					 | 
				
			||||||
      prefix: "lifecycle/aws:"
 | 
					 | 
				
			||||||
    labels:
 | 
					 | 
				
			||||||
      - dependencies
 | 
					 | 
				
			||||||
  - package-ecosystem: pip
 | 
					  - package-ecosystem: pip
 | 
				
			||||||
    directory: "/"
 | 
					    directory: "/"
 | 
				
			||||||
    schedule:
 | 
					    schedule:
 | 
				
			||||||
 | 
				
			|||||||
@ -1,96 +0,0 @@
 | 
				
			|||||||
# Re-usable workflow for a single-architecture build
 | 
					 | 
				
			||||||
name: Single-arch Container build
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
on:
 | 
					 | 
				
			||||||
  workflow_call:
 | 
					 | 
				
			||||||
    inputs:
 | 
					 | 
				
			||||||
      image_name:
 | 
					 | 
				
			||||||
        required: true
 | 
					 | 
				
			||||||
        type: string
 | 
					 | 
				
			||||||
      image_arch:
 | 
					 | 
				
			||||||
        required: true
 | 
					 | 
				
			||||||
        type: string
 | 
					 | 
				
			||||||
      runs-on:
 | 
					 | 
				
			||||||
        required: true
 | 
					 | 
				
			||||||
        type: string
 | 
					 | 
				
			||||||
      registry_dockerhub:
 | 
					 | 
				
			||||||
        default: false
 | 
					 | 
				
			||||||
        type: boolean
 | 
					 | 
				
			||||||
      registry_ghcr:
 | 
					 | 
				
			||||||
        default: false
 | 
					 | 
				
			||||||
        type: boolean
 | 
					 | 
				
			||||||
      release:
 | 
					 | 
				
			||||||
        default: false
 | 
					 | 
				
			||||||
        type: boolean
 | 
					 | 
				
			||||||
    outputs:
 | 
					 | 
				
			||||||
      image-digest:
 | 
					 | 
				
			||||||
        value: ${{ jobs.build.outputs.image-digest }}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
jobs:
 | 
					 | 
				
			||||||
  build:
 | 
					 | 
				
			||||||
    name: Build ${{ inputs.image_arch }}
 | 
					 | 
				
			||||||
    runs-on: ${{ inputs.runs-on }}
 | 
					 | 
				
			||||||
    outputs:
 | 
					 | 
				
			||||||
      image-digest: ${{ steps.push.outputs.digest }}
 | 
					 | 
				
			||||||
    permissions:
 | 
					 | 
				
			||||||
      # Needed to upload container images to ghcr.io
 | 
					 | 
				
			||||||
      packages: write
 | 
					 | 
				
			||||||
      # Needed for attestation
 | 
					 | 
				
			||||||
      id-token: write
 | 
					 | 
				
			||||||
      attestations: write
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v4
 | 
					 | 
				
			||||||
      - uses: docker/setup-qemu-action@v3.4.0
 | 
					 | 
				
			||||||
      - uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
      - name: prepare variables
 | 
					 | 
				
			||||||
        uses: ./.github/actions/docker-push-variables
 | 
					 | 
				
			||||||
        id: ev
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          image-name: ${{ inputs.image_name }}
 | 
					 | 
				
			||||||
          image-arch: ${{ inputs.image_arch }}
 | 
					 | 
				
			||||||
          release: ${{ inputs.release }}
 | 
					 | 
				
			||||||
      - name: Login to Docker Hub
 | 
					 | 
				
			||||||
        if: ${{ inputs.registry_dockerhub }}
 | 
					 | 
				
			||||||
        uses: docker/login-action@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
					 | 
				
			||||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
					 | 
				
			||||||
      - name: Login to GitHub Container Registry
 | 
					 | 
				
			||||||
        if: ${{ inputs.registry_ghcr }}
 | 
					 | 
				
			||||||
        uses: docker/login-action@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          registry: ghcr.io
 | 
					 | 
				
			||||||
          username: ${{ github.repository_owner }}
 | 
					 | 
				
			||||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
      - name: make empty clients
 | 
					 | 
				
			||||||
        if: ${{ inputs.release }}
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          mkdir -p ./gen-ts-api
 | 
					 | 
				
			||||||
          mkdir -p ./gen-go-api
 | 
					 | 
				
			||||||
      - name: generate ts client
 | 
					 | 
				
			||||||
        if: ${{ !inputs.release }}
 | 
					 | 
				
			||||||
        run: make gen-client-ts
 | 
					 | 
				
			||||||
      - name: Build Docker Image
 | 
					 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
        id: push
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          context: .
 | 
					 | 
				
			||||||
          push: ${{ steps.ev.outputs.shouldPush == 'true' }}
 | 
					 | 
				
			||||||
          secrets: |
 | 
					 | 
				
			||||||
            GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
 | 
					 | 
				
			||||||
            GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
 | 
					 | 
				
			||||||
          build-args: |
 | 
					 | 
				
			||||||
            ${{ steps.ev.outputs.imageBuildArgs }}
 | 
					 | 
				
			||||||
          tags: ${{ steps.ev.outputs.imageTags }}
 | 
					 | 
				
			||||||
          platforms: linux/${{ inputs.image_arch }}
 | 
					 | 
				
			||||||
          cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
 | 
					 | 
				
			||||||
          cache-to: ${{ steps.ev.outputs.cacheTo }}
 | 
					 | 
				
			||||||
      - uses: actions/attest-build-provenance@v2
 | 
					 | 
				
			||||||
        id: attest
 | 
					 | 
				
			||||||
        if: ${{ steps.ev.outputs.shouldPush == 'true' }}
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          subject-name: ${{ steps.ev.outputs.attestImageNames }}
 | 
					 | 
				
			||||||
          subject-digest: ${{ steps.push.outputs.digest }}
 | 
					 | 
				
			||||||
          push-to-registry: true
 | 
					 | 
				
			||||||
							
								
								
									
										104
									
								
								.github/workflows/_reusable-docker-build.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										104
									
								
								.github/workflows/_reusable-docker-build.yaml
									
									
									
									
										vendored
									
									
								
							@ -1,104 +0,0 @@
 | 
				
			|||||||
# Re-usable workflow for a multi-architecture build
 | 
					 | 
				
			||||||
name: Multi-arch container build
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
on:
 | 
					 | 
				
			||||||
  workflow_call:
 | 
					 | 
				
			||||||
    inputs:
 | 
					 | 
				
			||||||
      image_name:
 | 
					 | 
				
			||||||
        required: true
 | 
					 | 
				
			||||||
        type: string
 | 
					 | 
				
			||||||
      registry_dockerhub:
 | 
					 | 
				
			||||||
        default: false
 | 
					 | 
				
			||||||
        type: boolean
 | 
					 | 
				
			||||||
      registry_ghcr:
 | 
					 | 
				
			||||||
        default: true
 | 
					 | 
				
			||||||
        type: boolean
 | 
					 | 
				
			||||||
      release:
 | 
					 | 
				
			||||||
        default: false
 | 
					 | 
				
			||||||
        type: boolean
 | 
					 | 
				
			||||||
    outputs: {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
jobs:
 | 
					 | 
				
			||||||
  build-server-amd64:
 | 
					 | 
				
			||||||
    uses: ./.github/workflows/_reusable-docker-build-single.yaml
 | 
					 | 
				
			||||||
    secrets: inherit
 | 
					 | 
				
			||||||
    with:
 | 
					 | 
				
			||||||
      image_name: ${{ inputs.image_name }}
 | 
					 | 
				
			||||||
      image_arch: amd64
 | 
					 | 
				
			||||||
      runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
      registry_dockerhub: ${{ inputs.registry_dockerhub }}
 | 
					 | 
				
			||||||
      registry_ghcr: ${{ inputs.registry_ghcr }}
 | 
					 | 
				
			||||||
      release: ${{ inputs.release }}
 | 
					 | 
				
			||||||
  build-server-arm64:
 | 
					 | 
				
			||||||
    uses: ./.github/workflows/_reusable-docker-build-single.yaml
 | 
					 | 
				
			||||||
    secrets: inherit
 | 
					 | 
				
			||||||
    with:
 | 
					 | 
				
			||||||
      image_name: ${{ inputs.image_name }}
 | 
					 | 
				
			||||||
      image_arch: arm64
 | 
					 | 
				
			||||||
      runs-on: ubuntu-22.04-arm
 | 
					 | 
				
			||||||
      registry_dockerhub: ${{ inputs.registry_dockerhub }}
 | 
					 | 
				
			||||||
      registry_ghcr: ${{ inputs.registry_ghcr }}
 | 
					 | 
				
			||||||
      release: ${{ inputs.release }}
 | 
					 | 
				
			||||||
  get-tags:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    needs:
 | 
					 | 
				
			||||||
      - build-server-amd64
 | 
					 | 
				
			||||||
      - build-server-arm64
 | 
					 | 
				
			||||||
    outputs:
 | 
					 | 
				
			||||||
      tags: ${{ steps.ev.outputs.imageTagsJSON }}
 | 
					 | 
				
			||||||
      shouldPush: ${{ steps.ev.outputs.shouldPush }}
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v4
 | 
					 | 
				
			||||||
      - name: prepare variables
 | 
					 | 
				
			||||||
        uses: ./.github/actions/docker-push-variables
 | 
					 | 
				
			||||||
        id: ev
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          image-name: ${{ inputs.image_name }}
 | 
					 | 
				
			||||||
  merge-server:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    if: ${{ needs.get-tags.outputs.shouldPush == 'true' }}
 | 
					 | 
				
			||||||
    needs:
 | 
					 | 
				
			||||||
      - get-tags
 | 
					 | 
				
			||||||
      - build-server-amd64
 | 
					 | 
				
			||||||
      - build-server-arm64
 | 
					 | 
				
			||||||
    strategy:
 | 
					 | 
				
			||||||
      fail-fast: false
 | 
					 | 
				
			||||||
      matrix:
 | 
					 | 
				
			||||||
        tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v4
 | 
					 | 
				
			||||||
      - name: prepare variables
 | 
					 | 
				
			||||||
        uses: ./.github/actions/docker-push-variables
 | 
					 | 
				
			||||||
        id: ev
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          image-name: ${{ inputs.image_name }}
 | 
					 | 
				
			||||||
      - name: Login to Docker Hub
 | 
					 | 
				
			||||||
        if: ${{ inputs.registry_dockerhub }}
 | 
					 | 
				
			||||||
        uses: docker/login-action@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
					 | 
				
			||||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
					 | 
				
			||||||
      - name: Login to GitHub Container Registry
 | 
					 | 
				
			||||||
        if: ${{ inputs.registry_ghcr }}
 | 
					 | 
				
			||||||
        uses: docker/login-action@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          registry: ghcr.io
 | 
					 | 
				
			||||||
          username: ${{ github.repository_owner }}
 | 
					 | 
				
			||||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
      - uses: int128/docker-manifest-create-action@v2
 | 
					 | 
				
			||||||
        id: build
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          tags: ${{ matrix.tag }}
 | 
					 | 
				
			||||||
          sources: |
 | 
					 | 
				
			||||||
            ${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
 | 
					 | 
				
			||||||
            ${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
 | 
					 | 
				
			||||||
      - uses: actions/attest-build-provenance@v2
 | 
					 | 
				
			||||||
        id: attest
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          subject-name: ${{ steps.ev.outputs.attestImageNames }}
 | 
					 | 
				
			||||||
          subject-digest: ${{ steps.build.outputs.digest }}
 | 
					 | 
				
			||||||
          push-to-registry: true
 | 
					 | 
				
			||||||
							
								
								
									
										6
									
								
								.github/workflows/ci-aws-cfn.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/ci-aws-cfn.yml
									
									
									
									
										vendored
									
									
								
							@ -25,10 +25,10 @@ jobs:
 | 
				
			|||||||
        uses: ./.github/actions/setup
 | 
					        uses: ./.github/actions/setup
 | 
				
			||||||
      - uses: actions/setup-node@v4
 | 
					      - uses: actions/setup-node@v4
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          node-version-file: lifecycle/aws/package.json
 | 
					          node-version-file: website/package.json
 | 
				
			||||||
          cache: "npm"
 | 
					          cache: "npm"
 | 
				
			||||||
          cache-dependency-path: lifecycle/aws/package-lock.json
 | 
					          cache-dependency-path: website/package-lock.json
 | 
				
			||||||
      - working-directory: lifecycle/aws/
 | 
					      - working-directory: website/
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          npm ci
 | 
					          npm ci
 | 
				
			||||||
      - name: Check changes have been applied
 | 
					      - name: Check changes have been applied
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										28
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										28
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							@ -1,28 +0,0 @@
 | 
				
			|||||||
---
 | 
					 | 
				
			||||||
name: authentik-ci-main-daily
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
on:
 | 
					 | 
				
			||||||
  workflow_dispatch:
 | 
					 | 
				
			||||||
  schedule:
 | 
					 | 
				
			||||||
    # Every night at 3am
 | 
					 | 
				
			||||||
    - cron: "0 3 * * *"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
jobs:
 | 
					 | 
				
			||||||
  test-container:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    strategy:
 | 
					 | 
				
			||||||
      fail-fast: false
 | 
					 | 
				
			||||||
      matrix:
 | 
					 | 
				
			||||||
        version:
 | 
					 | 
				
			||||||
          - docs
 | 
					 | 
				
			||||||
          - version-2024-12
 | 
					 | 
				
			||||||
          - version-2024-10
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v4
 | 
					 | 
				
			||||||
      - run: |
 | 
					 | 
				
			||||||
          current="$(pwd)"
 | 
					 | 
				
			||||||
          dir="/tmp/authentik/${{ matrix.version }}"
 | 
					 | 
				
			||||||
          mkdir -p $dir
 | 
					 | 
				
			||||||
          cd $dir
 | 
					 | 
				
			||||||
          wget https://${{ matrix.version }}.goauthentik.io/docker-compose.yml
 | 
					 | 
				
			||||||
          ${current}/scripts/test_docker.sh
 | 
					 | 
				
			||||||
							
								
								
									
										97
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										97
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							@ -43,26 +43,15 @@ jobs:
 | 
				
			|||||||
        uses: ./.github/actions/setup
 | 
					        uses: ./.github/actions/setup
 | 
				
			||||||
      - name: run migrations
 | 
					      - name: run migrations
 | 
				
			||||||
        run: poetry run python -m lifecycle.migrate
 | 
					        run: poetry run python -m lifecycle.migrate
 | 
				
			||||||
  test-make-seed:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - id: seed
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          echo "seed=$(printf "%d\n" "0x$(openssl rand -hex 4)")" >> "$GITHUB_OUTPUT"
 | 
					 | 
				
			||||||
    outputs:
 | 
					 | 
				
			||||||
      seed: ${{ steps.seed.outputs.seed }}
 | 
					 | 
				
			||||||
  test-migrations-from-stable:
 | 
					  test-migrations-from-stable:
 | 
				
			||||||
    name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
 | 
					    name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }}
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    timeout-minutes: 20
 | 
					 | 
				
			||||||
    needs: test-make-seed
 | 
					 | 
				
			||||||
    strategy:
 | 
					    strategy:
 | 
				
			||||||
      fail-fast: false
 | 
					      fail-fast: false
 | 
				
			||||||
      matrix:
 | 
					      matrix:
 | 
				
			||||||
        psql:
 | 
					        psql:
 | 
				
			||||||
          - 15-alpine
 | 
					          - 15-alpine
 | 
				
			||||||
          - 16-alpine
 | 
					          - 16-alpine
 | 
				
			||||||
        run_id: [1, 2, 3, 4, 5]
 | 
					 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v4
 | 
					      - uses: actions/checkout@v4
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
@ -104,23 +93,18 @@ jobs:
 | 
				
			|||||||
        env:
 | 
					        env:
 | 
				
			||||||
          # Test in the main database that we just migrated from the previous stable version
 | 
					          # Test in the main database that we just migrated from the previous stable version
 | 
				
			||||||
          AUTHENTIK_POSTGRESQL__TEST__NAME: authentik
 | 
					          AUTHENTIK_POSTGRESQL__TEST__NAME: authentik
 | 
				
			||||||
          CI_TEST_SEED: ${{ needs.test-make-seed.outputs.seed }}
 | 
					 | 
				
			||||||
          CI_RUN_ID: ${{ matrix.run_id }}
 | 
					 | 
				
			||||||
          CI_TOTAL_RUNS: "5"
 | 
					 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          poetry run make ci-test
 | 
					          poetry run make test
 | 
				
			||||||
  test-unittest:
 | 
					  test-unittest:
 | 
				
			||||||
    name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
 | 
					    name: test-unittest - PostgreSQL ${{ matrix.psql }}
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    timeout-minutes: 20
 | 
					    timeout-minutes: 30
 | 
				
			||||||
    needs: test-make-seed
 | 
					 | 
				
			||||||
    strategy:
 | 
					    strategy:
 | 
				
			||||||
      fail-fast: false
 | 
					      fail-fast: false
 | 
				
			||||||
      matrix:
 | 
					      matrix:
 | 
				
			||||||
        psql:
 | 
					        psql:
 | 
				
			||||||
          - 15-alpine
 | 
					          - 15-alpine
 | 
				
			||||||
          - 16-alpine
 | 
					          - 16-alpine
 | 
				
			||||||
        run_id: [1, 2, 3, 4, 5]
 | 
					 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v4
 | 
					      - uses: actions/checkout@v4
 | 
				
			||||||
      - name: Setup authentik env
 | 
					      - name: Setup authentik env
 | 
				
			||||||
@ -128,12 +112,9 @@ jobs:
 | 
				
			|||||||
        with:
 | 
					        with:
 | 
				
			||||||
          postgresql_version: ${{ matrix.psql }}
 | 
					          postgresql_version: ${{ matrix.psql }}
 | 
				
			||||||
      - name: run unittest
 | 
					      - name: run unittest
 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          CI_TEST_SEED: ${{ needs.test-make-seed.outputs.seed }}
 | 
					 | 
				
			||||||
          CI_RUN_ID: ${{ matrix.run_id }}
 | 
					 | 
				
			||||||
          CI_TOTAL_RUNS: "5"
 | 
					 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          poetry run make ci-test
 | 
					          poetry run make test
 | 
				
			||||||
 | 
					          poetry run coverage xml
 | 
				
			||||||
      - if: ${{ always() }}
 | 
					      - if: ${{ always() }}
 | 
				
			||||||
        uses: codecov/codecov-action@v5
 | 
					        uses: codecov/codecov-action@v5
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
@ -153,7 +134,7 @@ jobs:
 | 
				
			|||||||
      - name: Setup authentik env
 | 
					      - name: Setup authentik env
 | 
				
			||||||
        uses: ./.github/actions/setup
 | 
					        uses: ./.github/actions/setup
 | 
				
			||||||
      - name: Create k8s Kind Cluster
 | 
					      - name: Create k8s Kind Cluster
 | 
				
			||||||
        uses: helm/kind-action@v1.12.0
 | 
					        uses: helm/kind-action@v1.11.0
 | 
				
			||||||
      - name: run integration
 | 
					      - name: run integration
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          poetry run coverage run manage.py test tests/integration
 | 
					          poetry run coverage run manage.py test tests/integration
 | 
				
			||||||
@ -242,18 +223,68 @@ jobs:
 | 
				
			|||||||
        with:
 | 
					        with:
 | 
				
			||||||
          jobs: ${{ toJSON(needs) }}
 | 
					          jobs: ${{ toJSON(needs) }}
 | 
				
			||||||
  build:
 | 
					  build:
 | 
				
			||||||
 | 
					    strategy:
 | 
				
			||||||
 | 
					      fail-fast: false
 | 
				
			||||||
 | 
					      matrix:
 | 
				
			||||||
 | 
					        arch:
 | 
				
			||||||
 | 
					          - amd64
 | 
				
			||||||
 | 
					          - arm64
 | 
				
			||||||
 | 
					    needs: ci-core-mark
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    permissions:
 | 
					    permissions:
 | 
				
			||||||
      # Needed to upload container images to ghcr.io
 | 
					      # Needed to upload contianer images to ghcr.io
 | 
				
			||||||
      packages: write
 | 
					      packages: write
 | 
				
			||||||
      # Needed for attestation
 | 
					      # Needed for attestation
 | 
				
			||||||
      id-token: write
 | 
					      id-token: write
 | 
				
			||||||
      attestations: write
 | 
					      attestations: write
 | 
				
			||||||
    needs: ci-core-mark
 | 
					    timeout-minutes: 120
 | 
				
			||||||
    uses: ./.github/workflows/_reusable-docker-build.yaml
 | 
					    steps:
 | 
				
			||||||
    secrets: inherit
 | 
					      - uses: actions/checkout@v4
 | 
				
			||||||
    with:
 | 
					        with:
 | 
				
			||||||
      image_name: ghcr.io/goauthentik/dev-server
 | 
					          ref: ${{ github.event.pull_request.head.sha }}
 | 
				
			||||||
      release: false
 | 
					      - name: Set up QEMU
 | 
				
			||||||
 | 
					        uses: docker/setup-qemu-action@v3.2.0
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					      - name: prepare variables
 | 
				
			||||||
 | 
					        uses: ./.github/actions/docker-push-variables
 | 
				
			||||||
 | 
					        id: ev
 | 
				
			||||||
 | 
					        env:
 | 
				
			||||||
 | 
					          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          image-name: ghcr.io/goauthentik/dev-server
 | 
				
			||||||
 | 
					          image-arch: ${{ matrix.arch }}
 | 
				
			||||||
 | 
					      - name: Login to Container Registry
 | 
				
			||||||
 | 
					        if: ${{ steps.ev.outputs.shouldPush == 'true' }}
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					      - name: generate ts client
 | 
				
			||||||
 | 
					        run: make gen-client-ts
 | 
				
			||||||
 | 
					      - name: Build Docker Image
 | 
				
			||||||
 | 
					        uses: docker/build-push-action@v6
 | 
				
			||||||
 | 
					        id: push
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          context: .
 | 
				
			||||||
 | 
					          secrets: |
 | 
				
			||||||
 | 
					            GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
 | 
				
			||||||
 | 
					            GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
 | 
				
			||||||
 | 
					          tags: ${{ steps.ev.outputs.imageTags }}
 | 
				
			||||||
 | 
					          push: ${{ steps.ev.outputs.shouldPush == 'true' }}
 | 
				
			||||||
 | 
					          build-args: |
 | 
				
			||||||
 | 
					            GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
 | 
				
			||||||
 | 
					          cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache
 | 
				
			||||||
 | 
					          cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }}
 | 
				
			||||||
 | 
					          platforms: linux/${{ matrix.arch }}
 | 
				
			||||||
 | 
					      - uses: actions/attest-build-provenance@v2
 | 
				
			||||||
 | 
					        id: attest
 | 
				
			||||||
 | 
					        if: ${{ steps.ev.outputs.shouldPush == 'true' }}
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          subject-name: ${{ steps.ev.outputs.attestImageNames }}
 | 
				
			||||||
 | 
					          subject-digest: ${{ steps.push.outputs.digest }}
 | 
				
			||||||
 | 
					          push-to-registry: true
 | 
				
			||||||
  pr-comment:
 | 
					  pr-comment:
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - build
 | 
					      - build
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							@ -72,7 +72,7 @@ jobs:
 | 
				
			|||||||
          - rac
 | 
					          - rac
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    permissions:
 | 
					    permissions:
 | 
				
			||||||
      # Needed to upload container images to ghcr.io
 | 
					      # Needed to upload contianer images to ghcr.io
 | 
				
			||||||
      packages: write
 | 
					      packages: write
 | 
				
			||||||
      # Needed for attestation
 | 
					      # Needed for attestation
 | 
				
			||||||
      id-token: write
 | 
					      id-token: write
 | 
				
			||||||
@ -82,7 +82,7 @@ jobs:
 | 
				
			|||||||
        with:
 | 
					        with:
 | 
				
			||||||
          ref: ${{ github.event.pull_request.head.sha }}
 | 
					          ref: ${{ github.event.pull_request.head.sha }}
 | 
				
			||||||
      - name: Set up QEMU
 | 
					      - name: Set up QEMU
 | 
				
			||||||
        uses: docker/setup-qemu-action@v3.4.0
 | 
					        uses: docker/setup-qemu-action@v3.2.0
 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
        uses: docker/setup-buildx-action@v3
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
      - name: prepare variables
 | 
					      - name: prepare variables
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										65
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										65
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -7,23 +7,64 @@ on:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  build-server:
 | 
					  build-server:
 | 
				
			||||||
    uses: ./.github/workflows/_reusable-docker-build.yaml
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    secrets: inherit
 | 
					 | 
				
			||||||
    permissions:
 | 
					    permissions:
 | 
				
			||||||
      # Needed to upload container images to ghcr.io
 | 
					      # Needed to upload contianer images to ghcr.io
 | 
				
			||||||
      packages: write
 | 
					      packages: write
 | 
				
			||||||
      # Needed for attestation
 | 
					      # Needed for attestation
 | 
				
			||||||
      id-token: write
 | 
					      id-token: write
 | 
				
			||||||
      attestations: write
 | 
					      attestations: write
 | 
				
			||||||
    with:
 | 
					    steps:
 | 
				
			||||||
      image_name: ghcr.io/goauthentik/server,beryju/authentik
 | 
					      - uses: actions/checkout@v4
 | 
				
			||||||
      release: true
 | 
					      - name: Set up QEMU
 | 
				
			||||||
      registry_dockerhub: true
 | 
					        uses: docker/setup-qemu-action@v3.2.0
 | 
				
			||||||
      registry_ghcr: true
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					      - name: prepare variables
 | 
				
			||||||
 | 
					        uses: ./.github/actions/docker-push-variables
 | 
				
			||||||
 | 
					        id: ev
 | 
				
			||||||
 | 
					        env:
 | 
				
			||||||
 | 
					          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          image-name: ghcr.io/goauthentik/server,beryju/authentik
 | 
				
			||||||
 | 
					      - name: Docker Login Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          username: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					      - name: make empty clients
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          mkdir -p ./gen-ts-api
 | 
				
			||||||
 | 
					          mkdir -p ./gen-go-api
 | 
				
			||||||
 | 
					      - name: Build Docker Image
 | 
				
			||||||
 | 
					        uses: docker/build-push-action@v6
 | 
				
			||||||
 | 
					        id: push
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          context: .
 | 
				
			||||||
 | 
					          push: true
 | 
				
			||||||
 | 
					          secrets: |
 | 
				
			||||||
 | 
					            GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
 | 
				
			||||||
 | 
					            GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
 | 
				
			||||||
 | 
					          build-args: |
 | 
				
			||||||
 | 
					            VERSION=${{ github.ref }}
 | 
				
			||||||
 | 
					          tags: ${{ steps.ev.outputs.imageTags }}
 | 
				
			||||||
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
 | 
					      - uses: actions/attest-build-provenance@v2
 | 
				
			||||||
 | 
					        id: attest
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          subject-name: ${{ steps.ev.outputs.attestImageNames }}
 | 
				
			||||||
 | 
					          subject-digest: ${{ steps.push.outputs.digest }}
 | 
				
			||||||
 | 
					          push-to-registry: true
 | 
				
			||||||
  build-outpost:
 | 
					  build-outpost:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    permissions:
 | 
					    permissions:
 | 
				
			||||||
      # Needed to upload container images to ghcr.io
 | 
					      # Needed to upload contianer images to ghcr.io
 | 
				
			||||||
      packages: write
 | 
					      packages: write
 | 
				
			||||||
      # Needed for attestation
 | 
					      # Needed for attestation
 | 
				
			||||||
      id-token: write
 | 
					      id-token: write
 | 
				
			||||||
@ -42,7 +83,7 @@ jobs:
 | 
				
			|||||||
        with:
 | 
					        with:
 | 
				
			||||||
          go-version-file: "go.mod"
 | 
					          go-version-file: "go.mod"
 | 
				
			||||||
      - name: Set up QEMU
 | 
					      - name: Set up QEMU
 | 
				
			||||||
        uses: docker/setup-qemu-action@v3.4.0
 | 
					        uses: docker/setup-qemu-action@v3.2.0
 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
        uses: docker/setup-buildx-action@v3
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
      - name: prepare variables
 | 
					      - name: prepare variables
 | 
				
			||||||
@ -147,8 +188,8 @@ jobs:
 | 
				
			|||||||
          aws-region: ${{ env.AWS_REGION }}
 | 
					          aws-region: ${{ env.AWS_REGION }}
 | 
				
			||||||
      - name: Upload template
 | 
					      - name: Upload template
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
 | 
					          aws s3 cp website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
 | 
				
			||||||
          aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
 | 
					          aws s3 cp website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
 | 
				
			||||||
  test-release:
 | 
					  test-release:
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - build-server
 | 
					      - build-server
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										11
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							@ -14,7 +14,16 @@ jobs:
 | 
				
			|||||||
      - uses: actions/checkout@v4
 | 
					      - uses: actions/checkout@v4
 | 
				
			||||||
      - name: Pre-release test
 | 
					      - name: Pre-release test
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          make test-docker
 | 
					          echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
 | 
				
			||||||
 | 
					          echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
 | 
				
			||||||
 | 
					          docker buildx install
 | 
				
			||||||
 | 
					          mkdir -p ./gen-ts-api
 | 
				
			||||||
 | 
					          docker build -t testing:latest .
 | 
				
			||||||
 | 
					          echo "AUTHENTIK_IMAGE=testing" >> .env
 | 
				
			||||||
 | 
					          echo "AUTHENTIK_TAG=latest" >> .env
 | 
				
			||||||
 | 
					          docker compose up --no-start
 | 
				
			||||||
 | 
					          docker compose start postgresql redis
 | 
				
			||||||
 | 
					          docker compose run -u root server test-all
 | 
				
			||||||
      - id: generate_token
 | 
					      - id: generate_token
 | 
				
			||||||
        uses: tibdex/github-app-token@v2
 | 
					        uses: tibdex/github-app-token@v2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										6
									
								
								.github/workflows/repo-stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/repo-stale.yml
									
									
									
									
										vendored
									
									
								
							@ -1,8 +1,8 @@
 | 
				
			|||||||
name: "authentik-repo-stale"
 | 
					name: 'authentik-repo-stale'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
on:
 | 
					on:
 | 
				
			||||||
  schedule:
 | 
					  schedule:
 | 
				
			||||||
    - cron: "30 1 * * *"
 | 
					    - cron: '30 1 * * *'
 | 
				
			||||||
  workflow_dispatch:
 | 
					  workflow_dispatch:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
permissions:
 | 
					permissions:
 | 
				
			||||||
@ -25,7 +25,7 @@ jobs:
 | 
				
			|||||||
          days-before-stale: 60
 | 
					          days-before-stale: 60
 | 
				
			||||||
          days-before-close: 7
 | 
					          days-before-close: 7
 | 
				
			||||||
          exempt-issue-labels: pinned,security,pr_wanted,enhancement,bug/confirmed,enhancement/confirmed,question,status/reviewing
 | 
					          exempt-issue-labels: pinned,security,pr_wanted,enhancement,bug/confirmed,enhancement/confirmed,question,status/reviewing
 | 
				
			||||||
          stale-issue-label: status/stale
 | 
					          stale-issue-label: wontfix
 | 
				
			||||||
          stale-issue-message: >
 | 
					          stale-issue-message: >
 | 
				
			||||||
            This issue has been automatically marked as stale because it has not had
 | 
					            This issue has been automatically marked as stale because it has not had
 | 
				
			||||||
            recent activity. It will be closed if no further activity occurs. Thank you
 | 
					            recent activity. It will be closed if no further activity occurs. Thank you
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -209,6 +209,3 @@ source_docs/
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
### Golang ###
 | 
					### Golang ###
 | 
				
			||||||
/vendor/
 | 
					/vendor/
 | 
				
			||||||
 | 
					 | 
				
			||||||
### Docker ###
 | 
					 | 
				
			||||||
docker-compose.override.yml
 | 
					 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										7
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							@ -2,7 +2,6 @@
 | 
				
			|||||||
    "recommendations": [
 | 
					    "recommendations": [
 | 
				
			||||||
        "bashmish.es6-string-css",
 | 
					        "bashmish.es6-string-css",
 | 
				
			||||||
        "bpruitt-goddard.mermaid-markdown-syntax-highlighting",
 | 
					        "bpruitt-goddard.mermaid-markdown-syntax-highlighting",
 | 
				
			||||||
        "charliermarsh.ruff",
 | 
					 | 
				
			||||||
        "dbaeumer.vscode-eslint",
 | 
					        "dbaeumer.vscode-eslint",
 | 
				
			||||||
        "EditorConfig.EditorConfig",
 | 
					        "EditorConfig.EditorConfig",
 | 
				
			||||||
        "esbenp.prettier-vscode",
 | 
					        "esbenp.prettier-vscode",
 | 
				
			||||||
@ -11,12 +10,12 @@
 | 
				
			|||||||
        "Gruntfuggly.todo-tree",
 | 
					        "Gruntfuggly.todo-tree",
 | 
				
			||||||
        "mechatroner.rainbow-csv",
 | 
					        "mechatroner.rainbow-csv",
 | 
				
			||||||
        "ms-python.black-formatter",
 | 
					        "ms-python.black-formatter",
 | 
				
			||||||
        "ms-python.black-formatter",
 | 
					        "charliermarsh.ruff",
 | 
				
			||||||
        "ms-python.debugpy",
 | 
					 | 
				
			||||||
        "ms-python.python",
 | 
					        "ms-python.python",
 | 
				
			||||||
        "ms-python.vscode-pylance",
 | 
					        "ms-python.vscode-pylance",
 | 
				
			||||||
 | 
					        "ms-python.black-formatter",
 | 
				
			||||||
        "redhat.vscode-yaml",
 | 
					        "redhat.vscode-yaml",
 | 
				
			||||||
        "Tobermory.es6-string-html",
 | 
					        "Tobermory.es6-string-html",
 | 
				
			||||||
        "unifiedjs.vscode-mdx",
 | 
					        "unifiedjs.vscode-mdx"
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										66
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										66
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							@ -2,76 +2,26 @@
 | 
				
			|||||||
    "version": "0.2.0",
 | 
					    "version": "0.2.0",
 | 
				
			||||||
    "configurations": [
 | 
					    "configurations": [
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "name": "Debug: Attach Server Core",
 | 
					            "name": "Python: PDB attach Server",
 | 
				
			||||||
            "type": "debugpy",
 | 
					            "type": "python",
 | 
				
			||||||
            "request": "attach",
 | 
					            "request": "attach",
 | 
				
			||||||
            "connect": {
 | 
					            "connect": {
 | 
				
			||||||
                "host": "localhost",
 | 
					                "host": "localhost",
 | 
				
			||||||
                "port": 9901
 | 
					                "port": 6800
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            "pathMappings": [
 | 
					            "justMyCode": true,
 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    "localRoot": "${workspaceFolder}",
 | 
					 | 
				
			||||||
                    "remoteRoot": "."
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            "django": true
 | 
					            "django": true
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "name": "Debug: Attach Worker",
 | 
					            "name": "Python: PDB attach Worker",
 | 
				
			||||||
            "type": "debugpy",
 | 
					            "type": "python",
 | 
				
			||||||
            "request": "attach",
 | 
					            "request": "attach",
 | 
				
			||||||
            "connect": {
 | 
					            "connect": {
 | 
				
			||||||
                "host": "localhost",
 | 
					                "host": "localhost",
 | 
				
			||||||
                "port": 9901
 | 
					                "port": 6900
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            "pathMappings": [
 | 
					            "justMyCode": true,
 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    "localRoot": "${workspaceFolder}",
 | 
					 | 
				
			||||||
                    "remoteRoot": "."
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            "django": true
 | 
					            "django": true
 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "name": "Debug: Start Server Router",
 | 
					 | 
				
			||||||
            "type": "go",
 | 
					 | 
				
			||||||
            "request": "launch",
 | 
					 | 
				
			||||||
            "mode": "auto",
 | 
					 | 
				
			||||||
            "program": "${workspaceFolder}/cmd/server",
 | 
					 | 
				
			||||||
            "cwd": "${workspaceFolder}"
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "name": "Debug: Start LDAP Outpost",
 | 
					 | 
				
			||||||
            "type": "go",
 | 
					 | 
				
			||||||
            "request": "launch",
 | 
					 | 
				
			||||||
            "mode": "auto",
 | 
					 | 
				
			||||||
            "program": "${workspaceFolder}/cmd/ldap",
 | 
					 | 
				
			||||||
            "cwd": "${workspaceFolder}"
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "name": "Debug: Start Proxy Outpost",
 | 
					 | 
				
			||||||
            "type": "go",
 | 
					 | 
				
			||||||
            "request": "launch",
 | 
					 | 
				
			||||||
            "mode": "auto",
 | 
					 | 
				
			||||||
            "program": "${workspaceFolder}/cmd/proxy",
 | 
					 | 
				
			||||||
            "cwd": "${workspaceFolder}"
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "name": "Debug: Start RAC Outpost",
 | 
					 | 
				
			||||||
            "type": "go",
 | 
					 | 
				
			||||||
            "request": "launch",
 | 
					 | 
				
			||||||
            "mode": "auto",
 | 
					 | 
				
			||||||
            "program": "${workspaceFolder}/cmd/rac",
 | 
					 | 
				
			||||||
            "cwd": "${workspaceFolder}"
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "name": "Debug: Start Radius Outpost",
 | 
					 | 
				
			||||||
            "type": "go",
 | 
					 | 
				
			||||||
            "request": "launch",
 | 
					 | 
				
			||||||
            "mode": "auto",
 | 
					 | 
				
			||||||
            "program": "${workspaceFolder}/cmd/radius",
 | 
					 | 
				
			||||||
            "cwd": "${workspaceFolder}"
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							@ -33,8 +33,7 @@
 | 
				
			|||||||
        "!If sequence",
 | 
					        "!If sequence",
 | 
				
			||||||
        "!Index scalar",
 | 
					        "!Index scalar",
 | 
				
			||||||
        "!KeyOf scalar",
 | 
					        "!KeyOf scalar",
 | 
				
			||||||
        "!Value scalar",
 | 
					        "!Value scalar"
 | 
				
			||||||
        "!AtIndex scalar"
 | 
					 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    "typescript.preferences.importModuleSpecifier": "non-relative",
 | 
					    "typescript.preferences.importModuleSpecifier": "non-relative",
 | 
				
			||||||
    "typescript.preferences.importModuleSpecifierEnding": "index",
 | 
					    "typescript.preferences.importModuleSpecifierEnding": "index",
 | 
				
			||||||
 | 
				
			|||||||
@ -15,7 +15,6 @@ go.mod                          @goauthentik/backend
 | 
				
			|||||||
go.sum                          @goauthentik/backend
 | 
					go.sum                          @goauthentik/backend
 | 
				
			||||||
# Infrastructure
 | 
					# Infrastructure
 | 
				
			||||||
.github/                        @goauthentik/infrastructure
 | 
					.github/                        @goauthentik/infrastructure
 | 
				
			||||||
lifecycle/aws/                  @goauthentik/infrastructure
 | 
					 | 
				
			||||||
Dockerfile                      @goauthentik/infrastructure
 | 
					Dockerfile                      @goauthentik/infrastructure
 | 
				
			||||||
*Dockerfile                     @goauthentik/infrastructure
 | 
					*Dockerfile                     @goauthentik/infrastructure
 | 
				
			||||||
.dockerignore                   @goauthentik/infrastructure
 | 
					.dockerignore                   @goauthentik/infrastructure
 | 
				
			||||||
@ -26,9 +25,6 @@ CODEOWNERS                      @goauthentik/infrastructure
 | 
				
			|||||||
# Web
 | 
					# Web
 | 
				
			||||||
web/                            @goauthentik/frontend
 | 
					web/                            @goauthentik/frontend
 | 
				
			||||||
tests/wdio/                     @goauthentik/frontend
 | 
					tests/wdio/                     @goauthentik/frontend
 | 
				
			||||||
# Locale
 | 
					 | 
				
			||||||
locale/                         @goauthentik/backend @goauthentik/frontend
 | 
					 | 
				
			||||||
web/xliff/                      @goauthentik/backend @goauthentik/frontend
 | 
					 | 
				
			||||||
# Docs & Website
 | 
					# Docs & Website
 | 
				
			||||||
website/                        @goauthentik/docs
 | 
					website/                        @goauthentik/docs
 | 
				
			||||||
CODE_OF_CONDUCT.md              @goauthentik/docs
 | 
					CODE_OF_CONDUCT.md              @goauthentik/docs
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										34
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								Dockerfile
									
									
									
									
									
								
							@ -94,7 +94,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
 | 
				
			|||||||
    /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
 | 
					    /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Stage 5: Python dependencies
 | 
					# Stage 5: Python dependencies
 | 
				
			||||||
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-deps
 | 
					FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS python-deps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ARG TARGETARCH
 | 
					ARG TARGETARCH
 | 
				
			||||||
ARG TARGETVARIANT
 | 
					ARG TARGETVARIANT
 | 
				
			||||||
@ -116,30 +116,15 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
 | 
				
			|||||||
    --mount=type=bind,target=./poetry.lock,src=./poetry.lock \
 | 
					    --mount=type=bind,target=./poetry.lock,src=./poetry.lock \
 | 
				
			||||||
    --mount=type=cache,target=/root/.cache/pip \
 | 
					    --mount=type=cache,target=/root/.cache/pip \
 | 
				
			||||||
    --mount=type=cache,target=/root/.cache/pypoetry \
 | 
					    --mount=type=cache,target=/root/.cache/pypoetry \
 | 
				
			||||||
    pip install --no-cache cffi && \
 | 
					 | 
				
			||||||
    apt-get update && \
 | 
					 | 
				
			||||||
    apt-get install -y --no-install-recommends \
 | 
					 | 
				
			||||||
        build-essential libffi-dev \
 | 
					 | 
				
			||||||
        # Required for cryptography
 | 
					 | 
				
			||||||
        curl pkg-config \
 | 
					 | 
				
			||||||
        # Required for lxml
 | 
					 | 
				
			||||||
        libxslt-dev zlib1g-dev \
 | 
					 | 
				
			||||||
        # Required for xmlsec
 | 
					 | 
				
			||||||
        libltdl-dev \
 | 
					 | 
				
			||||||
        # Required for kadmin
 | 
					 | 
				
			||||||
        sccache clang && \
 | 
					 | 
				
			||||||
    curl https://sh.rustup.rs -sSf | sh -s -- -y && \
 | 
					 | 
				
			||||||
    . "$HOME/.cargo/env" && \
 | 
					 | 
				
			||||||
    python -m venv /ak-root/venv/ && \
 | 
					    python -m venv /ak-root/venv/ && \
 | 
				
			||||||
    bash -c "source ${VENV_PATH}/bin/activate && \
 | 
					    bash -c "source ${VENV_PATH}/bin/activate && \
 | 
				
			||||||
    pip3 install --upgrade pip poetry && \
 | 
					    pip3 install --upgrade pip && \
 | 
				
			||||||
    poetry config --local installer.no-binary cryptography,xmlsec,lxml,python-kadmin-rs && \
 | 
					    pip3 install poetry && \
 | 
				
			||||||
    poetry install --only=main --no-ansi --no-interaction --no-root && \
 | 
					    poetry install --only=main --no-ansi --no-interaction --no-root && \
 | 
				
			||||||
    pip uninstall cryptography -y && \
 | 
					    pip install --force-reinstall /wheels/*"
 | 
				
			||||||
    poetry install --only=main --no-ansi --no-interaction --no-root"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Stage 6: Run
 | 
					# Stage 6: Run
 | 
				
			||||||
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS final-image
 | 
					FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS final-image
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ARG VERSION
 | 
					ARG VERSION
 | 
				
			||||||
ARG GIT_BUILD_HASH
 | 
					ARG GIT_BUILD_HASH
 | 
				
			||||||
@ -155,12 +140,10 @@ WORKDIR /
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# We cannot cache this layer otherwise we'll end up with a bigger image
 | 
					# We cannot cache this layer otherwise we'll end up with a bigger image
 | 
				
			||||||
RUN apt-get update && \
 | 
					RUN apt-get update && \
 | 
				
			||||||
    apt-get upgrade -y && \
 | 
					 | 
				
			||||||
    # Required for runtime
 | 
					    # Required for runtime
 | 
				
			||||||
    apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 libltdl7 libxslt1.1 && \
 | 
					    apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 && \
 | 
				
			||||||
    # Required for bootstrap & healtcheck
 | 
					    # Required for bootstrap & healtcheck
 | 
				
			||||||
    apt-get install -y --no-install-recommends runit && \
 | 
					    apt-get install -y --no-install-recommends runit && \
 | 
				
			||||||
    pip3 install --no-cache-dir --upgrade pip && \
 | 
					 | 
				
			||||||
    apt-get clean && \
 | 
					    apt-get clean && \
 | 
				
			||||||
    rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
 | 
					    rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
 | 
				
			||||||
    adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
 | 
					    adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
 | 
				
			||||||
@ -193,8 +176,9 @@ ENV TMPDIR=/dev/shm/ \
 | 
				
			|||||||
    PYTHONUNBUFFERED=1 \
 | 
					    PYTHONUNBUFFERED=1 \
 | 
				
			||||||
    PATH="/ak-root/venv/bin:/lifecycle:$PATH" \
 | 
					    PATH="/ak-root/venv/bin:/lifecycle:$PATH" \
 | 
				
			||||||
    VENV_PATH="/ak-root/venv" \
 | 
					    VENV_PATH="/ak-root/venv" \
 | 
				
			||||||
    POETRY_VIRTUALENVS_CREATE=false \
 | 
					    POETRY_VIRTUALENVS_CREATE=false
 | 
				
			||||||
    GOFIPS=1
 | 
					
 | 
				
			||||||
 | 
					ENV GOFIPS=1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]
 | 
					HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										38
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								Makefile
									
									
									
									
									
								
							@ -5,9 +5,7 @@ PWD = $(shell pwd)
 | 
				
			|||||||
UID = $(shell id -u)
 | 
					UID = $(shell id -u)
 | 
				
			||||||
GID = $(shell id -g)
 | 
					GID = $(shell id -g)
 | 
				
			||||||
NPM_VERSION = $(shell python -m scripts.npm_version)
 | 
					NPM_VERSION = $(shell python -m scripts.npm_version)
 | 
				
			||||||
PY_SOURCES = authentik tests scripts lifecycle .github
 | 
					PY_SOURCES = authentik tests scripts lifecycle .github website/docs/install-config/install/aws
 | 
				
			||||||
GO_SOURCES = cmd internal
 | 
					 | 
				
			||||||
WEB_SOURCES = web/src web/packages
 | 
					 | 
				
			||||||
DOCKER_IMAGE ?= "authentik:test"
 | 
					DOCKER_IMAGE ?= "authentik:test"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
GEN_API_TS = "gen-ts-api"
 | 
					GEN_API_TS = "gen-ts-api"
 | 
				
			||||||
@ -22,11 +20,10 @@ CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
 | 
				
			|||||||
		-I .github/codespell-words.txt \
 | 
							-I .github/codespell-words.txt \
 | 
				
			||||||
		-S 'web/src/locales/**' \
 | 
							-S 'web/src/locales/**' \
 | 
				
			||||||
		-S 'website/docs/developer-docs/api/reference/**' \
 | 
							-S 'website/docs/developer-docs/api/reference/**' \
 | 
				
			||||||
		-S '**/node_modules/**' \
 | 
							authentik \
 | 
				
			||||||
		-S '**/dist/**' \
 | 
							internal \
 | 
				
			||||||
		$(PY_SOURCES) \
 | 
							cmd \
 | 
				
			||||||
		$(GO_SOURCES) \
 | 
							web/src \
 | 
				
			||||||
		$(WEB_SOURCES) \
 | 
					 | 
				
			||||||
		website/src \
 | 
							website/src \
 | 
				
			||||||
		website/blog \
 | 
							website/blog \
 | 
				
			||||||
		website/docs \
 | 
							website/docs \
 | 
				
			||||||
@ -48,6 +45,15 @@ help:  ## Show this help
 | 
				
			|||||||
go-test:
 | 
					go-test:
 | 
				
			||||||
	go test -timeout 0 -v -race -cover ./...
 | 
						go test -timeout 0 -v -race -cover ./...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test-docker:  ## Run all tests in a docker-compose
 | 
				
			||||||
 | 
						echo "PG_PASS=$(shell openssl rand 32 | base64 -w 0)" >> .env
 | 
				
			||||||
 | 
						echo "AUTHENTIK_SECRET_KEY=$(shell openssl rand 32 | base64 -w 0)" >> .env
 | 
				
			||||||
 | 
						docker compose pull -q
 | 
				
			||||||
 | 
						docker compose up --no-start
 | 
				
			||||||
 | 
						docker compose start postgresql redis
 | 
				
			||||||
 | 
						docker compose run -u root server test-all
 | 
				
			||||||
 | 
						rm -f .env
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test: ## Run the server tests and produce a coverage report (locally)
 | 
					test: ## Run the server tests and produce a coverage report (locally)
 | 
				
			||||||
	coverage run manage.py test --keepdb authentik
 | 
						coverage run manage.py test --keepdb authentik
 | 
				
			||||||
	coverage html
 | 
						coverage html
 | 
				
			||||||
@ -72,9 +78,6 @@ migrate: ## Run the Authentik Django server's migrations
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
i18n-extract: core-i18n-extract web-i18n-extract  ## Extract strings that require translation into files to send to a translation service
 | 
					i18n-extract: core-i18n-extract web-i18n-extract  ## Extract strings that require translation into files to send to a translation service
 | 
				
			||||||
 | 
					
 | 
				
			||||||
aws-cfn:
 | 
					 | 
				
			||||||
	cd lifecycle/aws && npm run aws-cfn
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
core-i18n-extract:
 | 
					core-i18n-extract:
 | 
				
			||||||
	ak makemessages \
 | 
						ak makemessages \
 | 
				
			||||||
		--add-location file \
 | 
							--add-location file \
 | 
				
			||||||
@ -146,7 +149,7 @@ gen-client-ts: gen-clean-ts  ## Build and install the authentik API for Typescri
 | 
				
			|||||||
	docker run \
 | 
						docker run \
 | 
				
			||||||
		--rm -v ${PWD}:/local \
 | 
							--rm -v ${PWD}:/local \
 | 
				
			||||||
		--user ${UID}:${GID} \
 | 
							--user ${UID}:${GID} \
 | 
				
			||||||
		docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \
 | 
							docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
 | 
				
			||||||
		-i /local/schema.yml \
 | 
							-i /local/schema.yml \
 | 
				
			||||||
		-g typescript-fetch \
 | 
							-g typescript-fetch \
 | 
				
			||||||
		-o /local/${GEN_API_TS} \
 | 
							-o /local/${GEN_API_TS} \
 | 
				
			||||||
@ -249,6 +252,9 @@ website-build:
 | 
				
			|||||||
website-watch:  ## Build and watch the documentation website, updating automatically
 | 
					website-watch:  ## Build and watch the documentation website, updating automatically
 | 
				
			||||||
	cd website && npm run watch
 | 
						cd website && npm run watch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					aws-cfn:
 | 
				
			||||||
 | 
						cd website && npm run aws-cfn
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#########################
 | 
					#########################
 | 
				
			||||||
## Docker
 | 
					## Docker
 | 
				
			||||||
#########################
 | 
					#########################
 | 
				
			||||||
@ -257,9 +263,6 @@ docker:  ## Build a docker image of the current source tree
 | 
				
			|||||||
	mkdir -p ${GEN_API_TS}
 | 
						mkdir -p ${GEN_API_TS}
 | 
				
			||||||
	DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
 | 
						DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test-docker:
 | 
					 | 
				
			||||||
	BUILD=true ./scripts/test_docker.sh
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#########################
 | 
					#########################
 | 
				
			||||||
## CI
 | 
					## CI
 | 
				
			||||||
#########################
 | 
					#########################
 | 
				
			||||||
@ -284,8 +287,3 @@ ci-bandit: ci--meta-debug
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
ci-pending-migrations: ci--meta-debug
 | 
					ci-pending-migrations: ci--meta-debug
 | 
				
			||||||
	ak makemigrations --check
 | 
						ak makemigrations --check
 | 
				
			||||||
 | 
					 | 
				
			||||||
ci-test: ci--meta-debug
 | 
					 | 
				
			||||||
	coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
 | 
					 | 
				
			||||||
	coverage report
 | 
					 | 
				
			||||||
	coverage xml
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
| Version   | Supported |
 | 
					| Version   | Supported |
 | 
				
			||||||
| --------- | --------- |
 | 
					| --------- | --------- |
 | 
				
			||||||
 | 
					| 2024.8.x  | ✅        |
 | 
				
			||||||
| 2024.10.x | ✅        |
 | 
					| 2024.10.x | ✅        |
 | 
				
			||||||
| 2024.12.x | ✅        |
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Reporting a Vulnerability
 | 
					## Reporting a Vulnerability
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from os import environ
 | 
					from os import environ
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__version__ = "2024.12.3"
 | 
					__version__ = "2024.10.5"
 | 
				
			||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
					ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -16,5 +16,5 @@ def get_full_version() -> str:
 | 
				
			|||||||
    """Get full version, with build hash appended"""
 | 
					    """Get full version, with build hash appended"""
 | 
				
			||||||
    version = __version__
 | 
					    version = __version__
 | 
				
			||||||
    if (build_hash := get_build_hash()) != "":
 | 
					    if (build_hash := get_build_hash()) != "":
 | 
				
			||||||
        return f"{version}+{build_hash}"
 | 
					        version += "." + build_hash
 | 
				
			||||||
    return version
 | 
					    return version
 | 
				
			||||||
 | 
				
			|||||||
@ -7,9 +7,7 @@ from sys import version as python_version
 | 
				
			|||||||
from typing import TypedDict
 | 
					from typing import TypedDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from cryptography.hazmat.backends.openssl.backend import backend
 | 
					from cryptography.hazmat.backends.openssl.backend import backend
 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
from django.utils.timezone import now
 | 
					from django.utils.timezone import now
 | 
				
			||||||
from django.views.debug import SafeExceptionReporterFilter
 | 
					 | 
				
			||||||
from drf_spectacular.utils import extend_schema
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
from rest_framework.fields import SerializerMethodField
 | 
					from rest_framework.fields import SerializerMethodField
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
@ -54,16 +52,10 @@ class SystemInfoSerializer(PassiveSerializer):
 | 
				
			|||||||
    def get_http_headers(self, request: Request) -> dict[str, str]:
 | 
					    def get_http_headers(self, request: Request) -> dict[str, str]:
 | 
				
			||||||
        """Get HTTP Request headers"""
 | 
					        """Get HTTP Request headers"""
 | 
				
			||||||
        headers = {}
 | 
					        headers = {}
 | 
				
			||||||
        raw_session = request._request.COOKIES.get(settings.SESSION_COOKIE_NAME)
 | 
					 | 
				
			||||||
        for key, value in request.META.items():
 | 
					        for key, value in request.META.items():
 | 
				
			||||||
            if not isinstance(value, str):
 | 
					            if not isinstance(value, str):
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            actual_value = value
 | 
					            headers[key] = value
 | 
				
			||||||
            if raw_session in actual_value:
 | 
					 | 
				
			||||||
                actual_value = actual_value.replace(
 | 
					 | 
				
			||||||
                    raw_session, SafeExceptionReporterFilter.cleansed_substitute
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            headers[key] = actual_value
 | 
					 | 
				
			||||||
        return headers
 | 
					        return headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_http_host(self, request: Request) -> str:
 | 
					    def get_http_host(self, request: Request) -> str:
 | 
				
			||||||
 | 
				
			|||||||
@ -1,16 +1,12 @@
 | 
				
			|||||||
"""authentik administration overview"""
 | 
					"""authentik administration overview"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from socket import gethostname
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from drf_spectacular.utils import extend_schema, inline_serializer
 | 
					from drf_spectacular.utils import extend_schema, inline_serializer
 | 
				
			||||||
from packaging.version import parse
 | 
					from rest_framework.fields import IntegerField
 | 
				
			||||||
from rest_framework.fields import BooleanField, CharField
 | 
					 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.views import APIView
 | 
					from rest_framework.views import APIView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik import get_full_version
 | 
					 | 
				
			||||||
from authentik.rbac.permissions import HasPermission
 | 
					from authentik.rbac.permissions import HasPermission
 | 
				
			||||||
from authentik.root.celery import CELERY_APP
 | 
					from authentik.root.celery import CELERY_APP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -20,38 +16,11 @@ class WorkerView(APIView):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    permission_classes = [HasPermission("authentik_rbac.view_system_info")]
 | 
					    permission_classes = [HasPermission("authentik_rbac.view_system_info")]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @extend_schema(
 | 
					    @extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()}))
 | 
				
			||||||
        responses=inline_serializer(
 | 
					 | 
				
			||||||
            "Worker",
 | 
					 | 
				
			||||||
            fields={
 | 
					 | 
				
			||||||
                "worker_id": CharField(),
 | 
					 | 
				
			||||||
                "version": CharField(),
 | 
					 | 
				
			||||||
                "version_matching": BooleanField(),
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            many=True,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    def get(self, request: Request) -> Response:
 | 
					    def get(self, request: Request) -> Response:
 | 
				
			||||||
        """Get currently connected worker count."""
 | 
					        """Get currently connected worker count."""
 | 
				
			||||||
        raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
 | 
					        count = len(CELERY_APP.control.ping(timeout=0.5))
 | 
				
			||||||
        our_version = parse(get_full_version())
 | 
					 | 
				
			||||||
        response = []
 | 
					 | 
				
			||||||
        for worker in raw:
 | 
					 | 
				
			||||||
            key = list(worker.keys())[0]
 | 
					 | 
				
			||||||
            version = worker[key].get("version")
 | 
					 | 
				
			||||||
            version_matching = False
 | 
					 | 
				
			||||||
            if version:
 | 
					 | 
				
			||||||
                version_matching = parse(version) == our_version
 | 
					 | 
				
			||||||
            response.append(
 | 
					 | 
				
			||||||
                {"worker_id": key, "version": version, "version_matching": version_matching}
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        # In debug we run with `task_always_eager`, so tasks are ran on the main process
 | 
					        # In debug we run with `task_always_eager`, so tasks are ran on the main process
 | 
				
			||||||
        if settings.DEBUG:  # pragma: no cover
 | 
					        if settings.DEBUG:  # pragma: no cover
 | 
				
			||||||
            response.append(
 | 
					            count += 1
 | 
				
			||||||
                {
 | 
					        return Response({"count": count})
 | 
				
			||||||
                    "worker_id": f"authentik-debug@{gethostname()}",
 | 
					 | 
				
			||||||
                    "version": get_full_version(),
 | 
					 | 
				
			||||||
                    "version_matching": True,
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        return Response(response)
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -1,10 +1,11 @@
 | 
				
			|||||||
"""authentik admin app config"""
 | 
					"""authentik admin app config"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from prometheus_client import Info
 | 
					from prometheus_client import Gauge, Info
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.blueprints.apps import ManagedAppConfig
 | 
					from authentik.blueprints.apps import ManagedAppConfig
 | 
				
			||||||
 | 
					
 | 
				
			||||||
PROM_INFO = Info("authentik_version", "Currently running authentik version")
 | 
					PROM_INFO = Info("authentik_version", "Currently running authentik version")
 | 
				
			||||||
 | 
					GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AuthentikAdminConfig(ManagedAppConfig):
 | 
					class AuthentikAdminConfig(ManagedAppConfig):
 | 
				
			||||||
 | 
				
			|||||||
@ -1,35 +1,14 @@
 | 
				
			|||||||
"""admin signals"""
 | 
					"""admin signals"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
from packaging.version import parse
 | 
					 | 
				
			||||||
from prometheus_client import Gauge
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik import get_full_version
 | 
					from authentik.admin.apps import GAUGE_WORKERS
 | 
				
			||||||
from authentik.root.celery import CELERY_APP
 | 
					from authentik.root.celery import CELERY_APP
 | 
				
			||||||
from authentik.root.monitoring import monitoring_set
 | 
					from authentik.root.monitoring import monitoring_set
 | 
				
			||||||
 | 
					
 | 
				
			||||||
GAUGE_WORKERS = Gauge(
 | 
					 | 
				
			||||||
    "authentik_admin_workers",
 | 
					 | 
				
			||||||
    "Currently connected workers, their versions and if they are the same version as authentik",
 | 
					 | 
				
			||||||
    ["version", "version_matched"],
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
_version = parse(get_full_version())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(monitoring_set)
 | 
					@receiver(monitoring_set)
 | 
				
			||||||
def monitoring_set_workers(sender, **kwargs):
 | 
					def monitoring_set_workers(sender, **kwargs):
 | 
				
			||||||
    """Set worker gauge"""
 | 
					    """Set worker gauge"""
 | 
				
			||||||
    raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
 | 
					    count = len(CELERY_APP.control.ping(timeout=0.5))
 | 
				
			||||||
    worker_version_count = {}
 | 
					    GAUGE_WORKERS.set(count)
 | 
				
			||||||
    for worker in raw:
 | 
					 | 
				
			||||||
        key = list(worker.keys())[0]
 | 
					 | 
				
			||||||
        version = worker[key].get("version")
 | 
					 | 
				
			||||||
        version_matching = False
 | 
					 | 
				
			||||||
        if version:
 | 
					 | 
				
			||||||
            version_matching = parse(version) == _version
 | 
					 | 
				
			||||||
        worker_version_count.setdefault(version, {"count": 0, "matching": version_matching})
 | 
					 | 
				
			||||||
        worker_version_count[version]["count"] += 1
 | 
					 | 
				
			||||||
    for version, stats in worker_version_count.items():
 | 
					 | 
				
			||||||
        GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -34,7 +34,7 @@ class TestAdminAPI(TestCase):
 | 
				
			|||||||
        response = self.client.get(reverse("authentik_api:admin_workers"))
 | 
					        response = self.client.get(reverse("authentik_api:admin_workers"))
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
        body = loads(response.content)
 | 
					        body = loads(response.content)
 | 
				
			||||||
        self.assertEqual(len(body), 0)
 | 
					        self.assertEqual(body["count"], 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_metrics(self):
 | 
					    def test_metrics(self):
 | 
				
			||||||
        """Test metrics API"""
 | 
					        """Test metrics API"""
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										67
									
								
								authentik/api/authorization.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								authentik/api/authorization.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,67 @@
 | 
				
			|||||||
 | 
					"""API Authorization"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db.models import Model
 | 
				
			||||||
 | 
					from django.db.models.query import QuerySet
 | 
				
			||||||
 | 
					from django_filters.rest_framework import DjangoFilterBackend
 | 
				
			||||||
 | 
					from rest_framework.authentication import get_authorization_header
 | 
				
			||||||
 | 
					from rest_framework.filters import BaseFilterBackend
 | 
				
			||||||
 | 
					from rest_framework.permissions import BasePermission
 | 
				
			||||||
 | 
					from rest_framework.request import Request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.api.authentication import validate_auth
 | 
				
			||||||
 | 
					from authentik.rbac.filters import ObjectFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OwnerFilter(BaseFilterBackend):
 | 
				
			||||||
 | 
					    """Filter objects by their owner"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    owner_key = "user"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
 | 
				
			||||||
 | 
					        if request.user.is_superuser:
 | 
				
			||||||
 | 
					            return queryset
 | 
				
			||||||
 | 
					        return queryset.filter(**{self.owner_key: request.user})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SecretKeyFilter(DjangoFilterBackend):
 | 
				
			||||||
 | 
					    """Allow access to all objects when authenticated with secret key as token.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Replaces both DjangoFilterBackend and ObjectFilter"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
 | 
				
			||||||
 | 
					        auth_header = get_authorization_header(request)
 | 
				
			||||||
 | 
					        token = validate_auth(auth_header)
 | 
				
			||||||
 | 
					        if token and token == settings.SECRET_KEY:
 | 
				
			||||||
 | 
					            return queryset
 | 
				
			||||||
 | 
					        queryset = ObjectFilter().filter_queryset(request, queryset, view)
 | 
				
			||||||
 | 
					        return super().filter_queryset(request, queryset, view)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OwnerPermissions(BasePermission):
 | 
				
			||||||
 | 
					    """Authorize requests by an object's owner matching the requesting user"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    owner_key = "user"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def has_permission(self, request: Request, view) -> bool:
 | 
				
			||||||
 | 
					        """If the user is authenticated, we allow all requests here. For listing, the
 | 
				
			||||||
 | 
					        object-level permissions are done by the filter backend"""
 | 
				
			||||||
 | 
					        return request.user.is_authenticated
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def has_object_permission(self, request: Request, view, obj: Model) -> bool:
 | 
				
			||||||
 | 
					        """Check if the object's owner matches the currently logged in user"""
 | 
				
			||||||
 | 
					        if not hasattr(obj, self.owner_key):
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        owner = getattr(obj, self.owner_key)
 | 
				
			||||||
 | 
					        if owner != request.user:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OwnerSuperuserPermissions(OwnerPermissions):
 | 
				
			||||||
 | 
					    """Similar to OwnerPermissions, except always allow access for superusers"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def has_object_permission(self, request: Request, view, obj: Model) -> bool:
 | 
				
			||||||
 | 
					        if request.user.is_superuser:
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        return super().has_object_permission(request, view, obj)
 | 
				
			||||||
@ -1,68 +0,0 @@
 | 
				
			|||||||
"""Test and debug Blueprints"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import atexit
 | 
					 | 
				
			||||||
import readline
 | 
					 | 
				
			||||||
from pathlib import Path
 | 
					 | 
				
			||||||
from pprint import pformat
 | 
					 | 
				
			||||||
from sys import exit as sysexit
 | 
					 | 
				
			||||||
from textwrap import indent
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.core.management.base import BaseCommand, no_translations
 | 
					 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					 | 
				
			||||||
from yaml import load
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.blueprints.v1.common import BlueprintLoader, EntryInvalidError
 | 
					 | 
				
			||||||
from authentik.core.management.commands.shell import get_banner_text
 | 
					 | 
				
			||||||
from authentik.lib.utils.errors import exception_to_string
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
LOGGER = get_logger()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Command(BaseCommand):
 | 
					 | 
				
			||||||
    """Test and debug Blueprints"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    lines = []
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, *args, **kwargs) -> None:
 | 
					 | 
				
			||||||
        super().__init__(*args, **kwargs)
 | 
					 | 
				
			||||||
        histfolder = Path("~").expanduser() / Path(".local/share/authentik")
 | 
					 | 
				
			||||||
        histfolder.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
        histfile = histfolder / Path("blueprint_shell_history")
 | 
					 | 
				
			||||||
        readline.parse_and_bind("tab: complete")
 | 
					 | 
				
			||||||
        readline.parse_and_bind("set editing-mode vi")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            readline.read_history_file(str(histfile))
 | 
					 | 
				
			||||||
        except FileNotFoundError:
 | 
					 | 
				
			||||||
            pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        atexit.register(readline.write_history_file, str(histfile))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @no_translations
 | 
					 | 
				
			||||||
    def handle(self, *args, **options):
 | 
					 | 
				
			||||||
        """Interactively debug blueprint files"""
 | 
					 | 
				
			||||||
        self.stdout.write(get_banner_text("Blueprint shell"))
 | 
					 | 
				
			||||||
        self.stdout.write("Type '.eval' to evaluate previously entered statement(s).")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        def do_eval():
 | 
					 | 
				
			||||||
            yaml_input = "\n".join([line for line in self.lines if line])
 | 
					 | 
				
			||||||
            data = load(yaml_input, BlueprintLoader)
 | 
					 | 
				
			||||||
            self.stdout.write(pformat(data))
 | 
					 | 
				
			||||||
            self.lines = []
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        while True:
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                line = input("> ")
 | 
					 | 
				
			||||||
                if line == ".eval":
 | 
					 | 
				
			||||||
                    do_eval()
 | 
					 | 
				
			||||||
                else:
 | 
					 | 
				
			||||||
                    self.lines.append(line)
 | 
					 | 
				
			||||||
            except EntryInvalidError as exc:
 | 
					 | 
				
			||||||
                self.stdout.write("Failed to evaluate expression:")
 | 
					 | 
				
			||||||
                self.stdout.write(indent(exception_to_string(exc), prefix="  "))
 | 
					 | 
				
			||||||
            except EOFError:
 | 
					 | 
				
			||||||
                break
 | 
					 | 
				
			||||||
            except KeyboardInterrupt:
 | 
					 | 
				
			||||||
                self.stdout.write()
 | 
					 | 
				
			||||||
                sysexit(0)
 | 
					 | 
				
			||||||
        self.stdout.write()
 | 
					 | 
				
			||||||
@ -126,7 +126,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
        def_name_perm = f"model_{model_path}_permissions"
 | 
					        def_name_perm = f"model_{model_path}_permissions"
 | 
				
			||||||
        def_path_perm = f"#/$defs/{def_name_perm}"
 | 
					        def_path_perm = f"#/$defs/{def_name_perm}"
 | 
				
			||||||
        self.schema["$defs"][def_name_perm] = self.model_permissions(model)
 | 
					        self.schema["$defs"][def_name_perm] = self.model_permissions(model)
 | 
				
			||||||
        template = {
 | 
					        return {
 | 
				
			||||||
            "type": "object",
 | 
					            "type": "object",
 | 
				
			||||||
            "required": ["model", "identifiers"],
 | 
					            "required": ["model", "identifiers"],
 | 
				
			||||||
            "properties": {
 | 
					            "properties": {
 | 
				
			||||||
@ -143,11 +143,6 @@ class Command(BaseCommand):
 | 
				
			|||||||
                "identifiers": {"$ref": def_path},
 | 
					                "identifiers": {"$ref": def_path},
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        # Meta models don't require identifiers, as there's no matching database model to find
 | 
					 | 
				
			||||||
        if issubclass(model, BaseMetaModel):
 | 
					 | 
				
			||||||
            del template["properties"]["identifiers"]
 | 
					 | 
				
			||||||
            template["required"].remove("identifiers")
 | 
					 | 
				
			||||||
        return template
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def field_to_jsonschema(self, field: Field) -> dict:
 | 
					    def field_to_jsonschema(self, field: Field) -> dict:
 | 
				
			||||||
        """Convert a single field to json schema"""
 | 
					        """Convert a single field to json schema"""
 | 
				
			||||||
 | 
				
			|||||||
@ -146,10 +146,6 @@ entries:
 | 
				
			|||||||
                  ]
 | 
					                  ]
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
              nested_context: !Context context2
 | 
					              nested_context: !Context context2
 | 
				
			||||||
              at_index_sequence: !AtIndex [!Context sequence, 0]
 | 
					 | 
				
			||||||
              at_index_sequence_default: !AtIndex [!Context sequence, 100, "non existent"]
 | 
					 | 
				
			||||||
              at_index_mapping: !AtIndex [!Context mapping, "key2"]
 | 
					 | 
				
			||||||
              at_index_mapping_default: !AtIndex [!Context mapping, "invalid", "non existent"]
 | 
					 | 
				
			||||||
      identifiers:
 | 
					      identifiers:
 | 
				
			||||||
          name: test
 | 
					          name: test
 | 
				
			||||||
      conditions:
 | 
					      conditions:
 | 
				
			||||||
 | 
				
			|||||||
@ -215,10 +215,6 @@ class TestBlueprintsV1(TransactionTestCase):
 | 
				
			|||||||
                    },
 | 
					                    },
 | 
				
			||||||
                    "nested_context": "context-nested-value",
 | 
					                    "nested_context": "context-nested-value",
 | 
				
			||||||
                    "env_null": None,
 | 
					                    "env_null": None,
 | 
				
			||||||
                    "at_index_sequence": "foo",
 | 
					 | 
				
			||||||
                    "at_index_sequence_default": "non existent",
 | 
					 | 
				
			||||||
                    "at_index_mapping": 2,
 | 
					 | 
				
			||||||
                    "at_index_mapping_default": "non existent",
 | 
					 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            ).exists()
 | 
					            ).exists()
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -24,10 +24,6 @@ from authentik.lib.sentry import SentryIgnoredException
 | 
				
			|||||||
from authentik.policies.models import PolicyBindingModel
 | 
					from authentik.policies.models import PolicyBindingModel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UNSET:
 | 
					 | 
				
			||||||
    """Used to test whether a key has not been set."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def get_attrs(obj: SerializerModel) -> dict[str, Any]:
 | 
					def get_attrs(obj: SerializerModel) -> dict[str, Any]:
 | 
				
			||||||
    """Get object's attributes via their serializer, and convert it to a normal dict"""
 | 
					    """Get object's attributes via their serializer, and convert it to a normal dict"""
 | 
				
			||||||
    serializer: Serializer = obj.serializer(obj)
 | 
					    serializer: Serializer = obj.serializer(obj)
 | 
				
			||||||
@ -202,9 +198,6 @@ class Blueprint:
 | 
				
			|||||||
class YAMLTag:
 | 
					class YAMLTag:
 | 
				
			||||||
    """Base class for all YAML Tags"""
 | 
					    """Base class for all YAML Tags"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __repr__(self) -> str:
 | 
					 | 
				
			||||||
        return str(self.resolve(BlueprintEntry(""), Blueprint()))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
					    def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
				
			||||||
        """Implement yaml tag logic"""
 | 
					        """Implement yaml tag logic"""
 | 
				
			||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
@ -563,53 +556,6 @@ class Value(EnumeratedItem):
 | 
				
			|||||||
            raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc
 | 
					            raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AtIndex(YAMLTag):
 | 
					 | 
				
			||||||
    """Get value at index of a sequence or mapping"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    obj: YAMLTag | dict | list | tuple
 | 
					 | 
				
			||||||
    attribute: int | str | YAMLTag
 | 
					 | 
				
			||||||
    default: Any | UNSET
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
					 | 
				
			||||||
        super().__init__()
 | 
					 | 
				
			||||||
        self.obj = loader.construct_object(node.value[0])
 | 
					 | 
				
			||||||
        self.attribute = loader.construct_object(node.value[1])
 | 
					 | 
				
			||||||
        if len(node.value) == 2:  # noqa: PLR2004
 | 
					 | 
				
			||||||
            self.default = UNSET
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            self.default = loader.construct_object(node.value[2])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
					 | 
				
			||||||
        if isinstance(self.obj, YAMLTag):
 | 
					 | 
				
			||||||
            obj = self.obj.resolve(entry, blueprint)
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            obj = self.obj
 | 
					 | 
				
			||||||
        if isinstance(self.attribute, YAMLTag):
 | 
					 | 
				
			||||||
            attribute = self.attribute.resolve(entry, blueprint)
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            attribute = self.attribute
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if isinstance(obj, list | tuple):
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                return obj[attribute]
 | 
					 | 
				
			||||||
            except TypeError as exc:
 | 
					 | 
				
			||||||
                raise EntryInvalidError.from_entry(
 | 
					 | 
				
			||||||
                    f"Invalid index for list: {attribute}", entry
 | 
					 | 
				
			||||||
                ) from exc
 | 
					 | 
				
			||||||
            except IndexError as exc:
 | 
					 | 
				
			||||||
                if self.default is UNSET:
 | 
					 | 
				
			||||||
                    raise EntryInvalidError.from_entry(
 | 
					 | 
				
			||||||
                        f"Index out of range: {attribute}", entry
 | 
					 | 
				
			||||||
                    ) from exc
 | 
					 | 
				
			||||||
                return self.default
 | 
					 | 
				
			||||||
        if attribute in obj:
 | 
					 | 
				
			||||||
            return obj[attribute]
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            if self.default is UNSET:
 | 
					 | 
				
			||||||
                raise EntryInvalidError.from_entry(f"Key does not exist: {attribute}", entry)
 | 
					 | 
				
			||||||
            return self.default
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class BlueprintDumper(SafeDumper):
 | 
					class BlueprintDumper(SafeDumper):
 | 
				
			||||||
    """Dump dataclasses to yaml"""
 | 
					    """Dump dataclasses to yaml"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -660,7 +606,6 @@ class BlueprintLoader(SafeLoader):
 | 
				
			|||||||
        self.add_constructor("!Enumerate", Enumerate)
 | 
					        self.add_constructor("!Enumerate", Enumerate)
 | 
				
			||||||
        self.add_constructor("!Value", Value)
 | 
					        self.add_constructor("!Value", Value)
 | 
				
			||||||
        self.add_constructor("!Index", Index)
 | 
					        self.add_constructor("!Index", Index)
 | 
				
			||||||
        self.add_constructor("!AtIndex", AtIndex)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EntryInvalidError(SentryIgnoredException):
 | 
					class EntryInvalidError(SentryIgnoredException):
 | 
				
			||||||
 | 
				
			|||||||
@ -50,7 +50,7 @@ from authentik.enterprise.providers.microsoft_entra.models import (
 | 
				
			|||||||
    MicrosoftEntraProviderGroup,
 | 
					    MicrosoftEntraProviderGroup,
 | 
				
			||||||
    MicrosoftEntraProviderUser,
 | 
					    MicrosoftEntraProviderUser,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from authentik.enterprise.providers.ssf.models import StreamEvent
 | 
					from authentik.enterprise.providers.rac.models import ConnectionToken
 | 
				
			||||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
 | 
					from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
 | 
				
			||||||
    EndpointDevice,
 | 
					    EndpointDevice,
 | 
				
			||||||
    EndpointDeviceConnection,
 | 
					    EndpointDeviceConnection,
 | 
				
			||||||
@ -71,7 +71,6 @@ from authentik.providers.oauth2.models import (
 | 
				
			|||||||
    DeviceToken,
 | 
					    DeviceToken,
 | 
				
			||||||
    RefreshToken,
 | 
					    RefreshToken,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from authentik.providers.rac.models import ConnectionToken
 | 
					 | 
				
			||||||
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
 | 
					from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
 | 
				
			||||||
from authentik.rbac.models import Role
 | 
					from authentik.rbac.models import Role
 | 
				
			||||||
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
 | 
					from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
 | 
				
			||||||
@ -132,7 +131,6 @@ def excluded_models() -> list[type[Model]]:
 | 
				
			|||||||
        EndpointDevice,
 | 
					        EndpointDevice,
 | 
				
			||||||
        EndpointDeviceConnection,
 | 
					        EndpointDeviceConnection,
 | 
				
			||||||
        DeviceToken,
 | 
					        DeviceToken,
 | 
				
			||||||
        StreamEvent,
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -5,8 +5,9 @@ from hashlib import sha512
 | 
				
			|||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from sys import platform
 | 
					from sys import platform
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import pglock
 | 
				
			||||||
from dacite.core import from_dict
 | 
					from dacite.core import from_dict
 | 
				
			||||||
from django.db import DatabaseError, InternalError, ProgrammingError
 | 
					from django.db import DatabaseError, InternalError, ProgrammingError, connection
 | 
				
			||||||
from django.utils.text import slugify
 | 
					from django.utils.text import slugify
 | 
				
			||||||
from django.utils.timezone import now
 | 
					from django.utils.timezone import now
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
@ -152,15 +153,27 @@ def blueprints_find() -> list[BlueprintFile]:
 | 
				
			|||||||
@prefill_task
 | 
					@prefill_task
 | 
				
			||||||
def blueprints_discovery(self: SystemTask, path: str | None = None):
 | 
					def blueprints_discovery(self: SystemTask, path: str | None = None):
 | 
				
			||||||
    """Find blueprints and check if they need to be created in the database"""
 | 
					    """Find blueprints and check if they need to be created in the database"""
 | 
				
			||||||
    count = 0
 | 
					    with pglock.advisory(
 | 
				
			||||||
    for blueprint in blueprints_find():
 | 
					        lock_id=f"goauthentik.io/{connection.schema_name}/blueprints/discovery",
 | 
				
			||||||
        if path and blueprint.path != path:
 | 
					        timeout=0,
 | 
				
			||||||
            continue
 | 
					        side_effect=pglock.Return,
 | 
				
			||||||
        check_blueprint_v1_file(blueprint)
 | 
					    ) as lock_acquired:
 | 
				
			||||||
        count += 1
 | 
					        if not lock_acquired:
 | 
				
			||||||
    self.set_status(
 | 
					            LOGGER.debug("Not running blueprint discovery, lock was not acquired")
 | 
				
			||||||
        TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=count))
 | 
					            self.set_status(
 | 
				
			||||||
    )
 | 
					                TaskStatus.SUCCESSFUL,
 | 
				
			||||||
 | 
					                _("Blueprint discovery lock could not be acquired. Skipping discovery."),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        count = 0
 | 
				
			||||||
 | 
					        for blueprint in blueprints_find():
 | 
				
			||||||
 | 
					            if path and blueprint.path != path:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            check_blueprint_v1_file(blueprint)
 | 
				
			||||||
 | 
					            count += 1
 | 
				
			||||||
 | 
					        self.set_status(
 | 
				
			||||||
 | 
					            TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=count))
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def check_blueprint_v1_file(blueprint: BlueprintFile):
 | 
					def check_blueprint_v1_file(blueprint: BlueprintFile):
 | 
				
			||||||
@ -197,48 +210,60 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
 | 
				
			|||||||
def apply_blueprint(self: SystemTask, instance_pk: str):
 | 
					def apply_blueprint(self: SystemTask, instance_pk: str):
 | 
				
			||||||
    """Apply single blueprint"""
 | 
					    """Apply single blueprint"""
 | 
				
			||||||
    self.save_on_success = False
 | 
					    self.save_on_success = False
 | 
				
			||||||
    instance: BlueprintInstance | None = None
 | 
					    with pglock.advisory(
 | 
				
			||||||
    try:
 | 
					        lock_id=f"goauthentik.io/{connection.schema_name}/blueprints/apply/{instance_pk}",
 | 
				
			||||||
        instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
 | 
					        timeout=0,
 | 
				
			||||||
        if not instance or not instance.enabled:
 | 
					        side_effect=pglock.Return,
 | 
				
			||||||
 | 
					    ) as lock_acquired:
 | 
				
			||||||
 | 
					        if not lock_acquired:
 | 
				
			||||||
 | 
					            LOGGER.debug("Not running blueprint discovery, lock was not acquired")
 | 
				
			||||||
 | 
					            self.set_status(
 | 
				
			||||||
 | 
					                TaskStatus.SUCCESSFUL,
 | 
				
			||||||
 | 
					                _("Blueprint apply lock could not be acquired. Skipping apply."),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        self.set_uid(slugify(instance.name))
 | 
					        instance: BlueprintInstance | None = None
 | 
				
			||||||
        blueprint_content = instance.retrieve()
 | 
					        try:
 | 
				
			||||||
        file_hash = sha512(blueprint_content.encode()).hexdigest()
 | 
					            instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
 | 
				
			||||||
        importer = Importer.from_string(blueprint_content, instance.context)
 | 
					            if not instance or not instance.enabled:
 | 
				
			||||||
        if importer.blueprint.metadata:
 | 
					                return
 | 
				
			||||||
            instance.metadata = asdict(importer.blueprint.metadata)
 | 
					            self.set_uid(slugify(instance.name))
 | 
				
			||||||
        valid, logs = importer.validate()
 | 
					            blueprint_content = instance.retrieve()
 | 
				
			||||||
        if not valid:
 | 
					            file_hash = sha512(blueprint_content.encode()).hexdigest()
 | 
				
			||||||
            instance.status = BlueprintInstanceStatus.ERROR
 | 
					            importer = Importer.from_string(blueprint_content, instance.context)
 | 
				
			||||||
            instance.save()
 | 
					            if importer.blueprint.metadata:
 | 
				
			||||||
            self.set_status(TaskStatus.ERROR, *logs)
 | 
					                instance.metadata = asdict(importer.blueprint.metadata)
 | 
				
			||||||
            return
 | 
					            valid, logs = importer.validate()
 | 
				
			||||||
        with capture_logs() as logs:
 | 
					            if not valid:
 | 
				
			||||||
            applied = importer.apply()
 | 
					 | 
				
			||||||
            if not applied:
 | 
					 | 
				
			||||||
                instance.status = BlueprintInstanceStatus.ERROR
 | 
					                instance.status = BlueprintInstanceStatus.ERROR
 | 
				
			||||||
                instance.save()
 | 
					                instance.save()
 | 
				
			||||||
                self.set_status(TaskStatus.ERROR, *logs)
 | 
					                self.set_status(TaskStatus.ERROR, *logs)
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
        instance.status = BlueprintInstanceStatus.SUCCESSFUL
 | 
					            with capture_logs() as logs:
 | 
				
			||||||
        instance.last_applied_hash = file_hash
 | 
					                applied = importer.apply()
 | 
				
			||||||
        instance.last_applied = now()
 | 
					                if not applied:
 | 
				
			||||||
        self.set_status(TaskStatus.SUCCESSFUL)
 | 
					                    instance.status = BlueprintInstanceStatus.ERROR
 | 
				
			||||||
    except (
 | 
					                    instance.save()
 | 
				
			||||||
        OSError,
 | 
					                    self.set_status(TaskStatus.ERROR, *logs)
 | 
				
			||||||
        DatabaseError,
 | 
					                    return
 | 
				
			||||||
        ProgrammingError,
 | 
					            instance.status = BlueprintInstanceStatus.SUCCESSFUL
 | 
				
			||||||
        InternalError,
 | 
					            instance.last_applied_hash = file_hash
 | 
				
			||||||
        BlueprintRetrievalFailed,
 | 
					            instance.last_applied = now()
 | 
				
			||||||
        EntryInvalidError,
 | 
					            self.set_status(TaskStatus.SUCCESSFUL)
 | 
				
			||||||
    ) as exc:
 | 
					        except (
 | 
				
			||||||
        if instance:
 | 
					            OSError,
 | 
				
			||||||
            instance.status = BlueprintInstanceStatus.ERROR
 | 
					            DatabaseError,
 | 
				
			||||||
        self.set_error(exc)
 | 
					            ProgrammingError,
 | 
				
			||||||
    finally:
 | 
					            InternalError,
 | 
				
			||||||
        if instance:
 | 
					            BlueprintRetrievalFailed,
 | 
				
			||||||
            instance.save()
 | 
					            EntryInvalidError,
 | 
				
			||||||
 | 
					        ) as exc:
 | 
				
			||||||
 | 
					            if instance:
 | 
				
			||||||
 | 
					                instance.status = BlueprintInstanceStatus.ERROR
 | 
				
			||||||
 | 
					            self.set_error(exc)
 | 
				
			||||||
 | 
					        finally:
 | 
				
			||||||
 | 
					            if instance:
 | 
				
			||||||
 | 
					                instance.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@CELERY_APP.task()
 | 
					@CELERY_APP.task()
 | 
				
			||||||
 | 
				
			|||||||
@ -14,10 +14,10 @@ from rest_framework.response import Response
 | 
				
			|||||||
from rest_framework.validators import UniqueValidator
 | 
					from rest_framework.validators import UniqueValidator
 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.api.authorization import SecretKeyFilter
 | 
				
			||||||
from authentik.brands.models import Brand
 | 
					from authentik.brands.models import Brand
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
					from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
				
			||||||
from authentik.rbac.filters import SecretKeyFilter
 | 
					 | 
				
			||||||
from authentik.tenants.utils import get_current_tenant
 | 
					from authentik.tenants.utils import get_current_tenant
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,58 +0,0 @@
 | 
				
			|||||||
"""Application Roles API Viewset"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.http import HttpRequest
 | 
					 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					 | 
				
			||||||
from rest_framework.exceptions import ValidationError
 | 
					 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
					 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					 | 
				
			||||||
from authentik.core.api.utils import ModelSerializer
 | 
					 | 
				
			||||||
from authentik.core.models import (
 | 
					 | 
				
			||||||
    Application,
 | 
					 | 
				
			||||||
    ApplicationEntitlement,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ApplicationEntitlementSerializer(ModelSerializer):
 | 
					 | 
				
			||||||
    """ApplicationEntitlement Serializer"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def validate_app(self, app: Application) -> Application:
 | 
					 | 
				
			||||||
        """Ensure user has permission to view"""
 | 
					 | 
				
			||||||
        request: HttpRequest = self.context.get("request")
 | 
					 | 
				
			||||||
        if not request and SERIALIZER_CONTEXT_BLUEPRINT in self.context:
 | 
					 | 
				
			||||||
            return app
 | 
					 | 
				
			||||||
        user = request.user
 | 
					 | 
				
			||||||
        if user.has_perm("view_application", app) or user.has_perm(
 | 
					 | 
				
			||||||
            "authentik_core.view_application"
 | 
					 | 
				
			||||||
        ):
 | 
					 | 
				
			||||||
            return app
 | 
					 | 
				
			||||||
        raise ValidationError(_("User does not have access to application."), code="invalid")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        model = ApplicationEntitlement
 | 
					 | 
				
			||||||
        fields = [
 | 
					 | 
				
			||||||
            "pbm_uuid",
 | 
					 | 
				
			||||||
            "name",
 | 
					 | 
				
			||||||
            "app",
 | 
					 | 
				
			||||||
            "attributes",
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
 | 
					 | 
				
			||||||
    """ApplicationEntitlement Viewset"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    queryset = ApplicationEntitlement.objects.all()
 | 
					 | 
				
			||||||
    serializer_class = ApplicationEntitlementSerializer
 | 
					 | 
				
			||||||
    search_fields = [
 | 
					 | 
				
			||||||
        "pbm_uuid",
 | 
					 | 
				
			||||||
        "name",
 | 
					 | 
				
			||||||
        "app",
 | 
					 | 
				
			||||||
        "attributes",
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
    filterset_fields = [
 | 
					 | 
				
			||||||
        "pbm_uuid",
 | 
					 | 
				
			||||||
        "name",
 | 
					 | 
				
			||||||
        "app",
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
    ordering = ["name"]
 | 
					 | 
				
			||||||
@ -2,12 +2,16 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from typing import TypedDict
 | 
					from typing import TypedDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django_filters.rest_framework import DjangoFilterBackend
 | 
				
			||||||
 | 
					from guardian.utils import get_anonymous_user
 | 
				
			||||||
from rest_framework import mixins
 | 
					from rest_framework import mixins
 | 
				
			||||||
from rest_framework.fields import SerializerMethodField
 | 
					from rest_framework.fields import SerializerMethodField
 | 
				
			||||||
 | 
					from rest_framework.filters import OrderingFilter, SearchFilter
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
from ua_parser import user_agent_parser
 | 
					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.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import ModelSerializer
 | 
					from authentik.core.api.utils import ModelSerializer
 | 
				
			||||||
from authentik.core.models import AuthenticatedSession
 | 
					from authentik.core.models import AuthenticatedSession
 | 
				
			||||||
@ -106,4 +110,11 @@ class AuthenticatedSessionViewSet(
 | 
				
			|||||||
    search_fields = ["user__username", "last_ip", "last_user_agent"]
 | 
					    search_fields = ["user__username", "last_ip", "last_user_agent"]
 | 
				
			||||||
    filterset_fields = ["user__username", "last_ip", "last_user_agent"]
 | 
					    filterset_fields = ["user__username", "last_ip", "last_user_agent"]
 | 
				
			||||||
    ordering = ["user__username"]
 | 
					    ordering = ["user__username"]
 | 
				
			||||||
    owner_field = "user"
 | 
					    permission_classes = [OwnerSuperuserPermissions]
 | 
				
			||||||
 | 
					    filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_queryset(self):
 | 
				
			||||||
 | 
					        user = self.request.user if self.request else get_anonymous_user()
 | 
				
			||||||
 | 
					        if user.is_superuser:
 | 
				
			||||||
 | 
					            return super().get_queryset()
 | 
				
			||||||
 | 
					        return super().get_queryset().filter(user=user.pk)
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,6 @@
 | 
				
			|||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from drf_spectacular.types import OpenApiTypes
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
 | 
					from drf_spectacular.utils import OpenApiParameter, extend_schema
 | 
				
			||||||
from guardian.shortcuts import get_objects_for_user
 | 
					 | 
				
			||||||
from rest_framework.fields import (
 | 
					from rest_framework.fields import (
 | 
				
			||||||
    BooleanField,
 | 
					    BooleanField,
 | 
				
			||||||
    CharField,
 | 
					    CharField,
 | 
				
			||||||
@ -17,6 +16,7 @@ from rest_framework.viewsets import ViewSet
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.utils import MetaNameSerializer
 | 
					from authentik.core.api.utils import MetaNameSerializer
 | 
				
			||||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
 | 
					from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
 | 
				
			||||||
 | 
					from authentik.rbac.decorators import permission_required
 | 
				
			||||||
from authentik.stages.authenticator import device_classes, devices_for_user
 | 
					from authentik.stages.authenticator import device_classes, devices_for_user
 | 
				
			||||||
from authentik.stages.authenticator.models import Device
 | 
					from authentik.stages.authenticator.models import Device
 | 
				
			||||||
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
 | 
					from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
 | 
				
			||||||
@ -73,9 +73,7 @@ class AdminDeviceViewSet(ViewSet):
 | 
				
			|||||||
    def get_devices(self, **kwargs):
 | 
					    def get_devices(self, **kwargs):
 | 
				
			||||||
        """Get all devices in all child classes"""
 | 
					        """Get all devices in all child classes"""
 | 
				
			||||||
        for model in device_classes():
 | 
					        for model in device_classes():
 | 
				
			||||||
            device_set = get_objects_for_user(
 | 
					            device_set = model.objects.filter(**kwargs)
 | 
				
			||||||
                self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}", model
 | 
					 | 
				
			||||||
            ).filter(**kwargs)
 | 
					 | 
				
			||||||
            yield from device_set
 | 
					            yield from device_set
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @extend_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
@ -88,6 +86,10 @@ class AdminDeviceViewSet(ViewSet):
 | 
				
			|||||||
        ],
 | 
					        ],
 | 
				
			||||||
        responses={200: DeviceSerializer(many=True)},
 | 
					        responses={200: DeviceSerializer(many=True)},
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    @permission_required(
 | 
				
			||||||
 | 
					        None,
 | 
				
			||||||
 | 
					        [f"{model._meta.app_label}.view_{model._meta.model_name}" for model in device_classes()],
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    def list(self, request: Request) -> Response:
 | 
					    def list(self, request: Request) -> Response:
 | 
				
			||||||
        """Get all devices for current user"""
 | 
					        """Get all devices for current user"""
 | 
				
			||||||
        kwargs = {}
 | 
					        kwargs = {}
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,6 @@ from json import loads
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from django.db.models import Prefetch
 | 
					from django.db.models import Prefetch
 | 
				
			||||||
from django.http import Http404
 | 
					from django.http import Http404
 | 
				
			||||||
from django.utils.translation import gettext as _
 | 
					 | 
				
			||||||
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
 | 
					from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
 | 
				
			||||||
from django_filters.filterset import FilterSet
 | 
					from django_filters.filterset import FilterSet
 | 
				
			||||||
from drf_spectacular.utils import (
 | 
					from drf_spectacular.utils import (
 | 
				
			||||||
@ -82,37 +81,9 @@ class GroupSerializer(ModelSerializer):
 | 
				
			|||||||
        if not self.instance or not parent:
 | 
					        if not self.instance or not parent:
 | 
				
			||||||
            return parent
 | 
					            return parent
 | 
				
			||||||
        if str(parent.group_uuid) == str(self.instance.group_uuid):
 | 
					        if str(parent.group_uuid) == str(self.instance.group_uuid):
 | 
				
			||||||
            raise ValidationError(_("Cannot set group as parent of itself."))
 | 
					            raise ValidationError("Cannot set group as parent of itself.")
 | 
				
			||||||
        return parent
 | 
					        return parent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def validate_is_superuser(self, superuser: bool):
 | 
					 | 
				
			||||||
        """Ensure that the user creating this group has permissions to set the superuser flag"""
 | 
					 | 
				
			||||||
        request: Request = self.context.get("request", None)
 | 
					 | 
				
			||||||
        if not request:
 | 
					 | 
				
			||||||
            return superuser
 | 
					 | 
				
			||||||
        # If we're updating an instance, and the state hasn't changed, we don't need to check perms
 | 
					 | 
				
			||||||
        if self.instance and superuser == self.instance.is_superuser:
 | 
					 | 
				
			||||||
            return superuser
 | 
					 | 
				
			||||||
        user: User = request.user
 | 
					 | 
				
			||||||
        perm = (
 | 
					 | 
				
			||||||
            "authentik_core.enable_group_superuser"
 | 
					 | 
				
			||||||
            if superuser
 | 
					 | 
				
			||||||
            else "authentik_core.disable_group_superuser"
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        has_perm = user.has_perm(perm)
 | 
					 | 
				
			||||||
        if self.instance and not has_perm:
 | 
					 | 
				
			||||||
            has_perm = user.has_perm(perm, self.instance)
 | 
					 | 
				
			||||||
        if not has_perm:
 | 
					 | 
				
			||||||
            raise ValidationError(
 | 
					 | 
				
			||||||
                _(
 | 
					 | 
				
			||||||
                    (
 | 
					 | 
				
			||||||
                        "User does not have permission to set "
 | 
					 | 
				
			||||||
                        "superuser status to {superuser_status}."
 | 
					 | 
				
			||||||
                    ).format_map({"superuser_status": superuser})
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        return superuser
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = Group
 | 
					        model = Group
 | 
				
			||||||
        fields = [
 | 
					        fields = [
 | 
				
			||||||
 | 
				
			|||||||
@ -2,16 +2,19 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from collections.abc import Iterable
 | 
					from collections.abc import Iterable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django_filters.rest_framework import DjangoFilterBackend
 | 
				
			||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
					from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
				
			||||||
from rest_framework import mixins
 | 
					from rest_framework import mixins
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
 | 
					from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
 | 
				
			||||||
 | 
					from rest_framework.filters import OrderingFilter, SearchFilter
 | 
				
			||||||
from rest_framework.parsers import MultiPartParser
 | 
					from rest_framework.parsers import MultiPartParser
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
 | 
				
			||||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
					from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
				
			||||||
from authentik.core.api.object_types import TypesMixin
 | 
					from authentik.core.api.object_types import TypesMixin
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
@ -85,7 +88,7 @@ class SourceViewSet(
 | 
				
			|||||||
    serializer_class = SourceSerializer
 | 
					    serializer_class = SourceSerializer
 | 
				
			||||||
    lookup_field = "slug"
 | 
					    lookup_field = "slug"
 | 
				
			||||||
    search_fields = ["slug", "name"]
 | 
					    search_fields = ["slug", "name"]
 | 
				
			||||||
    filterset_fields = ["slug", "name", "managed", "pbm_uuid"]
 | 
					    filterset_fields = ["slug", "name", "managed"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):  # pragma: no cover
 | 
					    def get_queryset(self):  # pragma: no cover
 | 
				
			||||||
        return Source.objects.select_subclasses()
 | 
					        return Source.objects.select_subclasses()
 | 
				
			||||||
@ -156,9 +159,9 @@ class SourceViewSet(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserSourceConnectionSerializer(SourceSerializer):
 | 
					class UserSourceConnectionSerializer(SourceSerializer):
 | 
				
			||||||
    """User source connection"""
 | 
					    """OAuth Source Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    source_obj = SourceSerializer(read_only=True, source="source")
 | 
					    source = SourceSerializer(read_only=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = UserSourceConnection
 | 
					        model = UserSourceConnection
 | 
				
			||||||
@ -166,10 +169,10 @@ class UserSourceConnectionSerializer(SourceSerializer):
 | 
				
			|||||||
            "pk",
 | 
					            "pk",
 | 
				
			||||||
            "user",
 | 
					            "user",
 | 
				
			||||||
            "source",
 | 
					            "source",
 | 
				
			||||||
            "source_obj",
 | 
					 | 
				
			||||||
            "created",
 | 
					            "created",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        extra_kwargs = {
 | 
					        extra_kwargs = {
 | 
				
			||||||
 | 
					            "user": {"read_only": True},
 | 
				
			||||||
            "created": {"read_only": True},
 | 
					            "created": {"read_only": True},
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -186,16 +189,17 @@ class UserSourceConnectionViewSet(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    queryset = UserSourceConnection.objects.all()
 | 
					    queryset = UserSourceConnection.objects.all()
 | 
				
			||||||
    serializer_class = UserSourceConnectionSerializer
 | 
					    serializer_class = UserSourceConnectionSerializer
 | 
				
			||||||
 | 
					    permission_classes = [OwnerSuperuserPermissions]
 | 
				
			||||||
    filterset_fields = ["user", "source__slug"]
 | 
					    filterset_fields = ["user", "source__slug"]
 | 
				
			||||||
    search_fields = ["source__slug"]
 | 
					    search_fields = ["source__slug"]
 | 
				
			||||||
 | 
					    filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
 | 
				
			||||||
    ordering = ["source__slug", "pk"]
 | 
					    ordering = ["source__slug", "pk"]
 | 
				
			||||||
    owner_field = "user"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class GroupSourceConnectionSerializer(SourceSerializer):
 | 
					class GroupSourceConnectionSerializer(SourceSerializer):
 | 
				
			||||||
    """Group Source Connection"""
 | 
					    """Group Source Connection Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    source_obj = SourceSerializer(read_only=True)
 | 
					    source = SourceSerializer(read_only=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = GroupSourceConnection
 | 
					        model = GroupSourceConnection
 | 
				
			||||||
@ -203,11 +207,12 @@ class GroupSourceConnectionSerializer(SourceSerializer):
 | 
				
			|||||||
            "pk",
 | 
					            "pk",
 | 
				
			||||||
            "group",
 | 
					            "group",
 | 
				
			||||||
            "source",
 | 
					            "source",
 | 
				
			||||||
            "source_obj",
 | 
					 | 
				
			||||||
            "identifier",
 | 
					            "identifier",
 | 
				
			||||||
            "created",
 | 
					            "created",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        extra_kwargs = {
 | 
					        extra_kwargs = {
 | 
				
			||||||
 | 
					            "group": {"read_only": True},
 | 
				
			||||||
 | 
					            "identifier": {"read_only": True},
 | 
				
			||||||
            "created": {"read_only": True},
 | 
					            "created": {"read_only": True},
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -224,7 +229,8 @@ class GroupSourceConnectionViewSet(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    queryset = GroupSourceConnection.objects.all()
 | 
					    queryset = GroupSourceConnection.objects.all()
 | 
				
			||||||
    serializer_class = GroupSourceConnectionSerializer
 | 
					    serializer_class = GroupSourceConnectionSerializer
 | 
				
			||||||
 | 
					    permission_classes = [OwnerSuperuserPermissions]
 | 
				
			||||||
    filterset_fields = ["group", "source__slug"]
 | 
					    filterset_fields = ["group", "source__slug"]
 | 
				
			||||||
    search_fields = ["source__slug"]
 | 
					    search_fields = ["source__slug"]
 | 
				
			||||||
 | 
					    filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
 | 
				
			||||||
    ordering = ["source__slug", "pk"]
 | 
					    ordering = ["source__slug", "pk"]
 | 
				
			||||||
    owner_field = "user"
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -3,15 +3,18 @@
 | 
				
			|||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.utils.timezone import now
 | 
					from django.utils.timezone import now
 | 
				
			||||||
 | 
					from django_filters.rest_framework import DjangoFilterBackend
 | 
				
			||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
 | 
					from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
 | 
				
			||||||
from guardian.shortcuts import assign_perm, get_anonymous_user
 | 
					from guardian.shortcuts import assign_perm, get_anonymous_user
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.exceptions import ValidationError
 | 
					from rest_framework.exceptions import ValidationError
 | 
				
			||||||
from rest_framework.fields import CharField
 | 
					from rest_framework.fields import CharField
 | 
				
			||||||
 | 
					from rest_framework.filters import OrderingFilter, SearchFilter
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.api.authorization import OwnerSuperuserPermissions
 | 
				
			||||||
from authentik.blueprints.api import ManagedSerializer
 | 
					from authentik.blueprints.api import ManagedSerializer
 | 
				
			||||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
					from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
@ -135,8 +138,8 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
        "managed",
 | 
					        "managed",
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
    ordering = ["identifier", "expires"]
 | 
					    ordering = ["identifier", "expires"]
 | 
				
			||||||
    owner_field = "user"
 | 
					    permission_classes = [OwnerSuperuserPermissions]
 | 
				
			||||||
    rbac_allow_create_without_perm = True
 | 
					    filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):
 | 
				
			||||||
        user = self.request.user if self.request else get_anonymous_user()
 | 
					        user = self.request.user if self.request else get_anonymous_user()
 | 
				
			||||||
 | 
				
			|||||||
@ -22,7 +22,7 @@ from authentik.blueprints.v1.common import (
 | 
				
			|||||||
from authentik.blueprints.v1.importer import Importer
 | 
					from authentik.blueprints.v1.importer import Importer
 | 
				
			||||||
from authentik.core.api.applications import ApplicationSerializer
 | 
					from authentik.core.api.applications import ApplicationSerializer
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
from authentik.core.models import Application, Provider
 | 
					from authentik.core.models import Provider
 | 
				
			||||||
from authentik.lib.utils.reflection import all_subclasses
 | 
					from authentik.lib.utils.reflection import all_subclasses
 | 
				
			||||||
from authentik.policies.api.bindings import PolicyBindingSerializer
 | 
					from authentik.policies.api.bindings import PolicyBindingSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -51,13 +51,6 @@ class TransactionProviderField(DictField):
 | 
				
			|||||||
class TransactionPolicyBindingSerializer(PolicyBindingSerializer):
 | 
					class TransactionPolicyBindingSerializer(PolicyBindingSerializer):
 | 
				
			||||||
    """PolicyBindingSerializer which does not require target as target is set implicitly"""
 | 
					    """PolicyBindingSerializer which does not require target as target is set implicitly"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def validate(self, attrs):
 | 
					 | 
				
			||||||
        # As the PolicyBindingSerializer checks that the correct things can be bound to a target
 | 
					 | 
				
			||||||
        # but we don't have a target here as that's set by the blueprint, pass in an empty app
 | 
					 | 
				
			||||||
        # which will have the correct allowed combination of group/user/policy.
 | 
					 | 
				
			||||||
        attrs["target"] = Application()
 | 
					 | 
				
			||||||
        return super().validate(attrs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta(PolicyBindingSerializer.Meta):
 | 
					    class Meta(PolicyBindingSerializer.Meta):
 | 
				
			||||||
        fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"]
 | 
					        fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,11 @@
 | 
				
			|||||||
"""User API Views"""
 | 
					"""User API Views"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from datetime import datetime, timedelta
 | 
					from datetime import timedelta
 | 
				
			||||||
from hashlib import sha256
 | 
					 | 
				
			||||||
from json import loads
 | 
					from json import loads
 | 
				
			||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.contrib.auth import update_session_auth_hash
 | 
					from django.contrib.auth import update_session_auth_hash
 | 
				
			||||||
from django.contrib.auth.models import AnonymousUser, Permission
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
 | 
					from django.contrib.sessions.backends.cache import KEY_PREFIX
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.db.models.functions import ExtractHour
 | 
					from django.db.models.functions import ExtractHour
 | 
				
			||||||
@ -85,7 +84,6 @@ from authentik.flows.models import FlowToken
 | 
				
			|||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
 | 
					from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
 | 
				
			||||||
from authentik.flows.views.executor import QS_KEY_TOKEN
 | 
					from authentik.flows.views.executor import QS_KEY_TOKEN
 | 
				
			||||||
from authentik.lib.avatars import get_avatar
 | 
					from authentik.lib.avatars import get_avatar
 | 
				
			||||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
 | 
					 | 
				
			||||||
from authentik.rbac.decorators import permission_required
 | 
					from authentik.rbac.decorators import permission_required
 | 
				
			||||||
from authentik.rbac.models import get_permission_choices
 | 
					from authentik.rbac.models import get_permission_choices
 | 
				
			||||||
from authentik.stages.email.models import EmailStage
 | 
					from authentik.stages.email.models import EmailStage
 | 
				
			||||||
@ -238,11 +236,9 @@ class UserSerializer(ModelSerializer):
 | 
				
			|||||||
            "path",
 | 
					            "path",
 | 
				
			||||||
            "type",
 | 
					            "type",
 | 
				
			||||||
            "uuid",
 | 
					            "uuid",
 | 
				
			||||||
            "password_change_date",
 | 
					 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        extra_kwargs = {
 | 
					        extra_kwargs = {
 | 
				
			||||||
            "name": {"allow_blank": True},
 | 
					            "name": {"allow_blank": True},
 | 
				
			||||||
            "password_change_date": {"read_only": True},
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -431,7 +427,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
    queryset = User.objects.none()
 | 
					    queryset = User.objects.none()
 | 
				
			||||||
    ordering = ["username"]
 | 
					    ordering = ["username"]
 | 
				
			||||||
    serializer_class = UserSerializer
 | 
					    serializer_class = UserSerializer
 | 
				
			||||||
    search_fields = ["username", "name", "is_active", "email", "uuid", "attributes"]
 | 
					    search_fields = ["username", "name", "is_active", "email", "uuid"]
 | 
				
			||||||
    filterset_class = UsersFilter
 | 
					    filterset_class = UsersFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):
 | 
				
			||||||
@ -448,19 +444,15 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
    def list(self, request, *args, **kwargs):
 | 
					    def list(self, request, *args, **kwargs):
 | 
				
			||||||
        return super().list(request, *args, **kwargs)
 | 
					        return super().list(request, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _create_recovery_link(self, expires: datetime) -> tuple[str, Token]:
 | 
					    def _create_recovery_link(self) -> tuple[str, Token]:
 | 
				
			||||||
        """Create a recovery link (when the current brand has a recovery flow set),
 | 
					        """Create a recovery link (when the current brand has a recovery flow set),
 | 
				
			||||||
        that can either be shown to an admin or sent to the user directly"""
 | 
					        that can either be shown to an admin or sent to the user directly"""
 | 
				
			||||||
        brand: Brand = self.request._request.brand
 | 
					        brand: Brand = self.request._request.brand
 | 
				
			||||||
        # Check that there is a recovery flow, if not return an error
 | 
					        # Check that there is a recovery flow, if not return an error
 | 
				
			||||||
        flow = brand.flow_recovery
 | 
					        flow = brand.flow_recovery
 | 
				
			||||||
        if not flow:
 | 
					        if not flow:
 | 
				
			||||||
            raise ValidationError(
 | 
					            raise ValidationError({"non_field_errors": "No recovery flow set."})
 | 
				
			||||||
                {"non_field_errors": [_("Recovery flow is not set for this brand.")]}
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        # Mimic an unauthenticated user navigating the recovery flow
 | 
					 | 
				
			||||||
        user: User = self.get_object()
 | 
					        user: User = self.get_object()
 | 
				
			||||||
        self.request._request.user = AnonymousUser()
 | 
					 | 
				
			||||||
        planner = FlowPlanner(flow)
 | 
					        planner = FlowPlanner(flow)
 | 
				
			||||||
        planner.allow_empty_flows = True
 | 
					        planner.allow_empty_flows = True
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
@ -472,16 +464,16 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
        except FlowNonApplicableException:
 | 
					        except FlowNonApplicableException:
 | 
				
			||||||
            raise ValidationError(
 | 
					            raise ValidationError(
 | 
				
			||||||
                {"non_field_errors": [_("Recovery flow is not applicable to this user.")]}
 | 
					                {"non_field_errors": "Recovery flow not applicable to user"}
 | 
				
			||||||
            ) from None
 | 
					            ) from None
 | 
				
			||||||
        token = FlowToken.objects.create(
 | 
					        token, __ = FlowToken.objects.update_or_create(
 | 
				
			||||||
            identifier=f"{user.uid}-password-reset-{sha256(str(datetime.now()).encode('UTF-8')).hexdigest()[:8]}",
 | 
					            identifier=f"{user.uid}-password-reset",
 | 
				
			||||||
            user=user,
 | 
					            defaults={
 | 
				
			||||||
            flow=flow,
 | 
					                "user": user,
 | 
				
			||||||
            _plan=FlowToken.pickle(plan),
 | 
					                "flow": flow,
 | 
				
			||||||
            expires=expires,
 | 
					                "_plan": FlowToken.pickle(plan),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					 | 
				
			||||||
        querystring = urlencode({QS_KEY_TOKEN: token.key})
 | 
					        querystring = urlencode({QS_KEY_TOKEN: token.key})
 | 
				
			||||||
        link = self.request.build_absolute_uri(
 | 
					        link = self.request.build_absolute_uri(
 | 
				
			||||||
            reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
 | 
					            reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
 | 
				
			||||||
@ -593,7 +585,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
        """Set password for user"""
 | 
					        """Set password for user"""
 | 
				
			||||||
        user: User = self.get_object()
 | 
					        user: User = self.get_object()
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            user.set_password(request.data.get("password"), request=request)
 | 
					            user.set_password(request.data.get("password"))
 | 
				
			||||||
            user.save()
 | 
					            user.save()
 | 
				
			||||||
        except (ValidationError, IntegrityError) as exc:
 | 
					        except (ValidationError, IntegrityError) as exc:
 | 
				
			||||||
            LOGGER.debug("Failed to set password", exc=exc)
 | 
					            LOGGER.debug("Failed to set password", exc=exc)
 | 
				
			||||||
@ -616,69 +608,62 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_core.reset_user_password")
 | 
					    @permission_required("authentik_core.reset_user_password")
 | 
				
			||||||
    @extend_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        parameters=[
 | 
					 | 
				
			||||||
            OpenApiParameter(
 | 
					 | 
				
			||||||
                name="email_stage",
 | 
					 | 
				
			||||||
                location=OpenApiParameter.QUERY,
 | 
					 | 
				
			||||||
                type=OpenApiTypes.STR,
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
            OpenApiParameter(
 | 
					 | 
				
			||||||
                name="token_duration",
 | 
					 | 
				
			||||||
                location=OpenApiParameter.QUERY,
 | 
					 | 
				
			||||||
                type=OpenApiTypes.STR,
 | 
					 | 
				
			||||||
                required=True,
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ],
 | 
					 | 
				
			||||||
        responses={
 | 
					        responses={
 | 
				
			||||||
            "200": LinkSerializer(many=False),
 | 
					            "200": LinkSerializer(many=False),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        request=None,
 | 
					        request=None,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
				
			||||||
    def recovery_link(self, request: Request, pk: int) -> Response:
 | 
					    def recovery(self, request: Request, pk: int) -> Response:
 | 
				
			||||||
        """Create a temporary link that a user can use to recover their accounts"""
 | 
					        """Create a temporary link that a user can use to recover their accounts"""
 | 
				
			||||||
        token_duration = request.query_params.get("token_duration", "")
 | 
					        link, _ = self._create_recovery_link()
 | 
				
			||||||
        timedelta_string_validator(token_duration)
 | 
					 | 
				
			||||||
        expires = now() + timedelta_from_string(token_duration)
 | 
					 | 
				
			||||||
        link, token = self._create_recovery_link(expires)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if email_stage := request.query_params.get("email_stage"):
 | 
					 | 
				
			||||||
            for_user: User = self.get_object()
 | 
					 | 
				
			||||||
            if for_user.email == "":
 | 
					 | 
				
			||||||
                LOGGER.debug("User doesn't have an email address")
 | 
					 | 
				
			||||||
                raise ValidationError(
 | 
					 | 
				
			||||||
                    {"non_field_errors": [_("User does not have an email address set.")]}
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            # Lookup the email stage to assure the current user can access it
 | 
					 | 
				
			||||||
            stages = get_objects_for_user(
 | 
					 | 
				
			||||||
                request.user, "authentik_stages_email.view_emailstage"
 | 
					 | 
				
			||||||
            ).filter(pk=email_stage)
 | 
					 | 
				
			||||||
            if not stages.exists():
 | 
					 | 
				
			||||||
                if stages := EmailStage.objects.filter(pk=email_stage).exists():
 | 
					 | 
				
			||||||
                    raise ValidationError(
 | 
					 | 
				
			||||||
                        {"non_field_errors": [_("User has no permissions to this Email stage.")]}
 | 
					 | 
				
			||||||
                    )
 | 
					 | 
				
			||||||
                else:
 | 
					 | 
				
			||||||
                    raise ValidationError(
 | 
					 | 
				
			||||||
                        {"non_field_errors": [_("The given Email stage does not exist.")]}
 | 
					 | 
				
			||||||
                    )
 | 
					 | 
				
			||||||
            email_stage: EmailStage = stages.first()
 | 
					 | 
				
			||||||
            message = TemplateEmailMessage(
 | 
					 | 
				
			||||||
                subject=_(email_stage.subject),
 | 
					 | 
				
			||||||
                to=[(for_user.name, for_user.email)],
 | 
					 | 
				
			||||||
                template_name=email_stage.template,
 | 
					 | 
				
			||||||
                language=for_user.locale(request),
 | 
					 | 
				
			||||||
                template_context={
 | 
					 | 
				
			||||||
                    "url": link,
 | 
					 | 
				
			||||||
                    "user": for_user,
 | 
					 | 
				
			||||||
                    "expires": token.expires,
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            send_mails(email_stage, message)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Response({"link": link})
 | 
					        return Response({"link": link})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @permission_required("authentik_core.reset_user_password")
 | 
				
			||||||
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        parameters=[
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
 | 
					                name="email_stage",
 | 
				
			||||||
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
 | 
					                type=OpenApiTypes.STR,
 | 
				
			||||||
 | 
					                required=True,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        responses={
 | 
				
			||||||
 | 
					            "204": OpenApiResponse(description="Successfully sent recover email"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        request=None,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
				
			||||||
 | 
					    def recovery_email(self, request: Request, pk: int) -> Response:
 | 
				
			||||||
 | 
					        """Create a temporary link that a user can use to recover their accounts"""
 | 
				
			||||||
 | 
					        for_user: User = self.get_object()
 | 
				
			||||||
 | 
					        if for_user.email == "":
 | 
				
			||||||
 | 
					            LOGGER.debug("User doesn't have an email address")
 | 
				
			||||||
 | 
					            raise ValidationError({"non_field_errors": "User does not have an email address set."})
 | 
				
			||||||
 | 
					        link, token = self._create_recovery_link()
 | 
				
			||||||
 | 
					        # Lookup the email stage to assure the current user can access it
 | 
				
			||||||
 | 
					        stages = get_objects_for_user(
 | 
				
			||||||
 | 
					            request.user, "authentik_stages_email.view_emailstage"
 | 
				
			||||||
 | 
					        ).filter(pk=request.query_params.get("email_stage"))
 | 
				
			||||||
 | 
					        if not stages.exists():
 | 
				
			||||||
 | 
					            LOGGER.debug("Email stage does not exist/user has no permissions")
 | 
				
			||||||
 | 
					            raise ValidationError({"non_field_errors": "Email stage does not exist."})
 | 
				
			||||||
 | 
					        email_stage: EmailStage = stages.first()
 | 
				
			||||||
 | 
					        message = TemplateEmailMessage(
 | 
				
			||||||
 | 
					            subject=_(email_stage.subject),
 | 
				
			||||||
 | 
					            to=[(for_user.name, for_user.email)],
 | 
				
			||||||
 | 
					            template_name=email_stage.template,
 | 
				
			||||||
 | 
					            language=for_user.locale(request),
 | 
				
			||||||
 | 
					            template_context={
 | 
				
			||||||
 | 
					                "url": link,
 | 
				
			||||||
 | 
					                "user": for_user,
 | 
				
			||||||
 | 
					                "expires": token.expires,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        send_mails(email_stage, message)
 | 
				
			||||||
 | 
					        return Response(status=204)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_core.impersonate")
 | 
					    @permission_required("authentik_core.impersonate")
 | 
				
			||||||
    @extend_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        request=inline_serializer(
 | 
					        request=inline_serializer(
 | 
				
			||||||
 | 
				
			|||||||
@ -44,12 +44,13 @@ class TokenBackend(InbuiltBackend):
 | 
				
			|||||||
        self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
 | 
					        self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
 | 
				
			||||||
    ) -> User | None:
 | 
					    ) -> User | None:
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            user = User._default_manager.get_by_natural_key(username)
 | 
					            user = User._default_manager.get_by_natural_key(username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        except User.DoesNotExist:
 | 
					        except User.DoesNotExist:
 | 
				
			||||||
            # Run the default password hasher once to reduce the timing
 | 
					            # Run the default password hasher once to reduce the timing
 | 
				
			||||||
            # difference between an existing and a nonexistent user (#20760).
 | 
					            # difference between an existing and a nonexistent user (#20760).
 | 
				
			||||||
            User().set_password(password, request=request)
 | 
					            User().set_password(password)
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        tokens = Token.filter_not_expired(
 | 
					        tokens = Token.filter_not_expired(
 | 
				
			||||||
 | 
				
			|||||||
@ -58,7 +58,6 @@ class PropertyMappingEvaluator(BaseEvaluator):
 | 
				
			|||||||
            self._context["user"] = user
 | 
					            self._context["user"] = user
 | 
				
			||||||
        if request:
 | 
					        if request:
 | 
				
			||||||
            req.http_request = request
 | 
					            req.http_request = request
 | 
				
			||||||
            self._context["http_request"] = request
 | 
					 | 
				
			||||||
        req.context.update(**kwargs)
 | 
					        req.context.update(**kwargs)
 | 
				
			||||||
        self._context["request"] = req
 | 
					        self._context["request"] = req
 | 
				
			||||||
        self._context.update(**kwargs)
 | 
					        self._context.update(**kwargs)
 | 
				
			||||||
 | 
				
			|||||||
@ -5,7 +5,6 @@ from typing import TextIO
 | 
				
			|||||||
from daphne.management.commands.runserver import Command as RunServer
 | 
					from daphne.management.commands.runserver import Command as RunServer
 | 
				
			||||||
from daphne.server import Server
 | 
					from daphne.server import Server
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.lib.debug import start_debug_server
 | 
					 | 
				
			||||||
from authentik.root.signals import post_startup, pre_startup, startup
 | 
					from authentik.root.signals import post_startup, pre_startup, startup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -14,7 +13,6 @@ class SignalServer(Server):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __init__(self, *args, **kwargs):
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
        super().__init__(*args, **kwargs)
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
        start_debug_server()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        def ready_callable():
 | 
					        def ready_callable():
 | 
				
			||||||
            pre_startup.send(sender=self)
 | 
					            pre_startup.send(sender=self)
 | 
				
			||||||
 | 
				
			|||||||
@ -17,9 +17,7 @@ from authentik.events.middleware import should_log_model
 | 
				
			|||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
from authentik.events.utils import model_to_dict
 | 
					from authentik.events.utils import model_to_dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					BANNER_TEXT = f"""### authentik shell ({get_full_version()})
 | 
				
			||||||
def get_banner_text(shell_type="shell") -> str:
 | 
					 | 
				
			||||||
    return f"""### authentik {shell_type} ({get_full_version()})
 | 
					 | 
				
			||||||
### Node {platform.node()} | Arch {platform.machine()} | Python {platform.python_version()} """
 | 
					### Node {platform.node()} | Arch {platform.machine()} | Python {platform.python_version()} """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -116,4 +114,4 @@ class Command(BaseCommand):
 | 
				
			|||||||
            readline.parse_and_bind("tab: complete")
 | 
					            readline.parse_and_bind("tab: complete")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Run interactive shell
 | 
					        # Run interactive shell
 | 
				
			||||||
        code.interact(banner=get_banner_text(), local=namespace)
 | 
					        code.interact(banner=BANNER_TEXT, local=namespace)
 | 
				
			||||||
 | 
				
			|||||||
@ -9,7 +9,6 @@ from django.db import close_old_connections
 | 
				
			|||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
from authentik.lib.debug import start_debug_server
 | 
					 | 
				
			||||||
from authentik.root.celery import CELERY_APP
 | 
					from authentik.root.celery import CELERY_APP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
@ -29,7 +28,10 @@ class Command(BaseCommand):
 | 
				
			|||||||
    def handle(self, **options):
 | 
					    def handle(self, **options):
 | 
				
			||||||
        LOGGER.debug("Celery options", **options)
 | 
					        LOGGER.debug("Celery options", **options)
 | 
				
			||||||
        close_old_connections()
 | 
					        close_old_connections()
 | 
				
			||||||
        start_debug_server()
 | 
					        if CONFIG.get_bool("remote_debug"):
 | 
				
			||||||
 | 
					            import debugpy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            debugpy.listen(("0.0.0.0", 6900))  # nosec
 | 
				
			||||||
        worker: Worker = CELERY_APP.Worker(
 | 
					        worker: Worker = CELERY_APP.Worker(
 | 
				
			||||||
            no_color=False,
 | 
					            no_color=False,
 | 
				
			||||||
            quiet=True,
 | 
					            quiet=True,
 | 
				
			||||||
 | 
				
			|||||||
@ -1,45 +0,0 @@
 | 
				
			|||||||
# Generated by Django 5.0.9 on 2024-11-20 15:16
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import django.db.models.deletion
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    dependencies = [
 | 
					 | 
				
			||||||
        ("authentik_core", "0040_provider_invalidation_flow"),
 | 
					 | 
				
			||||||
        ("authentik_policies", "0011_policybinding_failure_result_and_more"),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.CreateModel(
 | 
					 | 
				
			||||||
            name="ApplicationEntitlement",
 | 
					 | 
				
			||||||
            fields=[
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "policybindingmodel_ptr",
 | 
					 | 
				
			||||||
                    models.OneToOneField(
 | 
					 | 
				
			||||||
                        auto_created=True,
 | 
					 | 
				
			||||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
					 | 
				
			||||||
                        parent_link=True,
 | 
					 | 
				
			||||||
                        primary_key=True,
 | 
					 | 
				
			||||||
                        serialize=False,
 | 
					 | 
				
			||||||
                        to="authentik_policies.policybindingmodel",
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                ("attributes", models.JSONField(blank=True, default=dict)),
 | 
					 | 
				
			||||||
                ("name", models.TextField()),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "app",
 | 
					 | 
				
			||||||
                    models.ForeignKey(
 | 
					 | 
				
			||||||
                        on_delete=django.db.models.deletion.CASCADE, to="authentik_core.application"
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            options={
 | 
					 | 
				
			||||||
                "verbose_name": "Application Entitlement",
 | 
					 | 
				
			||||||
                "verbose_name_plural": "Application Entitlements",
 | 
					 | 
				
			||||||
                "unique_together": {("app", "name")},
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            bases=("authentik_policies.policybindingmodel", models.Model),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@ -1,45 +0,0 @@
 | 
				
			|||||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    dependencies = [
 | 
					 | 
				
			||||||
        ("authentik_core", "0041_applicationentitlement"),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="authenticatedsession",
 | 
					 | 
				
			||||||
            index=models.Index(fields=["expires"], name="authentik_c_expires_08251d_idx"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="authenticatedsession",
 | 
					 | 
				
			||||||
            index=models.Index(fields=["expiring"], name="authentik_c_expirin_9cd839_idx"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="authenticatedsession",
 | 
					 | 
				
			||||||
            index=models.Index(
 | 
					 | 
				
			||||||
                fields=["expiring", "expires"], name="authentik_c_expirin_195a84_idx"
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="authenticatedsession",
 | 
					 | 
				
			||||||
            index=models.Index(fields=["session_key"], name="authentik_c_session_d0f005_idx"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="token",
 | 
					 | 
				
			||||||
            index=models.Index(fields=["expires"], name="authentik_c_expires_a62b4b_idx"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="token",
 | 
					 | 
				
			||||||
            index=models.Index(fields=["expiring"], name="authentik_c_expirin_a1b838_idx"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="token",
 | 
					 | 
				
			||||||
            index=models.Index(
 | 
					 | 
				
			||||||
                fields=["expiring", "expires"], name="authentik_c_expirin_ba04d9_idx"
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@ -1,26 +0,0 @@
 | 
				
			|||||||
# Generated by Django 5.0.11 on 2025-01-30 23:55
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.db import migrations
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    dependencies = [
 | 
					 | 
				
			||||||
        ("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.AlterModelOptions(
 | 
					 | 
				
			||||||
            name="group",
 | 
					 | 
				
			||||||
            options={
 | 
					 | 
				
			||||||
                "permissions": [
 | 
					 | 
				
			||||||
                    ("add_user_to_group", "Add user to group"),
 | 
					 | 
				
			||||||
                    ("remove_user_from_group", "Remove user from group"),
 | 
					 | 
				
			||||||
                    ("enable_group_superuser", "Enable superuser status"),
 | 
					 | 
				
			||||||
                    ("disable_group_superuser", "Disable superuser status"),
 | 
					 | 
				
			||||||
                ],
 | 
					 | 
				
			||||||
                "verbose_name": "Group",
 | 
					 | 
				
			||||||
                "verbose_name_plural": "Groups",
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@ -204,8 +204,6 @@ class Group(SerializerModel, AttributesMixin):
 | 
				
			|||||||
        permissions = [
 | 
					        permissions = [
 | 
				
			||||||
            ("add_user_to_group", _("Add user to group")),
 | 
					            ("add_user_to_group", _("Add user to group")),
 | 
				
			||||||
            ("remove_user_from_group", _("Remove user from group")),
 | 
					            ("remove_user_from_group", _("Remove user from group")),
 | 
				
			||||||
            ("enable_group_superuser", _("Enable superuser status")),
 | 
					 | 
				
			||||||
            ("disable_group_superuser", _("Disable superuser status")),
 | 
					 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
@ -316,32 +314,6 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
 | 
				
			|||||||
        always_merger.merge(final_attributes, self.attributes)
 | 
					        always_merger.merge(final_attributes, self.attributes)
 | 
				
			||||||
        return final_attributes
 | 
					        return final_attributes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def app_entitlements(self, app: "Application | None") -> QuerySet["ApplicationEntitlement"]:
 | 
					 | 
				
			||||||
        """Get all entitlements this user has for `app`."""
 | 
					 | 
				
			||||||
        if not app:
 | 
					 | 
				
			||||||
            return []
 | 
					 | 
				
			||||||
        all_groups = self.all_groups()
 | 
					 | 
				
			||||||
        qs = app.applicationentitlement_set.filter(
 | 
					 | 
				
			||||||
            Q(
 | 
					 | 
				
			||||||
                Q(bindings__user=self) | Q(bindings__group__in=all_groups),
 | 
					 | 
				
			||||||
                bindings__negate=False,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            | Q(
 | 
					 | 
				
			||||||
                Q(~Q(bindings__user=self), bindings__user__isnull=False)
 | 
					 | 
				
			||||||
                | Q(~Q(bindings__group__in=all_groups), bindings__group__isnull=False),
 | 
					 | 
				
			||||||
                bindings__negate=True,
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
            bindings__enabled=True,
 | 
					 | 
				
			||||||
        ).order_by("name")
 | 
					 | 
				
			||||||
        return qs
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def app_entitlements_attributes(self, app: "Application | None") -> dict:
 | 
					 | 
				
			||||||
        """Get a dictionary containing all merged attributes from app entitlements for `app`."""
 | 
					 | 
				
			||||||
        final_attributes = {}
 | 
					 | 
				
			||||||
        for attrs in self.app_entitlements(app).values_list("attributes", flat=True):
 | 
					 | 
				
			||||||
            always_merger.merge(final_attributes, attrs)
 | 
					 | 
				
			||||||
        return final_attributes
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def serializer(self) -> Serializer:
 | 
					    def serializer(self) -> Serializer:
 | 
				
			||||||
        from authentik.core.api.users import UserSerializer
 | 
					        from authentik.core.api.users import UserSerializer
 | 
				
			||||||
@ -358,13 +330,13 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
 | 
				
			|||||||
        """superuser == staff user"""
 | 
					        """superuser == staff user"""
 | 
				
			||||||
        return self.is_superuser  # type: ignore
 | 
					        return self.is_superuser  # type: ignore
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def set_password(self, raw_password, signal=True, sender=None, request=None):
 | 
					    def set_password(self, raw_password, signal=True, sender=None):
 | 
				
			||||||
        if self.pk and signal:
 | 
					        if self.pk and signal:
 | 
				
			||||||
            from authentik.core.signals import password_changed
 | 
					            from authentik.core.signals import password_changed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if not sender:
 | 
					            if not sender:
 | 
				
			||||||
                sender = self
 | 
					                sender = self
 | 
				
			||||||
            password_changed.send(sender=sender, user=self, password=raw_password, request=request)
 | 
					            password_changed.send(sender=sender, user=self, password=raw_password)
 | 
				
			||||||
        self.password_change_date = now()
 | 
					        self.password_change_date = now()
 | 
				
			||||||
        return super().set_password(raw_password)
 | 
					        return super().set_password(raw_password)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -601,14 +573,6 @@ class Application(SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
            return None
 | 
					            return None
 | 
				
			||||||
        return candidates[-1]
 | 
					        return candidates[-1]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None:
 | 
					 | 
				
			||||||
        """Get Backchannel provider for a specific type"""
 | 
					 | 
				
			||||||
        providers = self.backchannel_providers.filter(
 | 
					 | 
				
			||||||
            **{f"{provider_type._meta.model_name}__isnull": False},
 | 
					 | 
				
			||||||
            **kwargs,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        return getattr(providers.first(), provider_type._meta.model_name)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return str(self.name)
 | 
					        return str(self.name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -617,31 +581,6 @@ class Application(SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
        verbose_name_plural = _("Applications")
 | 
					        verbose_name_plural = _("Applications")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ApplicationEntitlement(AttributesMixin, SerializerModel, PolicyBindingModel):
 | 
					 | 
				
			||||||
    """Application-scoped entitlement to control authorization in an application"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    name = models.TextField()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    app = models.ForeignKey(Application, on_delete=models.CASCADE)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        verbose_name = _("Application Entitlement")
 | 
					 | 
				
			||||||
        verbose_name_plural = _("Application Entitlements")
 | 
					 | 
				
			||||||
        unique_together = (("app", "name"),)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __str__(self):
 | 
					 | 
				
			||||||
        return f"Application Entitlement {self.name} for app {self.app_id}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def serializer(self) -> type[Serializer]:
 | 
					 | 
				
			||||||
        from authentik.core.api.application_entitlements import ApplicationEntitlementSerializer
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return ApplicationEntitlementSerializer
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def supported_policy_binding_targets(self):
 | 
					 | 
				
			||||||
        return ["group", "user"]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SourceUserMatchingModes(models.TextChoices):
 | 
					class SourceUserMatchingModes(models.TextChoices):
 | 
				
			||||||
    """Different modes a source can handle new/returning users"""
 | 
					    """Different modes a source can handle new/returning users"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -856,11 +795,6 @@ class ExpiringModel(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        abstract = True
 | 
					        abstract = True
 | 
				
			||||||
        indexes = [
 | 
					 | 
				
			||||||
            models.Index(fields=["expires"]),
 | 
					 | 
				
			||||||
            models.Index(fields=["expiring"]),
 | 
					 | 
				
			||||||
            models.Index(fields=["expiring", "expires"]),
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def expire_action(self, *args, **kwargs):
 | 
					    def expire_action(self, *args, **kwargs):
 | 
				
			||||||
        """Handler which is called when this object is expired. By
 | 
					        """Handler which is called when this object is expired. By
 | 
				
			||||||
@ -916,7 +850,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        verbose_name = _("Token")
 | 
					        verbose_name = _("Token")
 | 
				
			||||||
        verbose_name_plural = _("Tokens")
 | 
					        verbose_name_plural = _("Tokens")
 | 
				
			||||||
        indexes = ExpiringModel.Meta.indexes + [
 | 
					        indexes = [
 | 
				
			||||||
            models.Index(fields=["identifier"]),
 | 
					            models.Index(fields=["identifier"]),
 | 
				
			||||||
            models.Index(fields=["key"]),
 | 
					            models.Index(fields=["key"]),
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@ -1016,9 +950,6 @@ class AuthenticatedSession(ExpiringModel):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        verbose_name = _("Authenticated Session")
 | 
					        verbose_name = _("Authenticated Session")
 | 
				
			||||||
        verbose_name_plural = _("Authenticated Sessions")
 | 
					        verbose_name_plural = _("Authenticated Sessions")
 | 
				
			||||||
        indexes = ExpiringModel.Meta.indexes + [
 | 
					 | 
				
			||||||
            models.Index(fields=["session_key"]),
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self) -> str:
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
        return f"Authenticated Session {self.session_key[:10]}"
 | 
					        return f"Authenticated Session {self.session_key[:10]}"
 | 
				
			||||||
 | 
				
			|||||||
@ -238,7 +238,13 @@ class SourceFlowManager:
 | 
				
			|||||||
                self.request.GET,
 | 
					                self.request.GET,
 | 
				
			||||||
                flow_slug=flow_slug,
 | 
					                flow_slug=flow_slug,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect)
 | 
					        # Ensure redirect is carried through when user was trying to
 | 
				
			||||||
 | 
					        # authorize application
 | 
				
			||||||
 | 
					        final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
 | 
				
			||||||
 | 
					            NEXT_ARG_NAME, "authentik_core:if-user"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if PLAN_CONTEXT_REDIRECT not in flow_context:
 | 
				
			||||||
 | 
					            flow_context[PLAN_CONTEXT_REDIRECT] = final_redirect
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if not flow:
 | 
					        if not flow:
 | 
				
			||||||
            return bad_request_message(
 | 
					            return bad_request_message(
 | 
				
			||||||
 | 
				
			|||||||
@ -67,8 +67,6 @@ def clean_expired_models(self: SystemTask):
 | 
				
			|||||||
                raise ImproperlyConfigured(
 | 
					                raise ImproperlyConfigured(
 | 
				
			||||||
                    "Invalid session_storage setting, allowed values are db and cache"
 | 
					                    "Invalid session_storage setting, allowed values are db and cache"
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
    if CONFIG.get("session_storage", "cache") == "db":
 | 
					 | 
				
			||||||
        DBSessionStore.clear_expired()
 | 
					 | 
				
			||||||
    LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
 | 
					    LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
 | 
					    messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
 | 
				
			||||||
 | 
				
			|||||||
@ -1,153 +0,0 @@
 | 
				
			|||||||
"""Test Application Entitlements API"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.urls import reverse
 | 
					 | 
				
			||||||
from guardian.shortcuts import assign_perm
 | 
					 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.core.models import Application, ApplicationEntitlement, Group
 | 
					 | 
				
			||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user
 | 
					 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					 | 
				
			||||||
from authentik.policies.dummy.models import DummyPolicy
 | 
					 | 
				
			||||||
from authentik.policies.models import PolicyBinding
 | 
					 | 
				
			||||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TestApplicationEntitlements(APITestCase):
 | 
					 | 
				
			||||||
    """Test application entitlements"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def setUp(self) -> None:
 | 
					 | 
				
			||||||
        self.user = create_test_user()
 | 
					 | 
				
			||||||
        self.other_user = create_test_user()
 | 
					 | 
				
			||||||
        self.provider = OAuth2Provider.objects.create(
 | 
					 | 
				
			||||||
            name="test",
 | 
					 | 
				
			||||||
            authorization_flow=create_test_flow(),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.app: Application = Application.objects.create(
 | 
					 | 
				
			||||||
            name=generate_id(),
 | 
					 | 
				
			||||||
            slug=generate_id(),
 | 
					 | 
				
			||||||
            provider=self.provider,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_user(self):
 | 
					 | 
				
			||||||
        """Test user-direct assignment"""
 | 
					 | 
				
			||||||
        ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
 | 
					 | 
				
			||||||
        PolicyBinding.objects.create(target=ent, user=self.user, order=0)
 | 
					 | 
				
			||||||
        ents = self.user.app_entitlements(self.app)
 | 
					 | 
				
			||||||
        self.assertEqual(len(ents), 1)
 | 
					 | 
				
			||||||
        self.assertEqual(ents[0].name, ent.name)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_group(self):
 | 
					 | 
				
			||||||
        """Test direct group"""
 | 
					 | 
				
			||||||
        group = Group.objects.create(name=generate_id())
 | 
					 | 
				
			||||||
        self.user.ak_groups.add(group)
 | 
					 | 
				
			||||||
        ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
 | 
					 | 
				
			||||||
        PolicyBinding.objects.create(target=ent, group=group, order=0)
 | 
					 | 
				
			||||||
        ents = self.user.app_entitlements(self.app)
 | 
					 | 
				
			||||||
        self.assertEqual(len(ents), 1)
 | 
					 | 
				
			||||||
        self.assertEqual(ents[0].name, ent.name)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_group_indirect(self):
 | 
					 | 
				
			||||||
        """Test indirect group"""
 | 
					 | 
				
			||||||
        parent = Group.objects.create(name=generate_id())
 | 
					 | 
				
			||||||
        group = Group.objects.create(name=generate_id(), parent=parent)
 | 
					 | 
				
			||||||
        self.user.ak_groups.add(group)
 | 
					 | 
				
			||||||
        ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
 | 
					 | 
				
			||||||
        PolicyBinding.objects.create(target=ent, group=parent, order=0)
 | 
					 | 
				
			||||||
        ents = self.user.app_entitlements(self.app)
 | 
					 | 
				
			||||||
        self.assertEqual(len(ents), 1)
 | 
					 | 
				
			||||||
        self.assertEqual(ents[0].name, ent.name)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_negate_user(self):
 | 
					 | 
				
			||||||
        """Test with negate flag"""
 | 
					 | 
				
			||||||
        ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
 | 
					 | 
				
			||||||
        PolicyBinding.objects.create(target=ent, user=self.other_user, order=0, negate=True)
 | 
					 | 
				
			||||||
        ents = self.user.app_entitlements(self.app)
 | 
					 | 
				
			||||||
        self.assertEqual(len(ents), 1)
 | 
					 | 
				
			||||||
        self.assertEqual(ents[0].name, ent.name)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_negate_group(self):
 | 
					 | 
				
			||||||
        """Test with negate flag"""
 | 
					 | 
				
			||||||
        other_group = Group.objects.create(name=generate_id())
 | 
					 | 
				
			||||||
        ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
 | 
					 | 
				
			||||||
        PolicyBinding.objects.create(target=ent, group=other_group, order=0, negate=True)
 | 
					 | 
				
			||||||
        ents = self.user.app_entitlements(self.app)
 | 
					 | 
				
			||||||
        self.assertEqual(len(ents), 1)
 | 
					 | 
				
			||||||
        self.assertEqual(ents[0].name, ent.name)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_api_perms_global(self):
 | 
					 | 
				
			||||||
        """Test API creation with global permissions"""
 | 
					 | 
				
			||||||
        assign_perm("authentik_core.add_applicationentitlement", self.user)
 | 
					 | 
				
			||||||
        assign_perm("authentik_core.view_application", self.user)
 | 
					 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					 | 
				
			||||||
        res = self.client.post(
 | 
					 | 
				
			||||||
            reverse("authentik_api:applicationentitlement-list"),
 | 
					 | 
				
			||||||
            data={
 | 
					 | 
				
			||||||
                "name": generate_id(),
 | 
					 | 
				
			||||||
                "app": self.app.pk,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertEqual(res.status_code, 201)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_api_perms_scoped(self):
 | 
					 | 
				
			||||||
        """Test API creation with scoped permissions"""
 | 
					 | 
				
			||||||
        assign_perm("authentik_core.add_applicationentitlement", self.user)
 | 
					 | 
				
			||||||
        assign_perm("authentik_core.view_application", self.user, self.app)
 | 
					 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					 | 
				
			||||||
        res = self.client.post(
 | 
					 | 
				
			||||||
            reverse("authentik_api:applicationentitlement-list"),
 | 
					 | 
				
			||||||
            data={
 | 
					 | 
				
			||||||
                "name": generate_id(),
 | 
					 | 
				
			||||||
                "app": self.app.pk,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertEqual(res.status_code, 201)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_api_perms_missing(self):
 | 
					 | 
				
			||||||
        """Test API creation with no permissions"""
 | 
					 | 
				
			||||||
        assign_perm("authentik_core.add_applicationentitlement", self.user)
 | 
					 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					 | 
				
			||||||
        res = self.client.post(
 | 
					 | 
				
			||||||
            reverse("authentik_api:applicationentitlement-list"),
 | 
					 | 
				
			||||||
            data={
 | 
					 | 
				
			||||||
                "name": generate_id(),
 | 
					 | 
				
			||||||
                "app": self.app.pk,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertEqual(res.status_code, 400)
 | 
					 | 
				
			||||||
        self.assertJSONEqual(res.content, {"app": ["User does not have access to application."]})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_api_bindings_policy(self):
 | 
					 | 
				
			||||||
        """Test that API doesn't allow policies to be bound to this"""
 | 
					 | 
				
			||||||
        ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
 | 
					 | 
				
			||||||
        policy = DummyPolicy.objects.create(name=generate_id())
 | 
					 | 
				
			||||||
        admin = create_test_admin_user()
 | 
					 | 
				
			||||||
        self.client.force_login(admin)
 | 
					 | 
				
			||||||
        response = self.client.post(
 | 
					 | 
				
			||||||
            reverse("authentik_api:policybinding-list"),
 | 
					 | 
				
			||||||
            data={
 | 
					 | 
				
			||||||
                "target": ent.pbm_uuid,
 | 
					 | 
				
			||||||
                "policy": policy.pk,
 | 
					 | 
				
			||||||
                "order": 0,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertJSONEqual(
 | 
					 | 
				
			||||||
            response.content.decode(),
 | 
					 | 
				
			||||||
            {"non_field_errors": ["One of 'group', 'user' must be set."]},
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_api_bindings_group(self):
 | 
					 | 
				
			||||||
        """Test that API doesn't allow policies to be bound to this"""
 | 
					 | 
				
			||||||
        ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
 | 
					 | 
				
			||||||
        group = Group.objects.create(name=generate_id())
 | 
					 | 
				
			||||||
        admin = create_test_admin_user()
 | 
					 | 
				
			||||||
        self.client.force_login(admin)
 | 
					 | 
				
			||||||
        response = self.client.post(
 | 
					 | 
				
			||||||
            reverse("authentik_api:policybinding-list"),
 | 
					 | 
				
			||||||
            data={
 | 
					 | 
				
			||||||
                "target": ent.pbm_uuid,
 | 
					 | 
				
			||||||
                "group": group.pk,
 | 
					 | 
				
			||||||
                "order": 0,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertEqual(response.status_code, 201)
 | 
					 | 
				
			||||||
        self.assertTrue(PolicyBinding.objects.filter(target=ent.pbm_uuid).exists())
 | 
					 | 
				
			||||||
@ -4,7 +4,7 @@ from django.urls.base import reverse
 | 
				
			|||||||
from guardian.shortcuts import assign_perm
 | 
					from guardian.shortcuts import assign_perm
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Group
 | 
					from authentik.core.models import Group, User
 | 
				
			||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
 | 
					from authentik.core.tests.utils import create_test_admin_user, create_test_user
 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -14,7 +14,7 @@ class TestGroupsAPI(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        self.login_user = create_test_user()
 | 
					        self.login_user = create_test_user()
 | 
				
			||||||
        self.user = create_test_user()
 | 
					        self.user = User.objects.create(username="test-user")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_list_with_users(self):
 | 
					    def test_list_with_users(self):
 | 
				
			||||||
        """Test listing with users"""
 | 
					        """Test listing with users"""
 | 
				
			||||||
@ -109,57 +109,3 @@ class TestGroupsAPI(APITestCase):
 | 
				
			|||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.assertEqual(res.status_code, 400)
 | 
					        self.assertEqual(res.status_code, 400)
 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_superuser_no_perm(self):
 | 
					 | 
				
			||||||
        """Test creating a superuser group without permission"""
 | 
					 | 
				
			||||||
        assign_perm("authentik_core.add_group", self.login_user)
 | 
					 | 
				
			||||||
        self.client.force_login(self.login_user)
 | 
					 | 
				
			||||||
        res = self.client.post(
 | 
					 | 
				
			||||||
            reverse("authentik_api:group-list"),
 | 
					 | 
				
			||||||
            data={"name": generate_id(), "is_superuser": True},
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertEqual(res.status_code, 400)
 | 
					 | 
				
			||||||
        self.assertJSONEqual(
 | 
					 | 
				
			||||||
            res.content,
 | 
					 | 
				
			||||||
            {"is_superuser": ["User does not have permission to set superuser status to True."]},
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_superuser_update_no_perm(self):
 | 
					 | 
				
			||||||
        """Test updating a superuser group without permission"""
 | 
					 | 
				
			||||||
        group = Group.objects.create(name=generate_id(), is_superuser=True)
 | 
					 | 
				
			||||||
        assign_perm("view_group", self.login_user, group)
 | 
					 | 
				
			||||||
        assign_perm("change_group", self.login_user, group)
 | 
					 | 
				
			||||||
        self.client.force_login(self.login_user)
 | 
					 | 
				
			||||||
        res = self.client.patch(
 | 
					 | 
				
			||||||
            reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
 | 
					 | 
				
			||||||
            data={"is_superuser": False},
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertEqual(res.status_code, 400)
 | 
					 | 
				
			||||||
        self.assertJSONEqual(
 | 
					 | 
				
			||||||
            res.content,
 | 
					 | 
				
			||||||
            {"is_superuser": ["User does not have permission to set superuser status to False."]},
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_superuser_update_no_change(self):
 | 
					 | 
				
			||||||
        """Test updating a superuser group without permission
 | 
					 | 
				
			||||||
        and without changing the superuser status"""
 | 
					 | 
				
			||||||
        group = Group.objects.create(name=generate_id(), is_superuser=True)
 | 
					 | 
				
			||||||
        assign_perm("view_group", self.login_user, group)
 | 
					 | 
				
			||||||
        assign_perm("change_group", self.login_user, group)
 | 
					 | 
				
			||||||
        self.client.force_login(self.login_user)
 | 
					 | 
				
			||||||
        res = self.client.patch(
 | 
					 | 
				
			||||||
            reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
 | 
					 | 
				
			||||||
            data={"name": generate_id(), "is_superuser": True},
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertEqual(res.status_code, 200)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_superuser_create(self):
 | 
					 | 
				
			||||||
        """Test creating a superuser group with permission"""
 | 
					 | 
				
			||||||
        assign_perm("authentik_core.add_group", self.login_user)
 | 
					 | 
				
			||||||
        assign_perm("authentik_core.enable_group_superuser", self.login_user)
 | 
					 | 
				
			||||||
        self.client.force_login(self.login_user)
 | 
					 | 
				
			||||||
        res = self.client.post(
 | 
					 | 
				
			||||||
            reverse("authentik_api:group-list"),
 | 
					 | 
				
			||||||
            data={"name": generate_id(), "is_superuser": True},
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertEqual(res.status_code, 201)
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -6,7 +6,6 @@ from django.conf import settings
 | 
				
			|||||||
from django.contrib.auth.decorators import login_required
 | 
					from django.contrib.auth.decorators import login_required
 | 
				
			||||||
from django.urls import path
 | 
					from django.urls import path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet
 | 
					 | 
				
			||||||
from authentik.core.api.applications import ApplicationViewSet
 | 
					from authentik.core.api.applications import ApplicationViewSet
 | 
				
			||||||
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
 | 
					from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
 | 
				
			||||||
from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
 | 
					from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
 | 
				
			||||||
@ -70,7 +69,6 @@ urlpatterns = [
 | 
				
			|||||||
api_urlpatterns = [
 | 
					api_urlpatterns = [
 | 
				
			||||||
    ("core/authenticated_sessions", AuthenticatedSessionViewSet),
 | 
					    ("core/authenticated_sessions", AuthenticatedSessionViewSet),
 | 
				
			||||||
    ("core/applications", ApplicationViewSet),
 | 
					    ("core/applications", ApplicationViewSet),
 | 
				
			||||||
    ("core/application_entitlements", ApplicationEntitlementViewSet),
 | 
					 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
        "core/transactional/applications/",
 | 
					        "core/transactional/applications/",
 | 
				
			||||||
        TransactionalApplicationView.as_view(),
 | 
					        TransactionalApplicationView.as_view(),
 | 
				
			||||||
 | 
				
			|||||||
@ -28,6 +28,7 @@ from rest_framework.validators import UniqueValidator
 | 
				
			|||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.api.authorization import SecretKeyFilter
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
					from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
				
			||||||
from authentik.crypto.apps import MANAGED_KEY
 | 
					from authentik.crypto.apps import MANAGED_KEY
 | 
				
			||||||
@ -35,7 +36,7 @@ from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
 | 
				
			|||||||
from authentik.crypto.models import CertificateKeyPair
 | 
					from authentik.crypto.models import CertificateKeyPair
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
from authentik.rbac.decorators import permission_required
 | 
					from authentik.rbac.decorators import permission_required
 | 
				
			||||||
from authentik.rbac.filters import ObjectFilter, SecretKeyFilter
 | 
					from authentik.rbac.filters import ObjectFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -97,8 +97,6 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
 | 
				
			|||||||
        thread_kwargs: dict | None = None,
 | 
					        thread_kwargs: dict | None = None,
 | 
				
			||||||
        **_,
 | 
					        **_,
 | 
				
			||||||
    ):
 | 
					    ):
 | 
				
			||||||
        if not self.enabled:
 | 
					 | 
				
			||||||
            return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_)
 | 
					 | 
				
			||||||
        if not should_log_model(instance):
 | 
					        if not should_log_model(instance):
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        thread_kwargs = {}
 | 
					        thread_kwargs = {}
 | 
				
			||||||
@ -124,8 +122,6 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
 | 
				
			|||||||
    ):
 | 
					    ):
 | 
				
			||||||
        thread_kwargs = {}
 | 
					        thread_kwargs = {}
 | 
				
			||||||
        m2m_field = None
 | 
					        m2m_field = None
 | 
				
			||||||
        if not self.enabled:
 | 
					 | 
				
			||||||
            return super().m2m_changed_handler(request, sender, instance, action, thread_kwargs)
 | 
					 | 
				
			||||||
        # For the audit log we don't care about `pre_` or `post_` so we trim that part off
 | 
					        # For the audit log we don't care about `pre_` or `post_` so we trim that part off
 | 
				
			||||||
        _, _, action_direction = action.partition("_")
 | 
					        _, _, action_direction = action.partition("_")
 | 
				
			||||||
        # resolve the "through" model to an actual field
 | 
					        # resolve the "through" model to an actual field
 | 
				
			||||||
 | 
				
			|||||||
@ -1,27 +0,0 @@
 | 
				
			|||||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    dependencies = [
 | 
					 | 
				
			||||||
        ("authentik_enterprise", "0003_remove_licenseusage_within_limits_and_more"),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="licenseusage",
 | 
					 | 
				
			||||||
            index=models.Index(fields=["expires"], name="authentik_e_expires_3f2956_idx"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="licenseusage",
 | 
					 | 
				
			||||||
            index=models.Index(fields=["expiring"], name="authentik_e_expirin_11d3d7_idx"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddIndex(
 | 
					 | 
				
			||||||
            model_name="licenseusage",
 | 
					 | 
				
			||||||
            index=models.Index(
 | 
					 | 
				
			||||||
                fields=["expiring", "expires"], name="authentik_e_expirin_4d558f_idx"
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@ -93,4 +93,3 @@ class LicenseUsage(ExpiringModel):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        verbose_name = _("License Usage")
 | 
					        verbose_name = _("License Usage")
 | 
				
			||||||
        verbose_name_plural = _("License Usage Records")
 | 
					        verbose_name_plural = _("License Usage Records")
 | 
				
			||||||
        indexes = ExpiringModel.Meta.indexes
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -1,17 +1,21 @@
 | 
				
			|||||||
"""RAC Provider API Views"""
 | 
					"""RAC Provider API Views"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django_filters.rest_framework.backends import DjangoFilterBackend
 | 
				
			||||||
from rest_framework import mixins
 | 
					from rest_framework import mixins
 | 
				
			||||||
 | 
					from rest_framework.filters import OrderingFilter, SearchFilter
 | 
				
			||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
 | 
				
			||||||
from authentik.core.api.groups import GroupMemberSerializer
 | 
					from authentik.core.api.groups import GroupMemberSerializer
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import ModelSerializer
 | 
					from authentik.core.api.utils import ModelSerializer
 | 
				
			||||||
from authentik.providers.rac.api.endpoints import EndpointSerializer
 | 
					from authentik.enterprise.api import EnterpriseRequiredMixin
 | 
				
			||||||
from authentik.providers.rac.api.providers import RACProviderSerializer
 | 
					from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
 | 
				
			||||||
from authentik.providers.rac.models import ConnectionToken
 | 
					from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
 | 
				
			||||||
 | 
					from authentik.enterprise.providers.rac.models import ConnectionToken
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ConnectionTokenSerializer(ModelSerializer):
 | 
					class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer):
 | 
				
			||||||
    """ConnectionToken Serializer"""
 | 
					    """ConnectionToken Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    provider_obj = RACProviderSerializer(source="provider", read_only=True)
 | 
					    provider_obj = RACProviderSerializer(source="provider", read_only=True)
 | 
				
			||||||
@ -30,6 +34,12 @@ class ConnectionTokenSerializer(ModelSerializer):
 | 
				
			|||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ConnectionTokenOwnerFilter(OwnerFilter):
 | 
				
			||||||
 | 
					    """Owner filter for connection tokens (checks session's user)"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    owner_key = "session__user"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ConnectionTokenViewSet(
 | 
					class ConnectionTokenViewSet(
 | 
				
			||||||
    mixins.RetrieveModelMixin,
 | 
					    mixins.RetrieveModelMixin,
 | 
				
			||||||
    mixins.UpdateModelMixin,
 | 
					    mixins.UpdateModelMixin,
 | 
				
			||||||
@ -45,4 +55,10 @@ class ConnectionTokenViewSet(
 | 
				
			|||||||
    filterset_fields = ["endpoint", "session__user", "provider"]
 | 
					    filterset_fields = ["endpoint", "session__user", "provider"]
 | 
				
			||||||
    search_fields = ["endpoint__name", "provider__name"]
 | 
					    search_fields = ["endpoint__name", "provider__name"]
 | 
				
			||||||
    ordering = ["endpoint__name", "provider__name"]
 | 
					    ordering = ["endpoint__name", "provider__name"]
 | 
				
			||||||
    owner_field = "session__user"
 | 
					    permission_classes = [OwnerSuperuserPermissions]
 | 
				
			||||||
 | 
					    filter_backends = [
 | 
				
			||||||
 | 
					        ConnectionTokenOwnerFilter,
 | 
				
			||||||
 | 
					        DjangoFilterBackend,
 | 
				
			||||||
 | 
					        OrderingFilter,
 | 
				
			||||||
 | 
					        SearchFilter,
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -14,9 +14,10 @@ from structlog.stdlib import get_logger
 | 
				
			|||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import ModelSerializer
 | 
					from authentik.core.api.utils import ModelSerializer
 | 
				
			||||||
from authentik.core.models import Provider
 | 
					from authentik.core.models import Provider
 | 
				
			||||||
 | 
					from authentik.enterprise.api import EnterpriseRequiredMixin
 | 
				
			||||||
 | 
					from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
 | 
				
			||||||
 | 
					from authentik.enterprise.providers.rac.models import Endpoint
 | 
				
			||||||
from authentik.policies.engine import PolicyEngine
 | 
					from authentik.policies.engine import PolicyEngine
 | 
				
			||||||
from authentik.providers.rac.api.providers import RACProviderSerializer
 | 
					 | 
				
			||||||
from authentik.providers.rac.models import Endpoint
 | 
					 | 
				
			||||||
from authentik.rbac.filters import ObjectFilter
 | 
					from authentik.rbac.filters import ObjectFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
@ -27,7 +28,7 @@ def user_endpoint_cache_key(user_pk: str) -> str:
 | 
				
			|||||||
    return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}"
 | 
					    return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EndpointSerializer(ModelSerializer):
 | 
					class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer):
 | 
				
			||||||
    """Endpoint Serializer"""
 | 
					    """Endpoint Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    provider_obj = RACProviderSerializer(source="provider", read_only=True)
 | 
					    provider_obj = RACProviderSerializer(source="provider", read_only=True)
 | 
				
			||||||
@ -10,7 +10,7 @@ from rest_framework.viewsets import ModelViewSet
 | 
				
			|||||||
from authentik.core.api.property_mappings import PropertyMappingSerializer
 | 
					from authentik.core.api.property_mappings import PropertyMappingSerializer
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import JSONDictField
 | 
					from authentik.core.api.utils import JSONDictField
 | 
				
			||||||
from authentik.providers.rac.models import RACPropertyMapping
 | 
					from authentik.enterprise.providers.rac.models import RACPropertyMapping
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RACPropertyMappingSerializer(PropertyMappingSerializer):
 | 
					class RACPropertyMappingSerializer(PropertyMappingSerializer):
 | 
				
			||||||
@ -5,10 +5,11 @@ from rest_framework.viewsets import ModelViewSet
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.providers import ProviderSerializer
 | 
					from authentik.core.api.providers import ProviderSerializer
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.providers.rac.models import RACProvider
 | 
					from authentik.enterprise.api import EnterpriseRequiredMixin
 | 
				
			||||||
 | 
					from authentik.enterprise.providers.rac.models import RACProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RACProviderSerializer(ProviderSerializer):
 | 
					class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
 | 
				
			||||||
    """RACProvider Serializer"""
 | 
					    """RACProvider Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
 | 
					    outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
 | 
				
			||||||
							
								
								
									
										14
									
								
								authentik/enterprise/providers/rac/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								authentik/enterprise/providers/rac/apps.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					"""RAC app config"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.enterprise.apps import EnterpriseConfig
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AuthentikEnterpriseProviderRAC(EnterpriseConfig):
 | 
				
			||||||
 | 
					    """authentik enterprise rac app config"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    name = "authentik.enterprise.providers.rac"
 | 
				
			||||||
 | 
					    label = "authentik_providers_rac"
 | 
				
			||||||
 | 
					    verbose_name = "authentik Enterprise.Providers.RAC"
 | 
				
			||||||
 | 
					    default = True
 | 
				
			||||||
 | 
					    mountpoint = ""
 | 
				
			||||||
 | 
					    ws_mountpoint = "authentik.enterprise.providers.rac.urls"
 | 
				
			||||||
@ -7,22 +7,22 @@ from channels.generic.websocket import AsyncWebsocketConsumer
 | 
				
			|||||||
from django.http.request import QueryDict
 | 
					from django.http.request import QueryDict
 | 
				
			||||||
from structlog.stdlib import BoundLogger, get_logger
 | 
					from structlog.stdlib import BoundLogger, get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.enterprise.providers.rac.models import ConnectionToken, RACProvider
 | 
				
			||||||
from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE
 | 
					from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE
 | 
				
			||||||
from authentik.outposts.models import Outpost, OutpostState, OutpostType
 | 
					from authentik.outposts.models import Outpost, OutpostState, OutpostType
 | 
				
			||||||
from authentik.providers.rac.models import ConnectionToken, RACProvider
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Global broadcast group, which messages are sent to when the outpost connects back
 | 
					# Global broadcast group, which messages are sent to when the outpost connects back
 | 
				
			||||||
# to authentik for a specific connection
 | 
					# to authentik for a specific connection
 | 
				
			||||||
# The `RACClientConsumer` consumer adds itself to this group on connection,
 | 
					# The `RACClientConsumer` consumer adds itself to this group on connection,
 | 
				
			||||||
# and removes itself once it has been assigned a specific outpost channel
 | 
					# and removes itself once it has been assigned a specific outpost channel
 | 
				
			||||||
RAC_CLIENT_GROUP = "group_rac_client"
 | 
					RAC_CLIENT_GROUP = "group_enterprise_rac_client"
 | 
				
			||||||
# A group for all connections in a given authentik session ID
 | 
					# A group for all connections in a given authentik session ID
 | 
				
			||||||
# A disconnect message is sent to this group when the session expires/is deleted
 | 
					# A disconnect message is sent to this group when the session expires/is deleted
 | 
				
			||||||
RAC_CLIENT_GROUP_SESSION = "group_rac_client_%(session)s"
 | 
					RAC_CLIENT_GROUP_SESSION = "group_enterprise_rac_client_%(session)s"
 | 
				
			||||||
# A group for all connections with a specific token, which in almost all cases
 | 
					# A group for all connections with a specific token, which in almost all cases
 | 
				
			||||||
# is just one connection, however this is used to disconnect the connection
 | 
					# is just one connection, however this is used to disconnect the connection
 | 
				
			||||||
# when the token is deleted
 | 
					# when the token is deleted
 | 
				
			||||||
RAC_CLIENT_GROUP_TOKEN = "group_rac_token_%(token)s"  # nosec
 | 
					RAC_CLIENT_GROUP_TOKEN = "group_enterprise_rac_token_%(token)s"  # nosec
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Step 1: Client connects to this websocket endpoint
 | 
					# Step 1: Client connects to this websocket endpoint
 | 
				
			||||||
# Step 2: We prepare all the connection args for Guac
 | 
					# Step 2: We prepare all the connection args for Guac
 | 
				
			||||||
@ -3,7 +3,7 @@
 | 
				
			|||||||
from channels.exceptions import ChannelFull
 | 
					from channels.exceptions import ChannelFull
 | 
				
			||||||
from channels.generic.websocket import AsyncWebsocketConsumer
 | 
					from channels.generic.websocket import AsyncWebsocketConsumer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.providers.rac.consumer_client import RAC_CLIENT_GROUP
 | 
					from authentik.enterprise.providers.rac.consumer_client import RAC_CLIENT_GROUP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RACOutpostConsumer(AsyncWebsocketConsumer):
 | 
					class RACOutpostConsumer(AsyncWebsocketConsumer):
 | 
				
			||||||
@ -74,7 +74,7 @@ class RACProvider(Provider):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def serializer(self) -> type[Serializer]:
 | 
					    def serializer(self) -> type[Serializer]:
 | 
				
			||||||
        from authentik.providers.rac.api.providers import RACProviderSerializer
 | 
					        from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return RACProviderSerializer
 | 
					        return RACProviderSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -100,7 +100,7 @@ class Endpoint(SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def serializer(self) -> type[Serializer]:
 | 
					    def serializer(self) -> type[Serializer]:
 | 
				
			||||||
        from authentik.providers.rac.api.endpoints import EndpointSerializer
 | 
					        from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return EndpointSerializer
 | 
					        return EndpointSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -129,7 +129,7 @@ class RACPropertyMapping(PropertyMapping):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def serializer(self) -> type[Serializer]:
 | 
					    def serializer(self) -> type[Serializer]:
 | 
				
			||||||
        from authentik.providers.rac.api.property_mappings import (
 | 
					        from authentik.enterprise.providers.rac.api.property_mappings import (
 | 
				
			||||||
            RACPropertyMappingSerializer,
 | 
					            RACPropertyMappingSerializer,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -159,9 +159,9 @@ class ConnectionToken(ExpiringModel):
 | 
				
			|||||||
            default_settings["port"] = str(port)
 | 
					            default_settings["port"] = str(port)
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            default_settings["hostname"] = self.endpoint.host
 | 
					            default_settings["hostname"] = self.endpoint.host
 | 
				
			||||||
        if self.endpoint.protocol == Protocols.RDP:
 | 
					        default_settings["client-name"] = "authentik"
 | 
				
			||||||
            default_settings["resize-method"] = "display-update"
 | 
					        # default_settings["enable-drive"] = "true"
 | 
				
			||||||
        default_settings["client-name"] = f"authentik - {self.session.user}"
 | 
					        # default_settings["drive-name"] = "authentik"
 | 
				
			||||||
        settings = {}
 | 
					        settings = {}
 | 
				
			||||||
        always_merger.merge(settings, default_settings)
 | 
					        always_merger.merge(settings, default_settings)
 | 
				
			||||||
        always_merger.merge(settings, self.endpoint.provider.settings)
 | 
					        always_merger.merge(settings, self.endpoint.provider.settings)
 | 
				
			||||||
@ -211,4 +211,3 @@ class ConnectionToken(ExpiringModel):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        verbose_name = _("RAC Connection token")
 | 
					        verbose_name = _("RAC Connection token")
 | 
				
			||||||
        verbose_name_plural = _("RAC Connection tokens")
 | 
					        verbose_name_plural = _("RAC Connection tokens")
 | 
				
			||||||
        indexes = ExpiringModel.Meta.indexes
 | 
					 | 
				
			||||||
@ -10,12 +10,12 @@ from django.dispatch import receiver
 | 
				
			|||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.models import User
 | 
				
			||||||
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
 | 
					from authentik.enterprise.providers.rac.api.endpoints import user_endpoint_cache_key
 | 
				
			||||||
from authentik.providers.rac.consumer_client import (
 | 
					from authentik.enterprise.providers.rac.consumer_client import (
 | 
				
			||||||
    RAC_CLIENT_GROUP_SESSION,
 | 
					    RAC_CLIENT_GROUP_SESSION,
 | 
				
			||||||
    RAC_CLIENT_GROUP_TOKEN,
 | 
					    RAC_CLIENT_GROUP_TOKEN,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from authentik.providers.rac.models import ConnectionToken, Endpoint
 | 
					from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(user_logged_out)
 | 
					@receiver(user_logged_out)
 | 
				
			||||||
@ -3,7 +3,7 @@
 | 
				
			|||||||
{% load authentik_core %}
 | 
					{% load authentik_core %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head %}
 | 
					{% block head %}
 | 
				
			||||||
<script src="{% versioned_script 'dist/rac/index-%v.js' %}" type="module"></script>
 | 
					<script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script>
 | 
				
			||||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
 | 
					<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
 | 
				
			||||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
 | 
					<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
 | 
				
			||||||
<link rel="icon" href="{{ tenant.branding_favicon_url }}">
 | 
					<link rel="icon" href="{{ tenant.branding_favicon_url }}">
 | 
				
			||||||
@ -1,9 +1,16 @@
 | 
				
			|||||||
"""Test RAC Provider"""
 | 
					"""Test RAC Provider"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
 | 
					from time import mktime
 | 
				
			||||||
 | 
					from unittest.mock import MagicMock, patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from django.utils.timezone import now
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
					from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
				
			||||||
 | 
					from authentik.enterprise.license import LicenseKey
 | 
				
			||||||
 | 
					from authentik.enterprise.models import License
 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -13,8 +20,21 @@ class TestAPI(APITestCase):
 | 
				
			|||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        self.user = create_test_admin_user()
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch(
 | 
				
			||||||
 | 
					        "authentik.enterprise.license.LicenseKey.validate",
 | 
				
			||||||
 | 
					        MagicMock(
 | 
				
			||||||
 | 
					            return_value=LicenseKey(
 | 
				
			||||||
 | 
					                aud="",
 | 
				
			||||||
 | 
					                exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
 | 
				
			||||||
 | 
					                name=generate_id(),
 | 
				
			||||||
 | 
					                internal_users=100,
 | 
				
			||||||
 | 
					                external_users=100,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    def test_create(self):
 | 
					    def test_create(self):
 | 
				
			||||||
        """Test creation of RAC Provider"""
 | 
					        """Test creation of RAC Provider"""
 | 
				
			||||||
 | 
					        License.objects.create(key=generate_id())
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
        response = self.client.post(
 | 
					        response = self.client.post(
 | 
				
			||||||
            reverse("authentik_api:racprovider-list"),
 | 
					            reverse("authentik_api:racprovider-list"),
 | 
				
			||||||
@ -5,10 +5,10 @@ from rest_framework.test import APITestCase
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Application
 | 
					from authentik.core.models import Application
 | 
				
			||||||
from authentik.core.tests.utils import create_test_admin_user
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
 | 
					from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider
 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
from authentik.policies.dummy.models import DummyPolicy
 | 
					from authentik.policies.dummy.models import DummyPolicy
 | 
				
			||||||
from authentik.policies.models import PolicyBinding
 | 
					from authentik.policies.models import PolicyBinding
 | 
				
			||||||
from authentik.providers.rac.models import Endpoint, Protocols, RACProvider
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestEndpointsAPI(APITestCase):
 | 
					class TestEndpointsAPI(APITestCase):
 | 
				
			||||||
@ -4,14 +4,14 @@ from django.test import TransactionTestCase
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Application, AuthenticatedSession
 | 
					from authentik.core.models import Application, AuthenticatedSession
 | 
				
			||||||
from authentik.core.tests.utils import create_test_admin_user
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					from authentik.enterprise.providers.rac.models import (
 | 
				
			||||||
from authentik.providers.rac.models import (
 | 
					 | 
				
			||||||
    ConnectionToken,
 | 
					    ConnectionToken,
 | 
				
			||||||
    Endpoint,
 | 
					    Endpoint,
 | 
				
			||||||
    Protocols,
 | 
					    Protocols,
 | 
				
			||||||
    RACPropertyMapping,
 | 
					    RACPropertyMapping,
 | 
				
			||||||
    RACProvider,
 | 
					    RACProvider,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestModels(TransactionTestCase):
 | 
					class TestModels(TransactionTestCase):
 | 
				
			||||||
@ -50,10 +50,9 @@ class TestModels(TransactionTestCase):
 | 
				
			|||||||
            {
 | 
					            {
 | 
				
			||||||
                "hostname": self.endpoint.host.split(":")[0],
 | 
					                "hostname": self.endpoint.host.split(":")[0],
 | 
				
			||||||
                "port": "1324",
 | 
					                "port": "1324",
 | 
				
			||||||
                "client-name": f"authentik - {self.user}",
 | 
					                "client-name": "authentik",
 | 
				
			||||||
                "drive-path": path,
 | 
					                "drive-path": path,
 | 
				
			||||||
                "create-drive-path": "true",
 | 
					                "create-drive-path": "true",
 | 
				
			||||||
                "resize-method": "display-update",
 | 
					 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        # Set settings in provider
 | 
					        # Set settings in provider
 | 
				
			||||||
@ -64,11 +63,10 @@ class TestModels(TransactionTestCase):
 | 
				
			|||||||
            {
 | 
					            {
 | 
				
			||||||
                "hostname": self.endpoint.host.split(":")[0],
 | 
					                "hostname": self.endpoint.host.split(":")[0],
 | 
				
			||||||
                "port": "1324",
 | 
					                "port": "1324",
 | 
				
			||||||
                "client-name": f"authentik - {self.user}",
 | 
					                "client-name": "authentik",
 | 
				
			||||||
                "drive-path": path,
 | 
					                "drive-path": path,
 | 
				
			||||||
                "create-drive-path": "true",
 | 
					                "create-drive-path": "true",
 | 
				
			||||||
                "level": "provider",
 | 
					                "level": "provider",
 | 
				
			||||||
                "resize-method": "display-update",
 | 
					 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        # Set settings in endpoint
 | 
					        # Set settings in endpoint
 | 
				
			||||||
@ -81,11 +79,10 @@ class TestModels(TransactionTestCase):
 | 
				
			|||||||
            {
 | 
					            {
 | 
				
			||||||
                "hostname": self.endpoint.host.split(":")[0],
 | 
					                "hostname": self.endpoint.host.split(":")[0],
 | 
				
			||||||
                "port": "1324",
 | 
					                "port": "1324",
 | 
				
			||||||
                "client-name": f"authentik - {self.user}",
 | 
					                "client-name": "authentik",
 | 
				
			||||||
                "drive-path": path,
 | 
					                "drive-path": path,
 | 
				
			||||||
                "create-drive-path": "true",
 | 
					                "create-drive-path": "true",
 | 
				
			||||||
                "level": "endpoint",
 | 
					                "level": "endpoint",
 | 
				
			||||||
                "resize-method": "display-update",
 | 
					 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        # Set settings in token
 | 
					        # Set settings in token
 | 
				
			||||||
@ -98,11 +95,10 @@ class TestModels(TransactionTestCase):
 | 
				
			|||||||
            {
 | 
					            {
 | 
				
			||||||
                "hostname": self.endpoint.host.split(":")[0],
 | 
					                "hostname": self.endpoint.host.split(":")[0],
 | 
				
			||||||
                "port": "1324",
 | 
					                "port": "1324",
 | 
				
			||||||
                "client-name": f"authentik - {self.user}",
 | 
					                "client-name": "authentik",
 | 
				
			||||||
                "drive-path": path,
 | 
					                "drive-path": path,
 | 
				
			||||||
                "create-drive-path": "true",
 | 
					                "create-drive-path": "true",
 | 
				
			||||||
                "level": "token",
 | 
					                "level": "token",
 | 
				
			||||||
                "resize-method": "display-update",
 | 
					 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        # Set settings in property mapping (provider)
 | 
					        # Set settings in property mapping (provider)
 | 
				
			||||||
@ -118,11 +114,10 @@ class TestModels(TransactionTestCase):
 | 
				
			|||||||
            {
 | 
					            {
 | 
				
			||||||
                "hostname": self.endpoint.host.split(":")[0],
 | 
					                "hostname": self.endpoint.host.split(":")[0],
 | 
				
			||||||
                "port": "1324",
 | 
					                "port": "1324",
 | 
				
			||||||
                "client-name": f"authentik - {self.user}",
 | 
					                "client-name": "authentik",
 | 
				
			||||||
                "drive-path": path,
 | 
					                "drive-path": path,
 | 
				
			||||||
                "create-drive-path": "true",
 | 
					                "create-drive-path": "true",
 | 
				
			||||||
                "level": "property_mapping_provider",
 | 
					                "level": "property_mapping_provider",
 | 
				
			||||||
                "resize-method": "display-update",
 | 
					 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        # Set settings in property mapping (endpoint)
 | 
					        # Set settings in property mapping (endpoint)
 | 
				
			||||||
@ -140,12 +135,11 @@ class TestModels(TransactionTestCase):
 | 
				
			|||||||
            {
 | 
					            {
 | 
				
			||||||
                "hostname": self.endpoint.host.split(":")[0],
 | 
					                "hostname": self.endpoint.host.split(":")[0],
 | 
				
			||||||
                "port": "1324",
 | 
					                "port": "1324",
 | 
				
			||||||
                "client-name": f"authentik - {self.user}",
 | 
					                "client-name": "authentik",
 | 
				
			||||||
                "drive-path": path,
 | 
					                "drive-path": path,
 | 
				
			||||||
                "create-drive-path": "true",
 | 
					                "create-drive-path": "true",
 | 
				
			||||||
                "level": "property_mapping_endpoint",
 | 
					                "level": "property_mapping_endpoint",
 | 
				
			||||||
                "foo": "true",
 | 
					                "foo": "true",
 | 
				
			||||||
                "bar": "6",
 | 
					                "bar": "6",
 | 
				
			||||||
                "resize-method": "display-update",
 | 
					 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
@ -1,17 +1,23 @@
 | 
				
			|||||||
"""RAC Views tests"""
 | 
					"""RAC Views tests"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
from json import loads
 | 
					from json import loads
 | 
				
			||||||
 | 
					from time import mktime
 | 
				
			||||||
 | 
					from unittest.mock import MagicMock, patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from django.utils.timezone import now
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Application
 | 
					from authentik.core.models import Application
 | 
				
			||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
					from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
				
			||||||
 | 
					from authentik.enterprise.license import LicenseKey
 | 
				
			||||||
 | 
					from authentik.enterprise.models import License
 | 
				
			||||||
 | 
					from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider
 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
from authentik.policies.denied import AccessDeniedResponse
 | 
					from authentik.policies.denied import AccessDeniedResponse
 | 
				
			||||||
from authentik.policies.dummy.models import DummyPolicy
 | 
					from authentik.policies.dummy.models import DummyPolicy
 | 
				
			||||||
from authentik.policies.models import PolicyBinding
 | 
					from authentik.policies.models import PolicyBinding
 | 
				
			||||||
from authentik.providers.rac.models import Endpoint, Protocols, RACProvider
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestRACViews(APITestCase):
 | 
					class TestRACViews(APITestCase):
 | 
				
			||||||
@ -33,8 +39,21 @@ class TestRACViews(APITestCase):
 | 
				
			|||||||
            provider=self.provider,
 | 
					            provider=self.provider,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch(
 | 
				
			||||||
 | 
					        "authentik.enterprise.license.LicenseKey.validate",
 | 
				
			||||||
 | 
					        MagicMock(
 | 
				
			||||||
 | 
					            return_value=LicenseKey(
 | 
				
			||||||
 | 
					                aud="",
 | 
				
			||||||
 | 
					                exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
 | 
				
			||||||
 | 
					                name=generate_id(),
 | 
				
			||||||
 | 
					                internal_users=100,
 | 
				
			||||||
 | 
					                external_users=100,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    def test_no_policy(self):
 | 
					    def test_no_policy(self):
 | 
				
			||||||
        """Test request"""
 | 
					        """Test request"""
 | 
				
			||||||
 | 
					        License.objects.create(key=generate_id())
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
        response = self.client.get(
 | 
					        response = self.client.get(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
@ -51,6 +70,18 @@ class TestRACViews(APITestCase):
 | 
				
			|||||||
        final_response = self.client.get(next_url)
 | 
					        final_response = self.client.get(next_url)
 | 
				
			||||||
        self.assertEqual(final_response.status_code, 200)
 | 
					        self.assertEqual(final_response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch(
 | 
				
			||||||
 | 
					        "authentik.enterprise.license.LicenseKey.validate",
 | 
				
			||||||
 | 
					        MagicMock(
 | 
				
			||||||
 | 
					            return_value=LicenseKey(
 | 
				
			||||||
 | 
					                aud="",
 | 
				
			||||||
 | 
					                exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
 | 
				
			||||||
 | 
					                name=generate_id(),
 | 
				
			||||||
 | 
					                internal_users=100,
 | 
				
			||||||
 | 
					                external_users=100,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    def test_app_deny(self):
 | 
					    def test_app_deny(self):
 | 
				
			||||||
        """Test request (deny on app level)"""
 | 
					        """Test request (deny on app level)"""
 | 
				
			||||||
        PolicyBinding.objects.create(
 | 
					        PolicyBinding.objects.create(
 | 
				
			||||||
@ -58,6 +89,7 @@ class TestRACViews(APITestCase):
 | 
				
			|||||||
            policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
 | 
					            policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
 | 
				
			||||||
            order=0,
 | 
					            order=0,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        License.objects.create(key=generate_id())
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
        response = self.client.get(
 | 
					        response = self.client.get(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
@ -67,6 +99,18 @@ class TestRACViews(APITestCase):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        self.assertIsInstance(response, AccessDeniedResponse)
 | 
					        self.assertIsInstance(response, AccessDeniedResponse)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch(
 | 
				
			||||||
 | 
					        "authentik.enterprise.license.LicenseKey.validate",
 | 
				
			||||||
 | 
					        MagicMock(
 | 
				
			||||||
 | 
					            return_value=LicenseKey(
 | 
				
			||||||
 | 
					                aud="",
 | 
				
			||||||
 | 
					                exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
 | 
				
			||||||
 | 
					                name=generate_id(),
 | 
				
			||||||
 | 
					                internal_users=100,
 | 
				
			||||||
 | 
					                external_users=100,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    def test_endpoint_deny(self):
 | 
					    def test_endpoint_deny(self):
 | 
				
			||||||
        """Test request (deny on endpoint level)"""
 | 
					        """Test request (deny on endpoint level)"""
 | 
				
			||||||
        PolicyBinding.objects.create(
 | 
					        PolicyBinding.objects.create(
 | 
				
			||||||
@ -74,6 +118,7 @@ class TestRACViews(APITestCase):
 | 
				
			|||||||
            policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
 | 
					            policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
 | 
				
			||||||
            order=0,
 | 
					            order=0,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        License.objects.create(key=generate_id())
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
        response = self.client.get(
 | 
					        response = self.client.get(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
@ -4,14 +4,14 @@ from channels.auth import AuthMiddleware
 | 
				
			|||||||
from channels.sessions import CookieMiddleware
 | 
					from channels.sessions import CookieMiddleware
 | 
				
			||||||
from django.urls import path
 | 
					from django.urls import path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
 | 
					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.outposts.channels import TokenOutpostMiddleware
 | 
				
			||||||
from authentik.providers.rac.api.connection_tokens import ConnectionTokenViewSet
 | 
					 | 
				
			||||||
from authentik.providers.rac.api.endpoints import EndpointViewSet
 | 
					 | 
				
			||||||
from authentik.providers.rac.api.property_mappings import RACPropertyMappingViewSet
 | 
					 | 
				
			||||||
from authentik.providers.rac.api.providers import RACProviderViewSet
 | 
					 | 
				
			||||||
from authentik.providers.rac.consumer_client import RACClientConsumer
 | 
					 | 
				
			||||||
from authentik.providers.rac.consumer_outpost import RACOutpostConsumer
 | 
					 | 
				
			||||||
from authentik.providers.rac.views import RACInterface, RACStartView
 | 
					 | 
				
			||||||
from authentik.root.asgi_middleware import SessionMiddleware
 | 
					from authentik.root.asgi_middleware import SessionMiddleware
 | 
				
			||||||
from authentik.root.middleware import ChannelsLoggingMiddleware
 | 
					from authentik.root.middleware import ChannelsLoggingMiddleware
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -10,6 +10,8 @@ from django.utils.translation import gettext as _
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Application, AuthenticatedSession
 | 
					from authentik.core.models import Application, AuthenticatedSession
 | 
				
			||||||
from authentik.core.views.interface import InterfaceView
 | 
					from authentik.core.views.interface import InterfaceView
 | 
				
			||||||
 | 
					from authentik.enterprise.policy import EnterprisePolicyAccessView
 | 
				
			||||||
 | 
					from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
from authentik.flows.challenge import RedirectChallenge
 | 
					from authentik.flows.challenge import RedirectChallenge
 | 
				
			||||||
from authentik.flows.exceptions import FlowNonApplicableException
 | 
					from authentik.flows.exceptions import FlowNonApplicableException
 | 
				
			||||||
@ -18,11 +20,9 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
 | 
				
			|||||||
from authentik.flows.stage import RedirectStage
 | 
					from authentik.flows.stage import RedirectStage
 | 
				
			||||||
from authentik.lib.utils.time import timedelta_from_string
 | 
					from authentik.lib.utils.time import timedelta_from_string
 | 
				
			||||||
from authentik.policies.engine import PolicyEngine
 | 
					from authentik.policies.engine import PolicyEngine
 | 
				
			||||||
from authentik.policies.views import PolicyAccessView
 | 
					 | 
				
			||||||
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RACStartView(PolicyAccessView):
 | 
					class RACStartView(EnterprisePolicyAccessView):
 | 
				
			||||||
    """Start a RAC connection by checking access and creating a connection token"""
 | 
					    """Start a RAC connection by checking access and creating a connection token"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    endpoint: Endpoint
 | 
					    endpoint: Endpoint
 | 
				
			||||||
@ -1,64 +0,0 @@
 | 
				
			|||||||
"""SSF Provider API Views"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.urls import reverse
 | 
					 | 
				
			||||||
from rest_framework.fields import SerializerMethodField
 | 
					 | 
				
			||||||
from rest_framework.request import Request
 | 
					 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.core.api.providers import ProviderSerializer
 | 
					 | 
				
			||||||
from authentik.core.api.tokens import TokenSerializer
 | 
					 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					 | 
				
			||||||
from authentik.enterprise.api import EnterpriseRequiredMixin
 | 
					 | 
				
			||||||
from authentik.enterprise.providers.ssf.models import SSFProvider
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SSFProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
 | 
					 | 
				
			||||||
    """SSFProvider Serializer"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    ssf_url = SerializerMethodField()
 | 
					 | 
				
			||||||
    token_obj = TokenSerializer(source="token", required=False, read_only=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_ssf_url(self, instance: SSFProvider) -> str | None:
 | 
					 | 
				
			||||||
        request: Request = self._context.get("request")
 | 
					 | 
				
			||||||
        if not request:
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
        if not instance.backchannel_application:
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
        return request.build_absolute_uri(
 | 
					 | 
				
			||||||
            reverse(
 | 
					 | 
				
			||||||
                "authentik_providers_ssf:configuration",
 | 
					 | 
				
			||||||
                kwargs={
 | 
					 | 
				
			||||||
                    "application_slug": instance.backchannel_application.slug,
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        model = SSFProvider
 | 
					 | 
				
			||||||
        fields = [
 | 
					 | 
				
			||||||
            "pk",
 | 
					 | 
				
			||||||
            "name",
 | 
					 | 
				
			||||||
            "component",
 | 
					 | 
				
			||||||
            "verbose_name",
 | 
					 | 
				
			||||||
            "verbose_name_plural",
 | 
					 | 
				
			||||||
            "meta_model_name",
 | 
					 | 
				
			||||||
            "signing_key",
 | 
					 | 
				
			||||||
            "token_obj",
 | 
					 | 
				
			||||||
            "oidc_auth_providers",
 | 
					 | 
				
			||||||
            "ssf_url",
 | 
					 | 
				
			||||||
            "event_retention",
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
        extra_kwargs = {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SSFProviderViewSet(UsedByMixin, ModelViewSet):
 | 
					 | 
				
			||||||
    """SSFProvider Viewset"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    queryset = SSFProvider.objects.all()
 | 
					 | 
				
			||||||
    serializer_class = SSFProviderSerializer
 | 
					 | 
				
			||||||
    filterset_fields = {
 | 
					 | 
				
			||||||
        "application": ["isnull"],
 | 
					 | 
				
			||||||
        "name": ["iexact"],
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    search_fields = ["name"]
 | 
					 | 
				
			||||||
    ordering = ["name"]
 | 
					 | 
				
			||||||
@ -1,37 +0,0 @@
 | 
				
			|||||||
"""SSF Stream API Views"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from rest_framework.viewsets import ReadOnlyModelViewSet
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.core.api.utils import ModelSerializer
 | 
					 | 
				
			||||||
from authentik.enterprise.providers.ssf.api.providers import SSFProviderSerializer
 | 
					 | 
				
			||||||
from authentik.enterprise.providers.ssf.models import Stream
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SSFStreamSerializer(ModelSerializer):
 | 
					 | 
				
			||||||
    """SSFStream Serializer"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    provider_obj = SSFProviderSerializer(source="provider", read_only=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        model = Stream
 | 
					 | 
				
			||||||
        fields = [
 | 
					 | 
				
			||||||
            "pk",
 | 
					 | 
				
			||||||
            "provider",
 | 
					 | 
				
			||||||
            "provider_obj",
 | 
					 | 
				
			||||||
            "delivery_method",
 | 
					 | 
				
			||||||
            "endpoint_url",
 | 
					 | 
				
			||||||
            "events_requested",
 | 
					 | 
				
			||||||
            "format",
 | 
					 | 
				
			||||||
            "aud",
 | 
					 | 
				
			||||||
            "iss",
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SSFStreamViewSet(ReadOnlyModelViewSet):
 | 
					 | 
				
			||||||
    """SSFStream Viewset"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    queryset = Stream.objects.all()
 | 
					 | 
				
			||||||
    serializer_class = SSFStreamSerializer
 | 
					 | 
				
			||||||
    filterset_fields = ["provider", "endpoint_url", "delivery_method"]
 | 
					 | 
				
			||||||
    search_fields = ["provider__name", "endpoint_url"]
 | 
					 | 
				
			||||||
    ordering = ["provider", "uuid"]
 | 
					 | 
				
			||||||
@ -1,13 +0,0 @@
 | 
				
			|||||||
"""SSF app config"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.enterprise.apps import EnterpriseConfig
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class AuthentikEnterpriseProviderSSF(EnterpriseConfig):
 | 
					 | 
				
			||||||
    """authentik enterprise ssf app config"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    name = "authentik.enterprise.providers.ssf"
 | 
					 | 
				
			||||||
    label = "authentik_providers_ssf"
 | 
					 | 
				
			||||||
    verbose_name = "authentik Enterprise.Providers.SSF"
 | 
					 | 
				
			||||||
    default = True
 | 
					 | 
				
			||||||
    mountpoint = ""
 | 
					 | 
				
			||||||
@ -1,201 +0,0 @@
 | 
				
			|||||||
# Generated by Django 5.0.11 on 2025-02-05 16:20
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import authentik.lib.utils.time
 | 
					 | 
				
			||||||
import django.contrib.postgres.fields
 | 
					 | 
				
			||||||
import django.db.models.deletion
 | 
					 | 
				
			||||||
import uuid
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    initial = True
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    dependencies = [
 | 
					 | 
				
			||||||
        ("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
 | 
					 | 
				
			||||||
        ("authentik_crypto", "0004_alter_certificatekeypair_name"),
 | 
					 | 
				
			||||||
        ("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.CreateModel(
 | 
					 | 
				
			||||||
            name="SSFProvider",
 | 
					 | 
				
			||||||
            fields=[
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "provider_ptr",
 | 
					 | 
				
			||||||
                    models.OneToOneField(
 | 
					 | 
				
			||||||
                        auto_created=True,
 | 
					 | 
				
			||||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
					 | 
				
			||||||
                        parent_link=True,
 | 
					 | 
				
			||||||
                        primary_key=True,
 | 
					 | 
				
			||||||
                        serialize=False,
 | 
					 | 
				
			||||||
                        to="authentik_core.provider",
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "event_retention",
 | 
					 | 
				
			||||||
                    models.TextField(
 | 
					 | 
				
			||||||
                        default="days=30",
 | 
					 | 
				
			||||||
                        validators=[authentik.lib.utils.time.timedelta_string_validator],
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "oidc_auth_providers",
 | 
					 | 
				
			||||||
                    models.ManyToManyField(
 | 
					 | 
				
			||||||
                        blank=True, default=None, to="authentik_providers_oauth2.oauth2provider"
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "signing_key",
 | 
					 | 
				
			||||||
                    models.ForeignKey(
 | 
					 | 
				
			||||||
                        help_text="Key used to sign the SSF Events.",
 | 
					 | 
				
			||||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
					 | 
				
			||||||
                        to="authentik_crypto.certificatekeypair",
 | 
					 | 
				
			||||||
                        verbose_name="Signing Key",
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "token",
 | 
					 | 
				
			||||||
                    models.ForeignKey(
 | 
					 | 
				
			||||||
                        default=None,
 | 
					 | 
				
			||||||
                        null=True,
 | 
					 | 
				
			||||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
					 | 
				
			||||||
                        to="authentik_core.token",
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            options={
 | 
					 | 
				
			||||||
                "verbose_name": "Shared Signals Framework Provider",
 | 
					 | 
				
			||||||
                "verbose_name_plural": "Shared Signals Framework Providers",
 | 
					 | 
				
			||||||
                "permissions": [("add_stream", "Add stream to SSF provider")],
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            bases=("authentik_core.provider",),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.CreateModel(
 | 
					 | 
				
			||||||
            name="Stream",
 | 
					 | 
				
			||||||
            fields=[
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "uuid",
 | 
					 | 
				
			||||||
                    models.UUIDField(
 | 
					 | 
				
			||||||
                        default=uuid.uuid4, editable=False, primary_key=True, serialize=False
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "delivery_method",
 | 
					 | 
				
			||||||
                    models.TextField(
 | 
					 | 
				
			||||||
                        choices=[
 | 
					 | 
				
			||||||
                            (
 | 
					 | 
				
			||||||
                                "https://schemas.openid.net/secevent/risc/delivery-method/push",
 | 
					 | 
				
			||||||
                                "Risc Push",
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            (
 | 
					 | 
				
			||||||
                                "https://schemas.openid.net/secevent/risc/delivery-method/poll",
 | 
					 | 
				
			||||||
                                "Risc Poll",
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                        ]
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                ("endpoint_url", models.TextField(null=True)),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "events_requested",
 | 
					 | 
				
			||||||
                    django.contrib.postgres.fields.ArrayField(
 | 
					 | 
				
			||||||
                        base_field=models.TextField(
 | 
					 | 
				
			||||||
                            choices=[
 | 
					 | 
				
			||||||
                                (
 | 
					 | 
				
			||||||
                                    "https://schemas.openid.net/secevent/caep/event-type/session-revoked",
 | 
					 | 
				
			||||||
                                    "Caep Session Revoked",
 | 
					 | 
				
			||||||
                                ),
 | 
					 | 
				
			||||||
                                (
 | 
					 | 
				
			||||||
                                    "https://schemas.openid.net/secevent/caep/event-type/credential-change",
 | 
					 | 
				
			||||||
                                    "Caep Credential Change",
 | 
					 | 
				
			||||||
                                ),
 | 
					 | 
				
			||||||
                                (
 | 
					 | 
				
			||||||
                                    "https://schemas.openid.net/secevent/ssf/event-type/verification",
 | 
					 | 
				
			||||||
                                    "Set Verification",
 | 
					 | 
				
			||||||
                                ),
 | 
					 | 
				
			||||||
                            ]
 | 
					 | 
				
			||||||
                        ),
 | 
					 | 
				
			||||||
                        default=list,
 | 
					 | 
				
			||||||
                        size=None,
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                ("format", models.TextField()),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "aud",
 | 
					 | 
				
			||||||
                    django.contrib.postgres.fields.ArrayField(
 | 
					 | 
				
			||||||
                        base_field=models.TextField(), default=list, size=None
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                ("iss", models.TextField()),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "provider",
 | 
					 | 
				
			||||||
                    models.ForeignKey(
 | 
					 | 
				
			||||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
					 | 
				
			||||||
                        to="authentik_providers_ssf.ssfprovider",
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            options={
 | 
					 | 
				
			||||||
                "verbose_name": "SSF Stream",
 | 
					 | 
				
			||||||
                "verbose_name_plural": "SSF Streams",
 | 
					 | 
				
			||||||
                "default_permissions": ["change", "delete", "view"],
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.CreateModel(
 | 
					 | 
				
			||||||
            name="StreamEvent",
 | 
					 | 
				
			||||||
            fields=[
 | 
					 | 
				
			||||||
                ("created", models.DateTimeField(auto_now_add=True)),
 | 
					 | 
				
			||||||
                ("last_updated", models.DateTimeField(auto_now=True)),
 | 
					 | 
				
			||||||
                ("expires", models.DateTimeField(default=None, null=True)),
 | 
					 | 
				
			||||||
                ("expiring", models.BooleanField(default=True)),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "uuid",
 | 
					 | 
				
			||||||
                    models.UUIDField(
 | 
					 | 
				
			||||||
                        default=uuid.uuid4, editable=False, primary_key=True, serialize=False
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "status",
 | 
					 | 
				
			||||||
                    models.TextField(
 | 
					 | 
				
			||||||
                        choices=[
 | 
					 | 
				
			||||||
                            ("pending_new", "Pending New"),
 | 
					 | 
				
			||||||
                            ("pending_failed", "Pending Failed"),
 | 
					 | 
				
			||||||
                            ("sent", "Sent"),
 | 
					 | 
				
			||||||
                        ]
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "type",
 | 
					 | 
				
			||||||
                    models.TextField(
 | 
					 | 
				
			||||||
                        choices=[
 | 
					 | 
				
			||||||
                            (
 | 
					 | 
				
			||||||
                                "https://schemas.openid.net/secevent/caep/event-type/session-revoked",
 | 
					 | 
				
			||||||
                                "Caep Session Revoked",
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            (
 | 
					 | 
				
			||||||
                                "https://schemas.openid.net/secevent/caep/event-type/credential-change",
 | 
					 | 
				
			||||||
                                "Caep Credential Change",
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            (
 | 
					 | 
				
			||||||
                                "https://schemas.openid.net/secevent/ssf/event-type/verification",
 | 
					 | 
				
			||||||
                                "Set Verification",
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                        ]
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                ("payload", models.JSONField(default=dict)),
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    "stream",
 | 
					 | 
				
			||||||
                    models.ForeignKey(
 | 
					 | 
				
			||||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
					 | 
				
			||||||
                        to="authentik_providers_ssf.stream",
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            options={
 | 
					 | 
				
			||||||
                "verbose_name": "SSF Stream Event",
 | 
					 | 
				
			||||||
                "verbose_name_plural": "SSF Stream Events",
 | 
					 | 
				
			||||||
                "ordering": ("-created",),
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@ -1,178 +0,0 @@
 | 
				
			|||||||
from datetime import datetime
 | 
					 | 
				
			||||||
from functools import cached_property
 | 
					 | 
				
			||||||
from uuid import uuid4
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
 | 
					 | 
				
			||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
 | 
					 | 
				
			||||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
 | 
					 | 
				
			||||||
from django.contrib.postgres.fields import ArrayField
 | 
					 | 
				
			||||||
from django.db import models
 | 
					 | 
				
			||||||
from django.templatetags.static import static
 | 
					 | 
				
			||||||
from django.utils.timezone import now
 | 
					 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					 | 
				
			||||||
from jwt import encode
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.core.models import BackchannelProvider, ExpiringModel, Token
 | 
					 | 
				
			||||||
from authentik.crypto.models import CertificateKeyPair
 | 
					 | 
				
			||||||
from authentik.lib.models import CreatedUpdatedModel
 | 
					 | 
				
			||||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
 | 
					 | 
				
			||||||
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class EventTypes(models.TextChoices):
 | 
					 | 
				
			||||||
    """SSF Event types supported by authentik"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    CAEP_SESSION_REVOKED = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
 | 
					 | 
				
			||||||
    CAEP_CREDENTIAL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/credential-change"
 | 
					 | 
				
			||||||
    SET_VERIFICATION = "https://schemas.openid.net/secevent/ssf/event-type/verification"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class DeliveryMethods(models.TextChoices):
 | 
					 | 
				
			||||||
    """SSF Delivery methods"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    RISC_PUSH = "https://schemas.openid.net/secevent/risc/delivery-method/push"
 | 
					 | 
				
			||||||
    RISC_POLL = "https://schemas.openid.net/secevent/risc/delivery-method/poll"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SSFEventStatus(models.TextChoices):
 | 
					 | 
				
			||||||
    """SSF Event status"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    PENDING_NEW = "pending_new"
 | 
					 | 
				
			||||||
    PENDING_FAILED = "pending_failed"
 | 
					 | 
				
			||||||
    SENT = "sent"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SSFProvider(BackchannelProvider):
 | 
					 | 
				
			||||||
    """Shared Signals Framework provider to allow applications to
 | 
					 | 
				
			||||||
    receive user events from authentik."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    signing_key = models.ForeignKey(
 | 
					 | 
				
			||||||
        CertificateKeyPair,
 | 
					 | 
				
			||||||
        verbose_name=_("Signing Key"),
 | 
					 | 
				
			||||||
        on_delete=models.CASCADE,
 | 
					 | 
				
			||||||
        help_text=_("Key used to sign the SSF Events."),
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    oidc_auth_providers = models.ManyToManyField(OAuth2Provider, blank=True, default=None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    event_retention = models.TextField(
 | 
					 | 
				
			||||||
        default="days=30",
 | 
					 | 
				
			||||||
        validators=[timedelta_string_validator],
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @cached_property
 | 
					 | 
				
			||||||
    def jwt_key(self) -> tuple[PrivateKeyTypes, str]:
 | 
					 | 
				
			||||||
        """Get either the configured certificate or the client secret"""
 | 
					 | 
				
			||||||
        key: CertificateKeyPair = self.signing_key
 | 
					 | 
				
			||||||
        private_key = key.private_key
 | 
					 | 
				
			||||||
        if isinstance(private_key, RSAPrivateKey):
 | 
					 | 
				
			||||||
            return private_key, JWTAlgorithms.RS256
 | 
					 | 
				
			||||||
        if isinstance(private_key, EllipticCurvePrivateKey):
 | 
					 | 
				
			||||||
            return private_key, JWTAlgorithms.ES256
 | 
					 | 
				
			||||||
        raise ValueError(f"Invalid private key type: {type(private_key)}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def service_account_identifier(self) -> str:
 | 
					 | 
				
			||||||
        return f"ak-providers-ssf-{self.pk}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def serializer(self):
 | 
					 | 
				
			||||||
        from authentik.enterprise.providers.ssf.api.providers import SSFProviderSerializer
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return SSFProviderSerializer
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def icon_url(self) -> str | None:
 | 
					 | 
				
			||||||
        return static("authentik/sources/ssf.svg")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def component(self) -> str:
 | 
					 | 
				
			||||||
        return "ak-provider-ssf-form"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        verbose_name = _("Shared Signals Framework Provider")
 | 
					 | 
				
			||||||
        verbose_name_plural = _("Shared Signals Framework Providers")
 | 
					 | 
				
			||||||
        permissions = [
 | 
					 | 
				
			||||||
            # This overrides the default "add_stream" permission of the Stream object,
 | 
					 | 
				
			||||||
            # as the user requesting to add a stream must have the permission on the provider
 | 
					 | 
				
			||||||
            ("add_stream", _("Add stream to SSF provider")),
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Stream(models.Model):
 | 
					 | 
				
			||||||
    """SSF Stream"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False)
 | 
					 | 
				
			||||||
    provider = models.ForeignKey(SSFProvider, on_delete=models.CASCADE)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    delivery_method = models.TextField(choices=DeliveryMethods.choices)
 | 
					 | 
				
			||||||
    endpoint_url = models.TextField(null=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    events_requested = ArrayField(models.TextField(choices=EventTypes.choices), default=list)
 | 
					 | 
				
			||||||
    format = models.TextField()
 | 
					 | 
				
			||||||
    aud = ArrayField(models.TextField(), default=list)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    iss = models.TextField()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        verbose_name = _("SSF Stream")
 | 
					 | 
				
			||||||
        verbose_name_plural = _("SSF Streams")
 | 
					 | 
				
			||||||
        default_permissions = ["change", "delete", "view"]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __str__(self) -> str:
 | 
					 | 
				
			||||||
        return "SSF Stream"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def prepare_event_payload(self, type: EventTypes, event_data: dict, **kwargs) -> dict:
 | 
					 | 
				
			||||||
        jti = uuid4()
 | 
					 | 
				
			||||||
        _now = now()
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
            "uuid": jti,
 | 
					 | 
				
			||||||
            "stream_id": str(self.pk),
 | 
					 | 
				
			||||||
            "type": type,
 | 
					 | 
				
			||||||
            "expiring": True,
 | 
					 | 
				
			||||||
            "status": SSFEventStatus.PENDING_NEW,
 | 
					 | 
				
			||||||
            "expires": _now + timedelta_from_string(self.provider.event_retention),
 | 
					 | 
				
			||||||
            "payload": {
 | 
					 | 
				
			||||||
                "jti": jti.hex,
 | 
					 | 
				
			||||||
                "aud": self.aud,
 | 
					 | 
				
			||||||
                "iat": int(datetime.now().timestamp()),
 | 
					 | 
				
			||||||
                "iss": self.iss,
 | 
					 | 
				
			||||||
                "events": {type: event_data},
 | 
					 | 
				
			||||||
                **kwargs,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def encode(self, data: dict) -> str:
 | 
					 | 
				
			||||||
        headers = {}
 | 
					 | 
				
			||||||
        if self.provider.signing_key:
 | 
					 | 
				
			||||||
            headers["kid"] = self.provider.signing_key.kid
 | 
					 | 
				
			||||||
        key, alg = self.provider.jwt_key
 | 
					 | 
				
			||||||
        return encode(data, key, algorithm=alg, headers=headers)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class StreamEvent(CreatedUpdatedModel, ExpiringModel):
 | 
					 | 
				
			||||||
    """Single stream event to be sent"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    stream = models.ForeignKey(Stream, on_delete=models.CASCADE)
 | 
					 | 
				
			||||||
    status = models.TextField(choices=SSFEventStatus.choices)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    type = models.TextField(choices=EventTypes.choices)
 | 
					 | 
				
			||||||
    payload = models.JSONField(default=dict)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def expire_action(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        """Only allow automatic cleanup of successfully sent event"""
 | 
					 | 
				
			||||||
        if self.status != SSFEventStatus.SENT:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
        return super().expire_action(*args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __str__(self):
 | 
					 | 
				
			||||||
        return f"Stream event {self.type}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        verbose_name = _("SSF Stream Event")
 | 
					 | 
				
			||||||
        verbose_name_plural = _("SSF Stream Events")
 | 
					 | 
				
			||||||
        ordering = ("-created",)
 | 
					 | 
				
			||||||
@ -1,193 +0,0 @@
 | 
				
			|||||||
from hashlib import sha256
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.contrib.auth.signals import user_logged_out
 | 
					 | 
				
			||||||
from django.db.models import Model
 | 
					 | 
				
			||||||
from django.db.models.signals import post_delete, post_save, pre_delete
 | 
					 | 
				
			||||||
from django.dispatch import receiver
 | 
					 | 
				
			||||||
from django.http.request import HttpRequest
 | 
					 | 
				
			||||||
from guardian.shortcuts import assign_perm
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.core.models import (
 | 
					 | 
				
			||||||
    USER_PATH_SYSTEM_PREFIX,
 | 
					 | 
				
			||||||
    AuthenticatedSession,
 | 
					 | 
				
			||||||
    Token,
 | 
					 | 
				
			||||||
    TokenIntents,
 | 
					 | 
				
			||||||
    User,
 | 
					 | 
				
			||||||
    UserTypes,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
from authentik.core.signals import password_changed
 | 
					 | 
				
			||||||
from authentik.enterprise.providers.ssf.models import (
 | 
					 | 
				
			||||||
    EventTypes,
 | 
					 | 
				
			||||||
    SSFProvider,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
from authentik.enterprise.providers.ssf.tasks import send_ssf_event
 | 
					 | 
				
			||||||
from authentik.events.middleware import audit_ignore
 | 
					 | 
				
			||||||
from authentik.stages.authenticator.models import Device
 | 
					 | 
				
			||||||
from authentik.stages.authenticator_duo.models import DuoDevice
 | 
					 | 
				
			||||||
from authentik.stages.authenticator_static.models import StaticDevice
 | 
					 | 
				
			||||||
from authentik.stages.authenticator_totp.models import TOTPDevice
 | 
					 | 
				
			||||||
from authentik.stages.authenticator_webauthn.models import (
 | 
					 | 
				
			||||||
    UNKNOWN_DEVICE_TYPE_AAGUID,
 | 
					 | 
				
			||||||
    WebAuthnDevice,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
USER_PATH_PROVIDERS_SSF = USER_PATH_SYSTEM_PREFIX + "/providers/ssf"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@receiver(post_save, sender=SSFProvider)
 | 
					 | 
				
			||||||
def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: bool, **_):
 | 
					 | 
				
			||||||
    """Create service account before provider is saved"""
 | 
					 | 
				
			||||||
    identifier = instance.service_account_identifier
 | 
					 | 
				
			||||||
    user, _ = User.objects.update_or_create(
 | 
					 | 
				
			||||||
        username=identifier,
 | 
					 | 
				
			||||||
        defaults={
 | 
					 | 
				
			||||||
            "name": f"SSF Provider {instance.name} Service-Account",
 | 
					 | 
				
			||||||
            "type": UserTypes.INTERNAL_SERVICE_ACCOUNT,
 | 
					 | 
				
			||||||
            "path": USER_PATH_PROVIDERS_SSF,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    assign_perm("add_stream", user, instance)
 | 
					 | 
				
			||||||
    token, token_created = Token.objects.update_or_create(
 | 
					 | 
				
			||||||
        identifier=identifier,
 | 
					 | 
				
			||||||
        defaults={
 | 
					 | 
				
			||||||
            "user": user,
 | 
					 | 
				
			||||||
            "intent": TokenIntents.INTENT_API,
 | 
					 | 
				
			||||||
            "expiring": False,
 | 
					 | 
				
			||||||
            "managed": f"goauthentik.io/providers/ssf/{instance.pk}",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    if created or token_created:
 | 
					 | 
				
			||||||
        with audit_ignore():
 | 
					 | 
				
			||||||
            instance.token = token
 | 
					 | 
				
			||||||
            instance.save()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@receiver(user_logged_out)
 | 
					 | 
				
			||||||
def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_):
 | 
					 | 
				
			||||||
    """Session revoked trigger (user logged out)"""
 | 
					 | 
				
			||||||
    if not request.session or not request.session.session_key or not user:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    send_ssf_event(
 | 
					 | 
				
			||||||
        EventTypes.CAEP_SESSION_REVOKED,
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "initiating_entity": "user",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        sub_id={
 | 
					 | 
				
			||||||
            "format": "complex",
 | 
					 | 
				
			||||||
            "session": {
 | 
					 | 
				
			||||||
                "format": "opaque",
 | 
					 | 
				
			||||||
                "id": sha256(request.session.session_key.encode("ascii")).hexdigest(),
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            "user": {
 | 
					 | 
				
			||||||
                "format": "email",
 | 
					 | 
				
			||||||
                "email": user.email,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        request=request,
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@receiver(pre_delete, sender=AuthenticatedSession)
 | 
					 | 
				
			||||||
def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_):
 | 
					 | 
				
			||||||
    """Session revoked trigger (users' session has been deleted)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    As this signal is also triggered with a regular logout, we can't be sure
 | 
					 | 
				
			||||||
    if the session has been deleted by an admin or by the user themselves."""
 | 
					 | 
				
			||||||
    send_ssf_event(
 | 
					 | 
				
			||||||
        EventTypes.CAEP_SESSION_REVOKED,
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "initiating_entity": "user",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        sub_id={
 | 
					 | 
				
			||||||
            "format": "complex",
 | 
					 | 
				
			||||||
            "session": {
 | 
					 | 
				
			||||||
                "format": "opaque",
 | 
					 | 
				
			||||||
                "id": sha256(instance.session_key.encode("ascii")).hexdigest(),
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            "user": {
 | 
					 | 
				
			||||||
                "format": "email",
 | 
					 | 
				
			||||||
                "email": instance.user.email,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@receiver(password_changed)
 | 
					 | 
				
			||||||
def ssf_password_changed_cred_change(sender, user: User, password: str | None, **_):
 | 
					 | 
				
			||||||
    """Credential change trigger (password changed)"""
 | 
					 | 
				
			||||||
    send_ssf_event(
 | 
					 | 
				
			||||||
        EventTypes.CAEP_CREDENTIAL_CHANGE,
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "credential_type": "password",
 | 
					 | 
				
			||||||
            "change_type": "revoke" if password is None else "update",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        sub_id={
 | 
					 | 
				
			||||||
            "format": "complex",
 | 
					 | 
				
			||||||
            "user": {
 | 
					 | 
				
			||||||
                "format": "email",
 | 
					 | 
				
			||||||
                "email": user.email,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
device_type_map = {
 | 
					 | 
				
			||||||
    StaticDevice: "pin",
 | 
					 | 
				
			||||||
    TOTPDevice: "pin",
 | 
					 | 
				
			||||||
    WebAuthnDevice: "fido-u2f",
 | 
					 | 
				
			||||||
    DuoDevice: "app",
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@receiver(post_save)
 | 
					 | 
				
			||||||
def ssf_device_post_save(sender: type[Model], instance: Device, created: bool, **_):
 | 
					 | 
				
			||||||
    if not isinstance(instance, Device):
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    if not instance.confirmed:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    device_type = device_type_map.get(instance.__class__)
 | 
					 | 
				
			||||||
    data = {
 | 
					 | 
				
			||||||
        "credential_type": device_type,
 | 
					 | 
				
			||||||
        "change_type": "create" if created else "update",
 | 
					 | 
				
			||||||
        "friendly_name": instance.name,
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if isinstance(instance, WebAuthnDevice) and instance.aaguid != UNKNOWN_DEVICE_TYPE_AAGUID:
 | 
					 | 
				
			||||||
        data["fido2_aaguid"] = instance.aaguid
 | 
					 | 
				
			||||||
    send_ssf_event(
 | 
					 | 
				
			||||||
        EventTypes.CAEP_CREDENTIAL_CHANGE,
 | 
					 | 
				
			||||||
        data,
 | 
					 | 
				
			||||||
        sub_id={
 | 
					 | 
				
			||||||
            "format": "complex",
 | 
					 | 
				
			||||||
            "user": {
 | 
					 | 
				
			||||||
                "format": "email",
 | 
					 | 
				
			||||||
                "email": instance.user.email,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@receiver(post_delete)
 | 
					 | 
				
			||||||
def ssf_device_post_delete(sender: type[Model], instance: Device, **_):
 | 
					 | 
				
			||||||
    if not isinstance(instance, Device):
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    if not instance.confirmed:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    device_type = device_type_map.get(instance.__class__)
 | 
					 | 
				
			||||||
    data = {
 | 
					 | 
				
			||||||
        "credential_type": device_type,
 | 
					 | 
				
			||||||
        "change_type": "delete",
 | 
					 | 
				
			||||||
        "friendly_name": instance.name,
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if isinstance(instance, WebAuthnDevice) and instance.aaguid != UNKNOWN_DEVICE_TYPE_AAGUID:
 | 
					 | 
				
			||||||
        data["fido2_aaguid"] = instance.aaguid
 | 
					 | 
				
			||||||
    send_ssf_event(
 | 
					 | 
				
			||||||
        EventTypes.CAEP_CREDENTIAL_CHANGE,
 | 
					 | 
				
			||||||
        data,
 | 
					 | 
				
			||||||
        sub_id={
 | 
					 | 
				
			||||||
            "format": "complex",
 | 
					 | 
				
			||||||
            "user": {
 | 
					 | 
				
			||||||
                "format": "email",
 | 
					 | 
				
			||||||
                "email": instance.user.email,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
@ -1,136 +0,0 @@
 | 
				
			|||||||
from celery import group
 | 
					 | 
				
			||||||
from django.http import HttpRequest
 | 
					 | 
				
			||||||
from django.utils.timezone import now
 | 
					 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					 | 
				
			||||||
from requests.exceptions import RequestException
 | 
					 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.core.models import User
 | 
					 | 
				
			||||||
from authentik.enterprise.providers.ssf.models import (
 | 
					 | 
				
			||||||
    DeliveryMethods,
 | 
					 | 
				
			||||||
    EventTypes,
 | 
					 | 
				
			||||||
    SSFEventStatus,
 | 
					 | 
				
			||||||
    Stream,
 | 
					 | 
				
			||||||
    StreamEvent,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
from authentik.events.logs import LogEvent
 | 
					 | 
				
			||||||
from authentik.events.models import TaskStatus
 | 
					 | 
				
			||||||
from authentik.events.system_tasks import SystemTask
 | 
					 | 
				
			||||||
from authentik.lib.utils.http import get_http_session
 | 
					 | 
				
			||||||
from authentik.lib.utils.time import timedelta_from_string
 | 
					 | 
				
			||||||
from authentik.policies.engine import PolicyEngine
 | 
					 | 
				
			||||||
from authentik.root.celery import CELERY_APP
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
session = get_http_session()
 | 
					 | 
				
			||||||
LOGGER = get_logger()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def send_ssf_event(
 | 
					 | 
				
			||||||
    event_type: EventTypes,
 | 
					 | 
				
			||||||
    data: dict,
 | 
					 | 
				
			||||||
    stream_filter: dict | None = None,
 | 
					 | 
				
			||||||
    request: HttpRequest | None = None,
 | 
					 | 
				
			||||||
    **extra_data,
 | 
					 | 
				
			||||||
):
 | 
					 | 
				
			||||||
    """Wrapper to send an SSF event to multiple streams"""
 | 
					 | 
				
			||||||
    payload = []
 | 
					 | 
				
			||||||
    if not stream_filter:
 | 
					 | 
				
			||||||
        stream_filter = {}
 | 
					 | 
				
			||||||
    stream_filter["events_requested__contains"] = [event_type]
 | 
					 | 
				
			||||||
    if request and hasattr(request, "request_id"):
 | 
					 | 
				
			||||||
        extra_data.setdefault("txn", request.request_id)
 | 
					 | 
				
			||||||
    for stream in Stream.objects.filter(**stream_filter):
 | 
					 | 
				
			||||||
        event_data = stream.prepare_event_payload(event_type, data, **extra_data)
 | 
					 | 
				
			||||||
        payload.append((str(stream.uuid), event_data))
 | 
					 | 
				
			||||||
    return _send_ssf_event.delay(payload)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def _check_app_access(stream_uuid: str, event_data: dict) -> bool:
 | 
					 | 
				
			||||||
    """Check if event is related to user and if so, check
 | 
					 | 
				
			||||||
    if the user has access to the application"""
 | 
					 | 
				
			||||||
    stream = Stream.objects.filter(pk=stream_uuid).first()
 | 
					 | 
				
			||||||
    if not stream:
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
    # `event_data` is a dict version of a StreamEvent
 | 
					 | 
				
			||||||
    sub_id = event_data.get("payload", {}).get("sub_id", {})
 | 
					 | 
				
			||||||
    email = sub_id.get("user", {}).get("email", None)
 | 
					 | 
				
			||||||
    if not email:
 | 
					 | 
				
			||||||
        return True
 | 
					 | 
				
			||||||
    user = User.objects.filter(email=email).first()
 | 
					 | 
				
			||||||
    if not user:
 | 
					 | 
				
			||||||
        return True
 | 
					 | 
				
			||||||
    engine = PolicyEngine(stream.provider.backchannel_application, user)
 | 
					 | 
				
			||||||
    engine.use_cache = False
 | 
					 | 
				
			||||||
    engine.build()
 | 
					 | 
				
			||||||
    return engine.passing
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@CELERY_APP.task()
 | 
					 | 
				
			||||||
def _send_ssf_event(event_data: list[tuple[str, dict]]):
 | 
					 | 
				
			||||||
    tasks = []
 | 
					 | 
				
			||||||
    for stream, data in event_data:
 | 
					 | 
				
			||||||
        if not _check_app_access(stream, data):
 | 
					 | 
				
			||||||
            continue
 | 
					 | 
				
			||||||
        event = StreamEvent.objects.create(**data)
 | 
					 | 
				
			||||||
        tasks.extend(send_single_ssf_event(stream, str(event.uuid)))
 | 
					 | 
				
			||||||
    main_task = group(*tasks)
 | 
					 | 
				
			||||||
    main_task()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def send_single_ssf_event(stream_id: str, evt_id: str):
 | 
					 | 
				
			||||||
    stream = Stream.objects.filter(pk=stream_id).first()
 | 
					 | 
				
			||||||
    if not stream:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    event = StreamEvent.objects.filter(pk=evt_id).first()
 | 
					 | 
				
			||||||
    if not event:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    if event.status == SSFEventStatus.SENT:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    if stream.delivery_method == DeliveryMethods.RISC_PUSH:
 | 
					 | 
				
			||||||
        return [ssf_push_event.si(str(event.pk))]
 | 
					 | 
				
			||||||
    return []
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@CELERY_APP.task(bind=True, base=SystemTask)
 | 
					 | 
				
			||||||
def ssf_push_event(self: SystemTask, event_id: str):
 | 
					 | 
				
			||||||
    self.save_on_success = False
 | 
					 | 
				
			||||||
    event = StreamEvent.objects.filter(pk=event_id).first()
 | 
					 | 
				
			||||||
    if not event:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    self.set_uid(event_id)
 | 
					 | 
				
			||||||
    if event.status == SSFEventStatus.SENT:
 | 
					 | 
				
			||||||
        self.set_status(TaskStatus.SUCCESSFUL)
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        response = session.post(
 | 
					 | 
				
			||||||
            event.stream.endpoint_url,
 | 
					 | 
				
			||||||
            data=event.stream.encode(event.payload),
 | 
					 | 
				
			||||||
            headers={"Content-Type": "application/secevent+jwt", "Accept": "application/json"},
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        response.raise_for_status()
 | 
					 | 
				
			||||||
        event.status = SSFEventStatus.SENT
 | 
					 | 
				
			||||||
        event.save()
 | 
					 | 
				
			||||||
        self.set_status(TaskStatus.SUCCESSFUL)
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    except RequestException as exc:
 | 
					 | 
				
			||||||
        LOGGER.warning("Failed to send SSF event", exc=exc)
 | 
					 | 
				
			||||||
        self.set_status(TaskStatus.ERROR)
 | 
					 | 
				
			||||||
        attrs = {}
 | 
					 | 
				
			||||||
        if exc.response:
 | 
					 | 
				
			||||||
            attrs["response"] = {
 | 
					 | 
				
			||||||
                "content": exc.response.text,
 | 
					 | 
				
			||||||
                "status": exc.response.status_code,
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        self.set_error(
 | 
					 | 
				
			||||||
            exc,
 | 
					 | 
				
			||||||
            LogEvent(
 | 
					 | 
				
			||||||
                _("Failed to send request"),
 | 
					 | 
				
			||||||
                log_level="warning",
 | 
					 | 
				
			||||||
                logger=self.__name__,
 | 
					 | 
				
			||||||
                attributes=attrs,
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Re-up the expiry of the stream event
 | 
					 | 
				
			||||||
        event.expires = now() + timedelta_from_string(event.stream.provider.event_retention)
 | 
					 | 
				
			||||||
        event.status = SSFEventStatus.PENDING_FAILED
 | 
					 | 
				
			||||||
        event.save()
 | 
					 | 
				
			||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user