Compare commits
	
		
			30 Commits
		
	
	
		
			web/testin
			...
			web/config
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 79da411f10 | |||
| ce761c4337 | |||
| 0d3025794c | |||
| 79601f6d66 | |||
| 1ec0623ab6 | |||
| 4bf151cfc2 | |||
| 6752d19375 | |||
| 284c2327c6 | |||
| 600c3caa62 | |||
| 366d48eddb | |||
| e67a290b73 | |||
| 4456f085d3 | |||
| 53e982594e | |||
| def988c3b1 | |||
| e164661321 | |||
| 849fea6e91 | |||
| 24278d0781 | |||
| 8c6f83b88e | |||
| fc80596432 | |||
| 03fde51313 | |||
| f669222529 | |||
| 297c29b231 | |||
| 21b50838db | |||
| d2a9b2a343 | |||
| c52fa631b4 | |||
| 6cf2de8a7c | |||
| d4b80c17e8 | |||
| 828b8a83ea | |||
| 115e2f3dcb | |||
| 6228931305 | 
| @ -1,30 +1,18 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2024.6.1 | current_version = 2023.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+) | ||||||
| serialize =  | serialize = {major}.{minor}.{patch} | ||||||
| 	{major}.{minor}.{patch}-{rc_t}{rc_n} |  | ||||||
| 	{major}.{minor}.{patch} |  | ||||||
| message = release: {new_version} | message = release: {new_version} | ||||||
| tag_name = version/{new_version} | tag_name = version/{new_version} | ||||||
|  |  | ||||||
| [bumpversion:part:rc_t] |  | ||||||
| values =  |  | ||||||
| 	rc |  | ||||||
| 	final |  | ||||||
| optional_value = final |  | ||||||
|  |  | ||||||
| [bumpversion:file:pyproject.toml] | [bumpversion:file:pyproject.toml] | ||||||
|  |  | ||||||
| [bumpversion:file:package.json] |  | ||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [bumpversion:file:docker-compose.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:schema.yml] | [bumpversion:file:schema.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:blueprints/schema.json] |  | ||||||
|  |  | ||||||
| [bumpversion:file:authentik/__init__.py] | [bumpversion:file:authentik/__init__.py] | ||||||
|  |  | ||||||
| [bumpversion:file:internal/constants/constants.go] | [bumpversion:file:internal/constants/constants.go] | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							| @ -1 +1 @@ | |||||||
| custom: https://goauthentik.io/pricing/ | github: [BeryJu] | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
								
							| @ -9,7 +9,7 @@ assignees: "" | |||||||
| **Describe your question/** | **Describe your question/** | ||||||
| A clear and concise description of what you're trying to do. | A clear and concise description of what you're trying to do. | ||||||
|  |  | ||||||
| **Relevant info** | **Relevant infos** | ||||||
| i.e. Version of other software you're using, specifics of your setup | i.e. Version of other software you're using, specifics of your setup | ||||||
|  |  | ||||||
| **Screenshots** | **Screenshots** | ||||||
|  | |||||||
| @ -9,6 +9,9 @@ inputs: | |||||||
| runs: | runs: | ||||||
|   using: "composite" |   using: "composite" | ||||||
|   steps: |   steps: | ||||||
|  |     - name: Generate config | ||||||
|  |       id: ev | ||||||
|  |       uses: ./.github/actions/docker-push-variables | ||||||
|     - name: Find Comment |     - name: Find Comment | ||||||
|       uses: peter-evans/find-comment@v2 |       uses: peter-evans/find-comment@v2 | ||||||
|       id: fc |       id: fc | ||||||
| @ -54,10 +57,9 @@ runs: | |||||||
|             authentik: |             authentik: | ||||||
|                 outposts: |                 outposts: | ||||||
|                     container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s |                     container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s | ||||||
|             global: |             image: | ||||||
|                 image: |                 repository: ghcr.io/goauthentik/dev-server | ||||||
|                     repository: ghcr.io/goauthentik/dev-server |                 tag: ${{ inputs.tag }} | ||||||
|                     tag: ${{ inputs.tag }} |  | ||||||
|             ``` |             ``` | ||||||
|  |  | ||||||
|             For arm64, use these values: |             For arm64, use these values: | ||||||
| @ -66,10 +68,9 @@ runs: | |||||||
|             authentik: |             authentik: | ||||||
|                 outposts: |                 outposts: | ||||||
|                     container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s |                     container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s | ||||||
|             global: |             image: | ||||||
|                 image: |                 repository: ghcr.io/goauthentik/dev-server | ||||||
|                     repository: ghcr.io/goauthentik/dev-server |                 tag: ${{ inputs.tag }}-arm64 | ||||||
|                     tag: ${{ inputs.tag }}-arm64 |  | ||||||
|             ``` |             ``` | ||||||
|  |  | ||||||
|             Afterwards, run the upgrade commands from the latest release notes. |             Afterwards, run the upgrade commands from the latest release notes. | ||||||
|  | |||||||
							
								
								
									
										73
									
								
								.github/actions/docker-push-variables/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										73
									
								
								.github/actions/docker-push-variables/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,47 +1,64 @@ | |||||||
| --- |  | ||||||
| name: "Prepare docker environment variables" | name: "Prepare docker environment variables" | ||||||
| description: "Prepare docker environment variables" | description: "Prepare docker environment variables" | ||||||
|  |  | ||||||
| inputs: |  | ||||||
|   image-name: |  | ||||||
|     required: true |  | ||||||
|     description: "Docker image prefix" |  | ||||||
|   image-arch: |  | ||||||
|     required: false |  | ||||||
|     description: "Docker image arch" |  | ||||||
|  |  | ||||||
| outputs: | outputs: | ||||||
|   shouldBuild: |   shouldBuild: | ||||||
|     description: "Whether to build image or not" |     description: "Whether to build image or not" | ||||||
|     value: ${{ steps.ev.outputs.shouldBuild }} |     value: ${{ steps.ev.outputs.shouldBuild }} | ||||||
|  |   branchName: | ||||||
|  |     description: "Branch name" | ||||||
|  |     value: ${{ steps.ev.outputs.branchName }} | ||||||
|  |   branchNameContainer: | ||||||
|  |     description: "Branch name (for containers)" | ||||||
|  |     value: ${{ steps.ev.outputs.branchNameContainer }} | ||||||
|  |   timestamp: | ||||||
|  |     description: "Timestamp" | ||||||
|  |     value: ${{ steps.ev.outputs.timestamp }} | ||||||
|   sha: |   sha: | ||||||
|     description: "sha" |     description: "sha" | ||||||
|     value: ${{ steps.ev.outputs.sha }} |     value: ${{ steps.ev.outputs.sha }} | ||||||
|  |   shortHash: | ||||||
|  |     description: "shortHash" | ||||||
|  |     value: ${{ steps.ev.outputs.shortHash }} | ||||||
|   version: |   version: | ||||||
|     description: "Version" |     description: "version" | ||||||
|     value: ${{ steps.ev.outputs.version }} |     value: ${{ steps.ev.outputs.version }} | ||||||
|   prerelease: |   versionFamily: | ||||||
|     description: "Prerelease" |     description: "versionFamily" | ||||||
|     value: ${{ steps.ev.outputs.prerelease }} |     value: ${{ steps.ev.outputs.versionFamily }} | ||||||
|  |  | ||||||
|   imageTags: |  | ||||||
|     description: "Docker image tags" |  | ||||||
|     value: ${{ steps.ev.outputs.imageTags }} |  | ||||||
|   imageMainTag: |  | ||||||
|     description: "Docker image main tag" |  | ||||||
|     value: ${{ steps.ev.outputs.imageMainTag }} |  | ||||||
|  |  | ||||||
| runs: | runs: | ||||||
|   using: "composite" |   using: "composite" | ||||||
|   steps: |   steps: | ||||||
|     - name: Generate config |     - name: Generate config | ||||||
|       id: ev |       id: ev | ||||||
|       shell: bash |       shell: python | ||||||
|       env: |  | ||||||
|         IMAGE_NAME: ${{ inputs.image-name }} |  | ||||||
|         IMAGE_ARCH: ${{ inputs.image-arch }} |  | ||||||
|         PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} |  | ||||||
|       run: | |       run: | | ||||||
|         python3 ${{ github.action_path }}/push_vars.py |         """Helper script to get the actual branch name, docker safe""" | ||||||
|  |         import configparser | ||||||
|  |         import os | ||||||
|  |         from time import time | ||||||
|  |  | ||||||
|  |         parser = configparser.ConfigParser() | ||||||
|  |         parser.read(".bumpversion.cfg") | ||||||
|  |  | ||||||
|  |         branch_name = os.environ["GITHUB_REF"] | ||||||
|  |         if os.environ.get("GITHUB_HEAD_REF", "") != "": | ||||||
|  |             branch_name = os.environ["GITHUB_HEAD_REF"] | ||||||
|  |  | ||||||
|  |         should_build = str(os.environ.get("DOCKER_USERNAME", "") != "").lower() | ||||||
|  |         version = parser.get("bumpversion", "current_version") | ||||||
|  |         version_family = ".".join(version.split(".")[:-1]) | ||||||
|  |         safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-") | ||||||
|  |  | ||||||
|  |         sha = os.environ["GITHUB_SHA"] if not "${{ github.event.pull_request.head.sha }}" else "${{ github.event.pull_request.head.sha }}" | ||||||
|  |  | ||||||
|  |         with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output: | ||||||
|  |             print("branchName=%s" % branch_name, file=_output) | ||||||
|  |             print("branchNameContainer=%s" % safe_branch_name, file=_output) | ||||||
|  |             print("timestamp=%s" % int(time()), file=_output) | ||||||
|  |             print("sha=%s" % sha, file=_output) | ||||||
|  |             print("shortHash=%s" % sha[:7], file=_output) | ||||||
|  |             print("shouldBuild=%s" % should_build, file=_output) | ||||||
|  |             print("version=%s" % version, file=_output) | ||||||
|  |             print("versionFamily=%s" % version_family, file=_output) | ||||||
|  | |||||||
| @ -1,62 +0,0 @@ | |||||||
| """Helper script to get the actual branch name, docker safe""" |  | ||||||
|  |  | ||||||
| import configparser |  | ||||||
| import os |  | ||||||
| from time import time |  | ||||||
|  |  | ||||||
| parser = configparser.ConfigParser() |  | ||||||
| parser.read(".bumpversion.cfg") |  | ||||||
|  |  | ||||||
| should_build = str(os.environ.get("DOCKER_USERNAME", None) is not None).lower() |  | ||||||
|  |  | ||||||
| branch_name = os.environ["GITHUB_REF"] |  | ||||||
| if os.environ.get("GITHUB_HEAD_REF", "") != "": |  | ||||||
|     branch_name = os.environ["GITHUB_HEAD_REF"] |  | ||||||
| safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-").replace("'", "-") |  | ||||||
|  |  | ||||||
| image_names = os.getenv("IMAGE_NAME").split(",") |  | ||||||
| image_arch = os.getenv("IMAGE_ARCH") or None |  | ||||||
|  |  | ||||||
| is_pull_request = bool(os.getenv("PR_HEAD_SHA")) |  | ||||||
| is_release = "dev" not in image_names[0] |  | ||||||
|  |  | ||||||
| sha = os.environ["GITHUB_SHA"] if not is_pull_request else os.getenv("PR_HEAD_SHA") |  | ||||||
|  |  | ||||||
| # 2042.1.0 or 2042.1.0-rc1 |  | ||||||
| version = parser.get("bumpversion", "current_version") |  | ||||||
| # 2042.1 |  | ||||||
| version_family = ".".join(version.split("-", 1)[0].split(".")[:-1]) |  | ||||||
| prerelease = "-" in version |  | ||||||
|  |  | ||||||
| image_tags = [] |  | ||||||
| if is_release: |  | ||||||
|     for name in image_names: |  | ||||||
|         image_tags += [ |  | ||||||
|             f"{name}:{version}", |  | ||||||
|         ] |  | ||||||
|         if not prerelease: |  | ||||||
|             image_tags += [ |  | ||||||
|                 f"{name}:latest", |  | ||||||
|                 f"{name}:{version_family}", |  | ||||||
|             ] |  | ||||||
| else: |  | ||||||
|     suffix = "" |  | ||||||
|     if image_arch and image_arch != "amd64": |  | ||||||
|         suffix = f"-{image_arch}" |  | ||||||
|     for name in image_names: |  | ||||||
|         image_tags += [ |  | ||||||
|             f"{name}:gh-{sha}{suffix}",  # Used for ArgoCD and PR comments |  | ||||||
|             f"{name}:gh-{safe_branch_name}{suffix}",  # For convenience |  | ||||||
|             f"{name}:gh-{safe_branch_name}-{int(time())}-{sha[:7]}{suffix}",  # Use by FluxCD |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
| image_main_tag = image_tags[0] |  | ||||||
| image_tags_rendered = ",".join(image_tags) |  | ||||||
|  |  | ||||||
| with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output: |  | ||||||
|     print(f"shouldBuild={should_build}", file=_output) |  | ||||||
|     print(f"sha={sha}", file=_output) |  | ||||||
|     print(f"version={version}", file=_output) |  | ||||||
|     print(f"prerelease={prerelease}", file=_output) |  | ||||||
|     print(f"imageTags={image_tags_rendered}", file=_output) |  | ||||||
|     print(f"imageMainTag={image_main_tag}", file=_output) |  | ||||||
| @ -1,7 +0,0 @@ | |||||||
| #!/bin/bash -x |  | ||||||
| SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) |  | ||||||
| GITHUB_OUTPUT=/dev/stdout \ |  | ||||||
|     GITHUB_REF=ref \ |  | ||||||
|     GITHUB_SHA=sha \ |  | ||||||
|     IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \ |  | ||||||
|     python $SCRIPT_DIR/push_vars.py |  | ||||||
							
								
								
									
										12
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -4,7 +4,7 @@ description: "Setup authentik testing environment" | |||||||
| inputs: | inputs: | ||||||
|   postgresql_version: |   postgresql_version: | ||||||
|     description: "Optional postgresql image tag" |     description: "Optional postgresql image tag" | ||||||
|     default: "16" |     default: "12" | ||||||
|  |  | ||||||
| runs: | runs: | ||||||
|   using: "composite" |   using: "composite" | ||||||
| @ -16,25 +16,25 @@ runs: | |||||||
|         sudo apt-get update |         sudo apt-get update | ||||||
|         sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext |         sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext | ||||||
|     - name: Setup python and restore poetry |     - name: Setup python and restore poetry | ||||||
|       uses: actions/setup-python@v5 |       uses: actions/setup-python@v4 | ||||||
|       with: |       with: | ||||||
|         python-version-file: "pyproject.toml" |         python-version-file: 'pyproject.toml' | ||||||
|         cache: "poetry" |         cache: "poetry" | ||||||
|     - name: Setup node |     - name: Setup node | ||||||
|       uses: actions/setup-node@v4 |       uses: actions/setup-node@v3 | ||||||
|       with: |       with: | ||||||
|         node-version-file: web/package.json |         node-version-file: web/package.json | ||||||
|         cache: "npm" |         cache: "npm" | ||||||
|         cache-dependency-path: web/package-lock.json |         cache-dependency-path: web/package-lock.json | ||||||
|     - name: Setup go |     - name: Setup go | ||||||
|       uses: actions/setup-go@v5 |       uses: actions/setup-go@v4 | ||||||
|       with: |       with: | ||||||
|         go-version-file: "go.mod" |         go-version-file: "go.mod" | ||||||
|     - name: Setup dependencies |     - name: Setup dependencies | ||||||
|       shell: bash |       shell: bash | ||||||
|       run: | |       run: | | ||||||
|         export PSQL_TAG=${{ inputs.postgresql_version }} |         export PSQL_TAG=${{ inputs.postgresql_version }} | ||||||
|         docker compose -f .github/actions/setup/docker-compose.yml up -d |         docker-compose -f .github/actions/setup/docker-compose.yml up -d | ||||||
|         poetry install |         poetry install | ||||||
|         cd web && npm ci |         cd web && npm ci | ||||||
|     - name: Generate config |     - name: Generate config | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/actions/setup/docker-compose.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/actions/setup/docker-compose.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,6 +1,8 @@ | |||||||
|  | version: "3.7" | ||||||
|  |  | ||||||
| services: | services: | ||||||
|   postgresql: |   postgresql: | ||||||
|     image: docker.io/library/postgres:${PSQL_TAG:-16} |     image: docker.io/library/postgres:${PSQL_TAG:-12} | ||||||
|     volumes: |     volumes: | ||||||
|       - db-data:/var/lib/postgresql/data |       - db-data:/var/lib/postgresql/data | ||||||
|     environment: |     environment: | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/codespell-words.txt
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/codespell-words.txt
									
									
									
									
										vendored
									
									
								
							| @ -3,5 +3,3 @@ keypairs | |||||||
| hass | hass | ||||||
| warmup | warmup | ||||||
| ontext | ontext | ||||||
| singed |  | ||||||
| assertIn |  | ||||||
|  | |||||||
							
								
								
									
										40
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										40
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -21,10 +21,7 @@ updates: | |||||||
|     labels: |     labels: | ||||||
|       - dependencies |       - dependencies | ||||||
|   - package-ecosystem: npm |   - package-ecosystem: npm | ||||||
|     directories: |     directory: "/web" | ||||||
|       - "/web" |  | ||||||
|       - "/tests/wdio" |  | ||||||
|       - "/web/sfe" |  | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
|       time: "04:00" |       time: "04:00" | ||||||
| @ -33,11 +30,11 @@ updates: | |||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|     commit-message: |     commit-message: | ||||||
|       prefix: "web:" |       prefix: "web:" | ||||||
|  |     # TODO: deduplicate these groups | ||||||
|     groups: |     groups: | ||||||
|       sentry: |       sentry: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@sentry/*" |           - "@sentry/*" | ||||||
|           - "@spotlightjs/*" |  | ||||||
|       babel: |       babel: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@babel/*" |           - "@babel/*" | ||||||
| @ -54,10 +51,37 @@ updates: | |||||||
|       esbuild: |       esbuild: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@esbuild/*" |           - "@esbuild/*" | ||||||
|       rollup: |   - package-ecosystem: npm | ||||||
|  |     directory: "/tests/wdio" | ||||||
|  |     schedule: | ||||||
|  |       interval: daily | ||||||
|  |       time: "04:00" | ||||||
|  |     labels: | ||||||
|  |       - dependencies | ||||||
|  |     open-pull-requests-limit: 10 | ||||||
|  |     commit-message: | ||||||
|  |       prefix: "web:" | ||||||
|  |     # TODO: deduplicate these groups | ||||||
|  |     groups: | ||||||
|  |       sentry: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@rollup/*" |           - "@sentry/*" | ||||||
|           - "rollup-*" |       babel: | ||||||
|  |         patterns: | ||||||
|  |           - "@babel/*" | ||||||
|  |           - "babel-*" | ||||||
|  |       eslint: | ||||||
|  |         patterns: | ||||||
|  |           - "@typescript-eslint/*" | ||||||
|  |           - "eslint" | ||||||
|  |           - "eslint-*" | ||||||
|  |       storybook: | ||||||
|  |         patterns: | ||||||
|  |           - "@storybook/*" | ||||||
|  |           - "*storybook*" | ||||||
|  |       esbuild: | ||||||
|  |         patterns: | ||||||
|  |           - "@esbuild/*" | ||||||
|       wdio: |       wdio: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@wdio/*" |           - "@wdio/*" | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/pull_request_template.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/pull_request_template.md
									
									
									
									
										vendored
									
									
								
							| @ -27,6 +27,7 @@ If an API change has been made | |||||||
| If changes to the frontend have been made | If changes to the frontend have been made | ||||||
|  |  | ||||||
| -   [ ] The code has been formatted (`make web`) | -   [ ] The code has been formatted (`make web`) | ||||||
|  | -   [ ] The translation files have been updated (`make i18n-extract`) | ||||||
|  |  | ||||||
| If applicable | If applicable | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										65
									
								
								.github/workflows/api-py-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										65
									
								
								.github/workflows/api-py-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,65 +0,0 @@ | |||||||
| name: authentik-api-py-publish |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|     branches: [main] |  | ||||||
|     paths: |  | ||||||
|       - "schema.yml" |  | ||||||
|   workflow_dispatch: |  | ||||||
| jobs: |  | ||||||
|   build: |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     permissions: |  | ||||||
|       id-token: write |  | ||||||
|     steps: |  | ||||||
|       - id: generate_token |  | ||||||
|         uses: tibdex/github-app-token@v2 |  | ||||||
|         with: |  | ||||||
|           app_id: ${{ secrets.GH_APP_ID }} |  | ||||||
|           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} |  | ||||||
|       - uses: actions/checkout@v4 |  | ||||||
|         with: |  | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |  | ||||||
|       - name: Install poetry & deps |  | ||||||
|         shell: bash |  | ||||||
|         run: | |  | ||||||
|           pipx install poetry || true |  | ||||||
|           sudo apt-get update |  | ||||||
|           sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext |  | ||||||
|       - name: Setup python and restore poetry |  | ||||||
|         uses: actions/setup-python@v5 |  | ||||||
|         with: |  | ||||||
|           python-version-file: "pyproject.toml" |  | ||||||
|           cache: "poetry" |  | ||||||
|       - name: Generate API Client |  | ||||||
|         run: make gen-client-py |  | ||||||
|       - name: Publish package |  | ||||||
|         working-directory: gen-py-api/ |  | ||||||
|         run: | |  | ||||||
|           poetry build |  | ||||||
|       - name: Publish package to PyPI |  | ||||||
|         uses: pypa/gh-action-pypi-publish@release/v1 |  | ||||||
|         with: |  | ||||||
|           packages-dir: gen-py-api/dist/ |  | ||||||
|       # We can't easily upgrade the API client being used due to poetry being poetry |  | ||||||
|       # so we'll have to rely on dependabot |  | ||||||
|       # - name: Upgrade / |  | ||||||
|       #   run: | |  | ||||||
|       #     export VERSION=$(cd gen-py-api && poetry version -s) |  | ||||||
|       #     poetry add "authentik_client=$VERSION" --allow-prereleases --lock |  | ||||||
|       # - uses: peter-evans/create-pull-request@v6 |  | ||||||
|       #   id: cpr |  | ||||||
|       #   with: |  | ||||||
|       #     token: ${{ steps.generate_token.outputs.token }} |  | ||||||
|       #     branch: update-root-api-client |  | ||||||
|       #     commit-message: "root: bump API Client version" |  | ||||||
|       #     title: "root: bump API Client version" |  | ||||||
|       #     body: "root: bump API Client version" |  | ||||||
|       #     delete-branch: true |  | ||||||
|       #     signoff: true |  | ||||||
|       #     # ID from https://api.github.com/users/authentik-automation[bot] |  | ||||||
|       #     author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com> |  | ||||||
|       # - uses: peter-evans/enable-pull-request-automerge@v3 |  | ||||||
|       #   with: |  | ||||||
|       #     token: ${{ steps.generate_token.outputs.token }} |  | ||||||
|       #     pull-request-number: ${{ steps.cpr.outputs.pull-request-number }} |  | ||||||
|       #     merge-method: squash |  | ||||||
							
								
								
									
										107
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										107
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,3 @@ | |||||||
| --- |  | ||||||
| name: authentik-ci-main | name: authentik-ci-main | ||||||
|  |  | ||||||
| on: | on: | ||||||
| @ -7,6 +6,8 @@ on: | |||||||
|       - main |       - main | ||||||
|       - next |       - next | ||||||
|       - version-* |       - version-* | ||||||
|  |     paths-ignore: | ||||||
|  |       - website | ||||||
|   pull_request: |   pull_request: | ||||||
|     branches: |     branches: | ||||||
|       - main |       - main | ||||||
| @ -26,7 +27,10 @@ jobs: | |||||||
|           - bandit |           - bandit | ||||||
|           - black |           - black | ||||||
|           - codespell |           - codespell | ||||||
|  |           - isort | ||||||
|           - pending-migrations |           - pending-migrations | ||||||
|  |           - pylint | ||||||
|  |           - pyright | ||||||
|           - ruff |           - ruff | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
| @ -50,6 +54,7 @@ jobs: | |||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         psql: |         psql: | ||||||
|  |           - 12-alpine | ||||||
|           - 15-alpine |           - 15-alpine | ||||||
|           - 16-alpine |           - 16-alpine | ||||||
|     steps: |     steps: | ||||||
| @ -64,7 +69,7 @@ jobs: | |||||||
|           cp authentik/lib/default.yml local.env.yml |           cp authentik/lib/default.yml local.env.yml | ||||||
|           cp -R .github .. |           cp -R .github .. | ||||||
|           cp -R scripts .. |           cp -R scripts .. | ||||||
|           git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1) |           git checkout version/$(python -c "from authentik import __version__; print(__version__)") | ||||||
|           rm -rf .github/ scripts/ |           rm -rf .github/ scripts/ | ||||||
|           mv ../.github ../scripts . |           mv ../.github ../scripts . | ||||||
|       - name: Setup authentik env (stable) |       - name: Setup authentik env (stable) | ||||||
| @ -103,6 +108,7 @@ jobs: | |||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         psql: |         psql: | ||||||
|  |           - 12-alpine | ||||||
|           - 15-alpine |           - 15-alpine | ||||||
|           - 16-alpine |           - 16-alpine | ||||||
|     steps: |     steps: | ||||||
| @ -116,10 +122,9 @@ jobs: | |||||||
|           poetry run make test |           poetry run make test | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v4 |         uses: codecov/codecov-action@v3 | ||||||
|         with: |         with: | ||||||
|           flags: unit |           flags: unit | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |  | ||||||
|   test-integration: |   test-integration: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     timeout-minutes: 30 |     timeout-minutes: 30 | ||||||
| @ -128,16 +133,15 @@ 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.10.0 |         uses: helm/kind-action@v1.8.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 | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v4 |         uses: codecov/codecov-action@v3 | ||||||
|         with: |         with: | ||||||
|           flags: integration |           flags: integration | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |  | ||||||
|   test-e2e: |   test-e2e: | ||||||
|     name: test-e2e (${{ matrix.job.name }}) |     name: test-e2e (${{ matrix.job.name }}) | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
| @ -158,8 +162,6 @@ jobs: | |||||||
|             glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* |             glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* | ||||||
|           - name: radius |           - name: radius | ||||||
|             glob: tests/e2e/test_provider_radius* |             glob: tests/e2e/test_provider_radius* | ||||||
|           - name: scim |  | ||||||
|             glob: tests/e2e/test_source_scim* |  | ||||||
|           - name: flows |           - name: flows | ||||||
|             glob: tests/e2e/test_flows* |             glob: tests/e2e/test_flows* | ||||||
|     steps: |     steps: | ||||||
| @ -168,9 +170,9 @@ jobs: | |||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - name: Setup e2e env (chrome, etc) |       - name: Setup e2e env (chrome, etc) | ||||||
|         run: | |         run: | | ||||||
|           docker compose -f tests/e2e/docker-compose.yml up -d |           docker-compose -f tests/e2e/docker-compose.yml up -d | ||||||
|       - id: cache-web |       - id: cache-web | ||||||
|         uses: actions/cache@v4 |         uses: actions/cache@v3 | ||||||
|         with: |         with: | ||||||
|           path: web/dist |           path: web/dist | ||||||
|           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }} |           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }} | ||||||
| @ -186,10 +188,9 @@ jobs: | |||||||
|           poetry run coverage run manage.py test ${{ matrix.job.glob }} |           poetry run coverage run manage.py test ${{ matrix.job.glob }} | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v4 |         uses: codecov/codecov-action@v3 | ||||||
|         with: |         with: | ||||||
|           flags: e2e |           flags: e2e | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |  | ||||||
|   ci-core-mark: |   ci-core-mark: | ||||||
|     needs: |     needs: | ||||||
|       - lint |       - lint | ||||||
| @ -202,12 +203,6 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - run: echo mark |       - run: echo mark | ||||||
|   build: |   build: | ||||||
|     strategy: |  | ||||||
|       fail-fast: false |  | ||||||
|       matrix: |  | ||||||
|         arch: |  | ||||||
|           - amd64 |  | ||||||
|           - arm64 |  | ||||||
|     needs: ci-core-mark |     needs: ci-core-mark | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     permissions: |     permissions: | ||||||
| @ -219,7 +214,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.1.0 |         uses: docker/setup-qemu-action@v3.0.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 | ||||||
| @ -227,12 +222,9 @@ jobs: | |||||||
|         id: ev |         id: ev | ||||||
|         env: |         env: | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|         with: |  | ||||||
|           image-name: ghcr.io/goauthentik/dev-server |  | ||||||
|           image-arch: ${{ matrix.arch }} |  | ||||||
|       - name: Login to Container Registry |       - name: Login to Container Registry | ||||||
|         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} |  | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|  |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|         with: |         with: | ||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
| @ -240,22 +232,75 @@ jobs: | |||||||
|       - name: generate ts client |       - name: generate ts client | ||||||
|         run: make gen-client-ts |         run: make gen-client-ts | ||||||
|       - name: Build Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v6 |         uses: docker/build-push-action@v5 | ||||||
|         with: |         with: | ||||||
|           context: . |           context: . | ||||||
|           secrets: | |           secrets: | | ||||||
|             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} |             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} | ||||||
|             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} |             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} | ||||||
|           tags: ${{ steps.ev.outputs.imageTags }} |  | ||||||
|           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|  |           tags: | | ||||||
|  |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }} | ||||||
|  |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.sha }} | ||||||
|  |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }} | ||||||
|           build-args: | |           build-args: | | ||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache |             VERSION=${{ steps.ev.outputs.version }} | ||||||
|           cache-to: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|           platforms: linux/${{ matrix.arch }} |           cache-from: type=gha | ||||||
|  |           cache-to: type=gha,mode=max | ||||||
|  |   build-arm64: | ||||||
|  |     needs: ci-core-mark | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     permissions: | ||||||
|  |       # Needed to upload contianer images to ghcr.io | ||||||
|  |       packages: write | ||||||
|  |     timeout-minutes: 120 | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |         with: | ||||||
|  |           ref: ${{ github.event.pull_request.head.sha }} | ||||||
|  |       - name: Set up QEMU | ||||||
|  |         uses: docker/setup-qemu-action@v3.0.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 }} | ||||||
|  |       - name: Login to Container Registry | ||||||
|  |         uses: docker/login-action@v3 | ||||||
|  |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|  |         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@v5 | ||||||
|  |         with: | ||||||
|  |           context: . | ||||||
|  |           secrets: | | ||||||
|  |             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} | ||||||
|  |             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} | ||||||
|  |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|  |           tags: | | ||||||
|  |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-arm64 | ||||||
|  |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.sha }}-arm64 | ||||||
|  |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}-arm64 | ||||||
|  |           build-args: | | ||||||
|  |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|  |             VERSION=${{ steps.ev.outputs.version }} | ||||||
|  |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|  |           platforms: linux/arm64 | ||||||
|  |           cache-from: type=gha | ||||||
|  |           cache-to: type=gha,mode=max | ||||||
|   pr-comment: |   pr-comment: | ||||||
|     needs: |     needs: | ||||||
|       - build |       - build | ||||||
|  |       - build-arm64 | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     if: ${{ github.event_name == 'pull_request' }} |     if: ${{ github.event_name == 'pull_request' }} | ||||||
|     permissions: |     permissions: | ||||||
| @ -271,9 +316,7 @@ jobs: | |||||||
|         id: ev |         id: ev | ||||||
|         env: |         env: | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|         with: |  | ||||||
|           image-name: ghcr.io/goauthentik/dev-server |  | ||||||
|       - name: Comment on PR |       - name: Comment on PR | ||||||
|         uses: ./.github/actions/comment-pr-instructions |         uses: ./.github/actions/comment-pr-instructions | ||||||
|         with: |         with: | ||||||
|           tag: gh-${{ steps.ev.outputs.imageMainTag }} |           tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }} | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,3 @@ | |||||||
| --- |  | ||||||
| name: authentik-ci-outpost | name: authentik-ci-outpost | ||||||
|  |  | ||||||
| on: | on: | ||||||
| @ -29,7 +28,7 @@ jobs: | |||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-client-go |         run: make gen-client-go | ||||||
|       - name: golangci-lint |       - name: golangci-lint | ||||||
|         uses: golangci/golangci-lint-action@v6 |         uses: golangci/golangci-lint-action@v3 | ||||||
|         with: |         with: | ||||||
|           version: v1.54.2 |           version: v1.54.2 | ||||||
|           args: --timeout 5000s --verbose |           args: --timeout 5000s --verbose | ||||||
| @ -76,7 +75,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.1.0 |         uses: docker/setup-qemu-action@v3.0.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 | ||||||
| @ -84,11 +83,9 @@ jobs: | |||||||
|         id: ev |         id: ev | ||||||
|         env: |         env: | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|         with: |  | ||||||
|           image-name: ghcr.io/goauthentik/dev-${{ matrix.type }} |  | ||||||
|       - name: Login to Container Registry |       - name: Login to Container Registry | ||||||
|         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} |  | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|  |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|         with: |         with: | ||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
| @ -96,17 +93,21 @@ jobs: | |||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-client-go |         run: make gen-client-go | ||||||
|       - name: Build Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v6 |         uses: docker/build-push-action@v5 | ||||||
|         with: |         with: | ||||||
|           tags: ${{ steps.ev.outputs.imageTags }} |  | ||||||
|           file: ${{ matrix.type }}.Dockerfile |  | ||||||
|           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|  |           tags: | | ||||||
|  |             ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }} | ||||||
|  |             ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }} | ||||||
|  |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           build-args: | |           build-args: | | ||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|  |             VERSION=${{ steps.ev.outputs.version }} | ||||||
|  |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache |           cache-from: type=gha | ||||||
|           cache-to: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache,mode=max |           cache-to: type=gha,mode=max | ||||||
|   build-binary: |   build-binary: | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
|     needs: |     needs: | ||||||
|  | |||||||
							
								
								
									
										114
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										114
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -12,36 +12,14 @@ on: | |||||||
|       - version-* |       - version-* | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   lint: |   lint-eslint: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         command: |  | ||||||
|           - lint |  | ||||||
|           - lint:lockfile |  | ||||||
|           - tsc |  | ||||||
|           - prettier-check |  | ||||||
|         project: |         project: | ||||||
|           - web |           - web | ||||||
|           - tests/wdio |           - tests/wdio | ||||||
|         include: |  | ||||||
|           - command: tsc |  | ||||||
|             project: web |  | ||||||
|             extra_setup: | |  | ||||||
|               cd sfe/ && npm ci |  | ||||||
|           - command: lit-analyse |  | ||||||
|             project: web |  | ||||||
|             extra_setup: | |  | ||||||
|               # lit-analyse doesn't understand path rewrites, so make it |  | ||||||
|               # belive it's an actual module |  | ||||||
|               cd node_modules/@goauthentik |  | ||||||
|               ln -s ../../src/ web |  | ||||||
|         exclude: |  | ||||||
|           - command: lint:lockfile |  | ||||||
|             project: tests/wdio |  | ||||||
|           - command: tsc |  | ||||||
|             project: tests/wdio |  | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
| @ -50,17 +28,77 @@ jobs: | |||||||
|           cache: "npm" |           cache: "npm" | ||||||
|           cache-dependency-path: ${{ matrix.project }}/package-lock.json |           cache-dependency-path: ${{ matrix.project }}/package-lock.json | ||||||
|       - working-directory: ${{ matrix.project }}/ |       - working-directory: ${{ matrix.project }}/ | ||||||
|         run: | |         run: npm ci | ||||||
|           npm ci |  | ||||||
|           ${{ matrix.extra_setup }} |  | ||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-client-ts |         run: make gen-client-ts | ||||||
|       - name: Lint |       - name: Eslint | ||||||
|         working-directory: ${{ matrix.project }}/ |         working-directory: ${{ matrix.project }}/ | ||||||
|         run: npm run ${{ matrix.command }} |         run: npm run lint | ||||||
|  |   lint-build: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |       - uses: actions/setup-node@v4 | ||||||
|  |         with: | ||||||
|  |           node-version-file: web/package.json | ||||||
|  |           cache: "npm" | ||||||
|  |           cache-dependency-path: web/package-lock.json | ||||||
|  |       - working-directory: web/ | ||||||
|  |         run: npm ci | ||||||
|  |       - name: Generate API | ||||||
|  |         run: make gen-client-ts | ||||||
|  |       - name: TSC | ||||||
|  |         working-directory: web/ | ||||||
|  |         run: npm run tsc | ||||||
|  |   lint-prettier: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     strategy: | ||||||
|  |       fail-fast: false | ||||||
|  |       matrix: | ||||||
|  |         project: | ||||||
|  |           - web | ||||||
|  |           - tests/wdio | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |       - uses: actions/setup-node@v4 | ||||||
|  |         with: | ||||||
|  |           node-version-file: ${{ matrix.project }}/package.json | ||||||
|  |           cache: "npm" | ||||||
|  |           cache-dependency-path: ${{ matrix.project }}/package-lock.json | ||||||
|  |       - working-directory: ${{ matrix.project }}/ | ||||||
|  |         run: npm ci | ||||||
|  |       - name: Generate API | ||||||
|  |         run: make gen-client-ts | ||||||
|  |       - name: prettier | ||||||
|  |         working-directory: ${{ matrix.project }}/ | ||||||
|  |         run: npm run prettier-check | ||||||
|  |   lint-lit-analyse: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |       - uses: actions/setup-node@v4 | ||||||
|  |         with: | ||||||
|  |           node-version-file: web/package.json | ||||||
|  |           cache: "npm" | ||||||
|  |           cache-dependency-path: web/package-lock.json | ||||||
|  |       - working-directory: web/ | ||||||
|  |         run: | | ||||||
|  |           npm ci | ||||||
|  |           # lit-analyse doesn't understand path rewrites, so make it | ||||||
|  |           # belive it's an actual module | ||||||
|  |           cd node_modules/@goauthentik | ||||||
|  |           ln -s ../../src/ web | ||||||
|  |       - name: Generate API | ||||||
|  |         run: make gen-client-ts | ||||||
|  |       - name: lit-analyse | ||||||
|  |         working-directory: web/ | ||||||
|  |         run: npm run lit-analyse | ||||||
|   ci-web-mark: |   ci-web-mark: | ||||||
|     needs: |     needs: | ||||||
|       - lint |       - lint-eslint | ||||||
|  |       - lint-prettier | ||||||
|  |       - lint-lit-analyse | ||||||
|  |       - lint-build | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - run: echo mark |       - run: echo mark | ||||||
| @ -82,21 +120,3 @@ jobs: | |||||||
|       - name: build |       - name: build | ||||||
|         working-directory: web/ |         working-directory: web/ | ||||||
|         run: npm run build |         run: npm run build | ||||||
|   test: |  | ||||||
|     needs: |  | ||||||
|       - ci-web-mark |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     steps: |  | ||||||
|       - uses: actions/checkout@v4 |  | ||||||
|       - uses: actions/setup-node@v4 |  | ||||||
|         with: |  | ||||||
|           node-version-file: web/package.json |  | ||||||
|           cache: "npm" |  | ||||||
|           cache-dependency-path: web/package-lock.json |  | ||||||
|       - working-directory: web/ |  | ||||||
|         run: npm ci |  | ||||||
|       - name: Generate API |  | ||||||
|         run: make gen-client-ts |  | ||||||
|       - name: test |  | ||||||
|         working-directory: web/ |  | ||||||
|         run: npm run test |  | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -12,21 +12,20 @@ on: | |||||||
|       - version-* |       - version-* | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   lint: |   lint-prettier: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     strategy: |  | ||||||
|       fail-fast: false |  | ||||||
|       matrix: |  | ||||||
|         command: |  | ||||||
|           - lint:lockfile |  | ||||||
|           - prettier-check |  | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|  |       - uses: actions/setup-node@v4 | ||||||
|  |         with: | ||||||
|  |           node-version-file: website/package.json | ||||||
|  |           cache: "npm" | ||||||
|  |           cache-dependency-path: website/package-lock.json | ||||||
|       - working-directory: website/ |       - working-directory: website/ | ||||||
|         run: npm ci |         run: npm ci | ||||||
|       - name: Lint |       - name: prettier | ||||||
|         working-directory: website/ |         working-directory: website/ | ||||||
|         run: npm run ${{ matrix.command }} |         run: npm run prettier-check | ||||||
|   test: |   test: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
| @ -49,6 +48,7 @@ jobs: | |||||||
|       matrix: |       matrix: | ||||||
|         job: |         job: | ||||||
|           - build |           - build | ||||||
|  |           - build-docs-only | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
| @ -63,7 +63,7 @@ jobs: | |||||||
|         run: npm run ${{ matrix.job }} |         run: npm run ${{ matrix.job }} | ||||||
|   ci-website-mark: |   ci-website-mark: | ||||||
|     needs: |     needs: | ||||||
|       - lint |       - lint-prettier | ||||||
|       - test |       - test | ||||||
|       - build |       - build | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|  | |||||||
							
								
								
									
										43
									
								
								.github/workflows/gen-update-webauthn-mds.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										43
									
								
								.github/workflows/gen-update-webauthn-mds.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,43 +0,0 @@ | |||||||
| name: authentik-gen-update-webauthn-mds |  | ||||||
| on: |  | ||||||
|   workflow_dispatch: |  | ||||||
|   schedule: |  | ||||||
|     - cron: '30 1 1,15 * *' |  | ||||||
|  |  | ||||||
| env: |  | ||||||
|   POSTGRES_DB: authentik |  | ||||||
|   POSTGRES_USER: authentik |  | ||||||
|   POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77" |  | ||||||
|  |  | ||||||
| jobs: |  | ||||||
|   build: |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     steps: |  | ||||||
|       - id: generate_token |  | ||||||
|         uses: tibdex/github-app-token@v2 |  | ||||||
|         with: |  | ||||||
|           app_id: ${{ secrets.GH_APP_ID }} |  | ||||||
|           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} |  | ||||||
|       - uses: actions/checkout@v4 |  | ||||||
|         with: |  | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |  | ||||||
|       - name: Setup authentik env |  | ||||||
|         uses: ./.github/actions/setup |  | ||||||
|       - run: poetry run ak update_webauthn_mds |  | ||||||
|       - uses: peter-evans/create-pull-request@v6 |  | ||||||
|         id: cpr |  | ||||||
|         with: |  | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |  | ||||||
|           branch: update-fido-mds-client |  | ||||||
|           commit-message: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs" |  | ||||||
|           title: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs" |  | ||||||
|           body: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs" |  | ||||||
|           delete-branch: true |  | ||||||
|           signoff: true |  | ||||||
|           # ID from https://api.github.com/users/authentik-automation[bot] |  | ||||||
|           author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com> |  | ||||||
|       - uses: peter-evans/enable-pull-request-automerge@v3 |  | ||||||
|         with: |  | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |  | ||||||
|           pull-request-number: ${{ steps.cpr.outputs.pull-request-number }} |  | ||||||
|           merge-method: squash |  | ||||||
							
								
								
									
										2
									
								
								.github/workflows/image-compress.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/image-compress.yml
									
									
									
									
										vendored
									
									
								
							| @ -42,7 +42,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           githubToken: ${{ steps.generate_token.outputs.token }} |           githubToken: ${{ steps.generate_token.outputs.token }} | ||||||
|           compressOnly: ${{ github.event_name != 'pull_request' }} |           compressOnly: ${{ github.event_name != 'pull_request' }} | ||||||
|       - uses: peter-evans/create-pull-request@v6 |       - uses: peter-evans/create-pull-request@v5 | ||||||
|         if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}" |         if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}" | ||||||
|         id: cpr |         id: cpr | ||||||
|         with: |         with: | ||||||
|  | |||||||
							
								
								
									
										64
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										64
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,3 @@ | |||||||
| --- |  | ||||||
| name: authentik-on-release | name: authentik-on-release | ||||||
|  |  | ||||||
| on: | on: | ||||||
| @ -14,16 +13,12 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - name: Set up QEMU |       - name: Set up QEMU | ||||||
|         uses: docker/setup-qemu-action@v3.1.0 |         uses: docker/setup-qemu-action@v3.0.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 | ||||||
|         uses: ./.github/actions/docker-push-variables |         uses: ./.github/actions/docker-push-variables | ||||||
|         id: ev |         id: ev | ||||||
|         env: |  | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |  | ||||||
|         with: |  | ||||||
|           image-name: ghcr.io/goauthentik/server,beryju/authentik |  | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|         with: |         with: | ||||||
| @ -40,15 +35,24 @@ jobs: | |||||||
|           mkdir -p ./gen-ts-api |           mkdir -p ./gen-ts-api | ||||||
|           mkdir -p ./gen-go-api |           mkdir -p ./gen-go-api | ||||||
|       - name: Build Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v6 |         uses: docker/build-push-action@v5 | ||||||
|         with: |         with: | ||||||
|           context: . |           context: . | ||||||
|           push: true |           push: ${{ github.event_name == 'release' }} | ||||||
|           secrets: | |           secrets: | | ||||||
|             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} |             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} | ||||||
|             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} |             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} | ||||||
|           tags: ${{ steps.ev.outputs.imageTags }} |           tags: | | ||||||
|  |             beryju/authentik:${{ steps.ev.outputs.version }}, | ||||||
|  |             beryju/authentik:${{ steps.ev.outputs.versionFamily }}, | ||||||
|  |             beryju/authentik:latest, | ||||||
|  |             ghcr.io/goauthentik/server:${{ steps.ev.outputs.version }}, | ||||||
|  |             ghcr.io/goauthentik/server:${{ steps.ev.outputs.versionFamily }}, | ||||||
|  |             ghcr.io/goauthentik/server:latest | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|  |           build-args: | | ||||||
|  |             VERSION=${{ steps.ev.outputs.version }} | ||||||
|  |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|   build-outpost: |   build-outpost: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     permissions: |     permissions: | ||||||
| @ -68,16 +72,12 @@ 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.1.0 |         uses: docker/setup-qemu-action@v3.0.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 | ||||||
|         uses: ./.github/actions/docker-push-variables |         uses: ./.github/actions/docker-push-variables | ||||||
|         id: ev |         id: ev | ||||||
|         env: |  | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |  | ||||||
|         with: |  | ||||||
|           image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }} |  | ||||||
|       - name: make empty clients |       - name: make empty clients | ||||||
|         run: | |         run: | | ||||||
|           mkdir -p ./gen-ts-api |           mkdir -p ./gen-ts-api | ||||||
| @ -94,13 +94,22 @@ jobs: | |||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|       - name: Build Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v6 |         uses: docker/build-push-action@v5 | ||||||
|         with: |         with: | ||||||
|           push: true |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: ${{ steps.ev.outputs.imageTags }} |           tags: | | ||||||
|  |             beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.version }}, | ||||||
|  |             beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }}, | ||||||
|  |             beryju/authentik-${{ matrix.type }}:latest, | ||||||
|  |             ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.version }}, | ||||||
|  |             ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }}, | ||||||
|  |             ghcr.io/goauthentik/${{ matrix.type }}:latest | ||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|  |           build-args: | | ||||||
|  |             VERSION=${{ steps.ev.outputs.version }} | ||||||
|  |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|   build-outpost-binary: |   build-outpost-binary: | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
| @ -155,12 +164,12 @@ jobs: | |||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - name: Run test suite in final docker images |       - name: Run test suite in final docker images | ||||||
|         run: | |         run: | | ||||||
|           echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env |           echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
|           echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env |           echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env | ||||||
|           docker compose pull -q |           docker-compose pull -q | ||||||
|           docker compose up --no-start |           docker-compose up --no-start | ||||||
|           docker compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker compose run -u root server test-all |           docker-compose run -u root server test-all | ||||||
|   sentry-release: |   sentry-release: | ||||||
|     needs: |     needs: | ||||||
|       - build-server |       - build-server | ||||||
| @ -172,18 +181,15 @@ jobs: | |||||||
|       - name: prepare variables |       - name: prepare variables | ||||||
|         uses: ./.github/actions/docker-push-variables |         uses: ./.github/actions/docker-push-variables | ||||||
|         id: ev |         id: ev | ||||||
|         env: |  | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |  | ||||||
|         with: |  | ||||||
|           image-name: ghcr.io/goauthentik/server |  | ||||||
|       - name: Get static files from docker image |       - name: Get static files from docker image | ||||||
|         run: | |         run: | | ||||||
|           docker pull ${{ steps.ev.outputs.imageMainTag }} |           docker pull ghcr.io/goauthentik/server:latest | ||||||
|           container=$(docker container create ${{ steps.ev.outputs.imageMainTag }}) |           container=$(docker container create ghcr.io/goauthentik/server:latest) | ||||||
|           docker cp ${container}:web/ . |           docker cp ${container}:web/ . | ||||||
|       - name: Create a Sentry.io release |       - name: Create a Sentry.io release | ||||||
|         uses: getsentry/action-release@v1 |         uses: getsentry/action-release@v1 | ||||||
|         continue-on-error: true |         continue-on-error: true | ||||||
|  |         if: ${{ github.event_name == 'release' }} | ||||||
|         env: |         env: | ||||||
|           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} |           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | ||||||
|           SENTRY_ORG: authentik-security-inc |           SENTRY_ORG: authentik-security-inc | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,3 @@ | |||||||
| --- |  | ||||||
| name: authentik-on-tag | name: authentik-on-tag | ||||||
|  |  | ||||||
| on: | on: | ||||||
| @ -14,28 +13,28 @@ jobs: | |||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - name: Pre-release test |       - name: Pre-release test | ||||||
|         run: | |         run: | | ||||||
|           echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env |           echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
|           echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env |           echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env | ||||||
|           docker buildx install |           docker buildx install | ||||||
|           mkdir -p ./gen-ts-api |           mkdir -p ./gen-ts-api | ||||||
|           docker build -t testing:latest . |           docker build -t testing:latest . | ||||||
|           echo "AUTHENTIK_IMAGE=testing" >> .env |           echo "AUTHENTIK_IMAGE=testing" >> .env | ||||||
|           echo "AUTHENTIK_TAG=latest" >> .env |           echo "AUTHENTIK_TAG=latest" >> .env | ||||||
|           docker compose up --no-start |           docker-compose up --no-start | ||||||
|           docker compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker compose run -u root server test-all |           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: | ||||||
|           app_id: ${{ secrets.GH_APP_ID }} |           app_id: ${{ secrets.GH_APP_ID }} | ||||||
|           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} |           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} | ||||||
|       - name: prepare variables |       - name: Extract version number | ||||||
|         uses: ./.github/actions/docker-push-variables |         id: get_version | ||||||
|         id: ev |         uses: actions/github-script@v7 | ||||||
|         env: |  | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |  | ||||||
|         with: |         with: | ||||||
|           image-name: ghcr.io/goauthentik/server |           github-token: ${{ steps.generate_token.outputs.token }} | ||||||
|  |           script: | | ||||||
|  |             return context.payload.ref.replace(/\/refs\/tags\/version\//, ''); | ||||||
|       - name: Create Release |       - name: Create Release | ||||||
|         id: create_release |         id: create_release | ||||||
|         uses: actions/create-release@v1.1.4 |         uses: actions/create-release@v1.1.4 | ||||||
| @ -43,6 +42,6 @@ jobs: | |||||||
|           GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} |           GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} | ||||||
|         with: |         with: | ||||||
|           tag_name: ${{ github.ref }} |           tag_name: ${{ github.ref }} | ||||||
|           release_name: Release ${{ steps.ev.outputs.version }} |           release_name: Release ${{ steps.get_version.outputs.result }} | ||||||
|           draft: true |           draft: true | ||||||
|           prerelease: ${{ steps.ev.outputs.prerelease == 'true' }} |           prerelease: false | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/repo-stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/repo-stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -23,7 +23,7 @@ jobs: | |||||||
|           repo-token: ${{ steps.generate_token.outputs.token }} |           repo-token: ${{ steps.generate_token.outputs.token }} | ||||||
|           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 | ||||||
|           stale-issue-label: wontfix |           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 | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/translation-advice.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/translation-advice.yml
									
									
									
									
										vendored
									
									
								
							| @ -19,14 +19,14 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - name: Find Comment |       - name: Find Comment | ||||||
|         uses: peter-evans/find-comment@v3 |         uses: peter-evans/find-comment@v2 | ||||||
|         id: fc |         id: fc | ||||||
|         with: |         with: | ||||||
|           issue-number: ${{ github.event.pull_request.number }} |           issue-number: ${{ github.event.pull_request.number }} | ||||||
|           comment-author: "github-actions[bot]" |           comment-author: "github-actions[bot]" | ||||||
|           body-includes: authentik translations instructions |           body-includes: authentik translations instructions | ||||||
|       - name: Create or update comment |       - name: Create or update comment | ||||||
|         uses: peter-evans/create-or-update-comment@v4 |         uses: peter-evans/create-or-update-comment@v3 | ||||||
|         with: |         with: | ||||||
|           comment-id: ${{ steps.fc.outputs.comment-id }} |           comment-id: ${{ steps.fc.outputs.comment-id }} | ||||||
|           issue-number: ${{ github.event.pull_request.number }} |           issue-number: ${{ github.event.pull_request.number }} | ||||||
|  | |||||||
| @ -1,8 +1,9 @@ | |||||||
| --- | name: authentik-backend-translate-compile | ||||||
| name: authentik-backend-translate-extract-compile |  | ||||||
| on: | on: | ||||||
|   schedule: |   push: | ||||||
|     - cron: "0 0 * * *" # every day at midnight |     branches: [main] | ||||||
|  |     paths: | ||||||
|  |       - "locale/**" | ||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
| 
 | 
 | ||||||
| env: | env: | ||||||
| @ -24,20 +25,16 @@ jobs: | |||||||
|           token: ${{ steps.generate_token.outputs.token }} |           token: ${{ steps.generate_token.outputs.token }} | ||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - name: run extract |  | ||||||
|         run: | |  | ||||||
|           poetry run make i18n-extract |  | ||||||
|       - name: run compile |       - name: run compile | ||||||
|         run: | |         run: poetry run ak compilemessages | ||||||
|           poetry run ak compilemessages |  | ||||||
|           make web-check-compile |  | ||||||
|       - name: Create Pull Request |       - name: Create Pull Request | ||||||
|         uses: peter-evans/create-pull-request@v6 |         uses: peter-evans/create-pull-request@v5 | ||||||
|  |         id: cpr | ||||||
|         with: |         with: | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |           token: ${{ steps.generate_token.outputs.token }} | ||||||
|           branch: extract-compile-backend-translation |           branch: compile-backend-translation | ||||||
|           commit-message: "core, web: update translations" |           commit-message: "core: compile backend translations" | ||||||
|           title: "core, web: update translations" |           title: "core: compile backend translations" | ||||||
|           body: "core, web: update translations" |           body: "core: compile backend translations" | ||||||
|           delete-branch: true |           delete-branch: true | ||||||
|           signoff: true |           signoff: true | ||||||
| @ -1,4 +1,4 @@ | |||||||
| name: authentik-api-ts-publish | name: authentik-web-api-publish | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: [main] |     branches: [main] | ||||||
| @ -31,16 +31,11 @@ jobs: | |||||||
|         env: |         env: | ||||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} |           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} | ||||||
|       - name: Upgrade /web |       - name: Upgrade /web | ||||||
|         working-directory: web |         working-directory: web/ | ||||||
|         run: | |         run: | | ||||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` |           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||||
|           npm i @goauthentik/api@$VERSION |           npm i @goauthentik/api@$VERSION | ||||||
|       - name: Upgrade /web/sfe |       - uses: peter-evans/create-pull-request@v5 | ||||||
|         working-directory: web/sfe |  | ||||||
|         run: | |  | ||||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` |  | ||||||
|           npm i @goauthentik/api@$VERSION |  | ||||||
|       - uses: peter-evans/create-pull-request@v6 |  | ||||||
|         id: cpr |         id: cpr | ||||||
|         with: |         with: | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |           token: ${{ steps.generate_token.outputs.token }} | ||||||
							
								
								
									
										3
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							| @ -10,7 +10,8 @@ | |||||||
|         "Gruntfuggly.todo-tree", |         "Gruntfuggly.todo-tree", | ||||||
|         "mechatroner.rainbow-csv", |         "mechatroner.rainbow-csv", | ||||||
|         "ms-python.black-formatter", |         "ms-python.black-formatter", | ||||||
|         "charliermarsh.ruff", |         "ms-python.isort", | ||||||
|  |         "ms-python.pylint", | ||||||
|         "ms-python.python", |         "ms-python.python", | ||||||
|         "ms-python.vscode-pylance", |         "ms-python.vscode-pylance", | ||||||
|         "ms-python.black-formatter", |         "ms-python.black-formatter", | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -4,21 +4,20 @@ | |||||||
|         "asgi", |         "asgi", | ||||||
|         "authentik", |         "authentik", | ||||||
|         "authn", |         "authn", | ||||||
|         "entra", |  | ||||||
|         "goauthentik", |         "goauthentik", | ||||||
|         "jwks", |         "jwks", | ||||||
|         "kubernetes", |  | ||||||
|         "oidc", |         "oidc", | ||||||
|         "openid", |         "openid", | ||||||
|         "passwordless", |  | ||||||
|         "plex", |         "plex", | ||||||
|         "saml", |         "saml", | ||||||
|         "scim", |  | ||||||
|         "slo", |  | ||||||
|         "sso", |  | ||||||
|         "totp", |         "totp", | ||||||
|         "traefik", |  | ||||||
|         "webauthn", |         "webauthn", | ||||||
|  |         "traefik", | ||||||
|  |         "passwordless", | ||||||
|  |         "kubernetes", | ||||||
|  |         "sso", | ||||||
|  |         "slo", | ||||||
|  |         "scim", | ||||||
|     ], |     ], | ||||||
|     "todo-tree.tree.showCountsInTree": true, |     "todo-tree.tree.showCountsInTree": true, | ||||||
|     "todo-tree.tree.showBadges": true, |     "todo-tree.tree.showBadges": true, | ||||||
|  | |||||||
| @ -11,8 +11,6 @@ scripts/                        @goauthentik/backend | |||||||
| tests/                          @goauthentik/backend | tests/                          @goauthentik/backend | ||||||
| pyproject.toml                  @goauthentik/backend | pyproject.toml                  @goauthentik/backend | ||||||
| poetry.lock                     @goauthentik/backend | poetry.lock                     @goauthentik/backend | ||||||
| go.mod                          @goauthentik/backend |  | ||||||
| go.sum                          @goauthentik/backend |  | ||||||
| # Infrastructure | # Infrastructure | ||||||
| .github/                        @goauthentik/infrastructure | .github/                        @goauthentik/infrastructure | ||||||
| Dockerfile                      @goauthentik/infrastructure | Dockerfile                      @goauthentik/infrastructure | ||||||
|  | |||||||
							
								
								
									
										52
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										52
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| # syntax=docker/dockerfile:1 | # syntax=docker/dockerfile:1 | ||||||
|  |  | ||||||
| # Stage 1: Build website | # Stage 1: Build website | ||||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:22 as website-builder | FROM --platform=${BUILDPLATFORM} docker.io/node:21 as website-builder | ||||||
|  |  | ||||||
| ENV NODE_ENV=production | ENV NODE_ENV=production | ||||||
|  |  | ||||||
| @ -14,41 +14,30 @@ RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.js | |||||||
|  |  | ||||||
| COPY ./website /work/website/ | COPY ./website /work/website/ | ||||||
| COPY ./blueprints /work/blueprints/ | COPY ./blueprints /work/blueprints/ | ||||||
| COPY ./schema.yml /work/ |  | ||||||
| COPY ./SECURITY.md /work/ | COPY ./SECURITY.md /work/ | ||||||
|  |  | ||||||
| RUN npm run build-bundled | RUN npm run build-docs-only | ||||||
|  |  | ||||||
| # Stage 2: Build webui | # Stage 2: Build webui | ||||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:22 as web-builder | FROM --platform=${BUILDPLATFORM} docker.io/node:21 as web-builder | ||||||
|  |  | ||||||
| ARG GIT_BUILD_HASH |  | ||||||
| ENV GIT_BUILD_HASH=$GIT_BUILD_HASH |  | ||||||
| ENV NODE_ENV=production | ENV NODE_ENV=production | ||||||
|  |  | ||||||
| WORKDIR /work/web | WORKDIR /work/web | ||||||
|  |  | ||||||
| RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | ||||||
|     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ |     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ | ||||||
|     --mount=type=bind,target=/work/web/sfe/package.json,src=./web/sfe/package.json \ |  | ||||||
|     --mount=type=bind,target=/work/web/sfe/package-lock.json,src=./web/sfe/package-lock.json \ |  | ||||||
|     --mount=type=bind,target=/work/web/scripts,src=./web/scripts \ |  | ||||||
|     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ |     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ | ||||||
|     npm ci --include=dev && \ |  | ||||||
|     cd sfe && \ |  | ||||||
|     npm ci --include=dev |     npm ci --include=dev | ||||||
|  |  | ||||||
| COPY ./package.json /work |  | ||||||
| COPY ./web /work/web/ | COPY ./web /work/web/ | ||||||
| COPY ./website /work/website/ | COPY ./website /work/website/ | ||||||
| COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | ||||||
|  |  | ||||||
| RUN npm run build && \ | RUN npm run build | ||||||
|     cd sfe && \ |  | ||||||
|     npm run build |  | ||||||
|  |  | ||||||
| # Stage 3: Build go proxy | # Stage 3: Build go proxy | ||||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder | FROM --platform=${BUILDPLATFORM} docker.io/golang:1.21.5-bookworm AS go-builder | ||||||
|  |  | ||||||
| ARG TARGETOS | ARG TARGETOS | ||||||
| ARG TARGETARCH | ARG TARGETARCH | ||||||
| @ -59,11 +48,6 @@ ARG GOARCH=$TARGETARCH | |||||||
|  |  | ||||||
| WORKDIR /go/src/goauthentik.io | WORKDIR /go/src/goauthentik.io | ||||||
|  |  | ||||||
| RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ |  | ||||||
|     dpkg --add-architecture arm64 && \ |  | ||||||
|     apt-get update && \ |  | ||||||
|     apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu |  | ||||||
|  |  | ||||||
| RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \ | RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \ | ||||||
|     --mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \ |     --mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \ | ||||||
|     --mount=type=cache,target=/go/pkg/mod \ |     --mount=type=cache,target=/go/pkg/mod \ | ||||||
| @ -78,17 +62,17 @@ COPY ./internal /go/src/goauthentik.io/internal | |||||||
| COPY ./go.mod /go/src/goauthentik.io/go.mod | COPY ./go.mod /go/src/goauthentik.io/go.mod | ||||||
| COPY ./go.sum /go/src/goauthentik.io/go.sum | COPY ./go.sum /go/src/goauthentik.io/go.sum | ||||||
|  |  | ||||||
|  | ENV CGO_ENABLED=0 | ||||||
|  |  | ||||||
| RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | ||||||
|     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ |     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ | ||||||
|     if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ |     GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server | ||||||
|     CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \ |  | ||||||
|     go build -o /go/authentik ./cmd/server |  | ||||||
|  |  | ||||||
| # Stage 4: MaxMind GeoIP | # Stage 4: MaxMind GeoIP | ||||||
| FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip | FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.0 as geoip | ||||||
|  |  | ||||||
| ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" | ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" | ||||||
| ENV GEOIPUPDATE_VERBOSE="1" | ENV GEOIPUPDATE_VERBOSE="true" | ||||||
| ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID" | ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID" | ||||||
| ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY" | ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY" | ||||||
|  |  | ||||||
| @ -99,7 +83,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.3-slim-bookworm-fips-full AS python-deps | FROM docker.io/python:3.12.1-slim-bookworm AS python-deps | ||||||
|  |  | ||||||
| WORKDIR /ak-root/poetry | WORKDIR /ak-root/poetry | ||||||
|  |  | ||||||
| @ -112,21 +96,19 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloa | |||||||
| RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ | RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ | ||||||
|     apt-get update && \ |     apt-get update && \ | ||||||
|     # Required for installing pip packages |     # Required for installing pip packages | ||||||
|     apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev |     apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev libpq-dev | ||||||
|  |  | ||||||
| RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \ | 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 \ | ||||||
|     python -m venv /ak-root/venv/ && \ |     python -m venv /ak-root/venv/ && \ | ||||||
|     bash -c "source ${VENV_PATH}/bin/activate && \ |  | ||||||
|     pip3 install --upgrade pip && \ |     pip3 install --upgrade pip && \ | ||||||
|     pip3 install poetry && \ |     pip3 install poetry && \ | ||||||
|     poetry install --only=main --no-ansi --no-interaction --no-root && \ |     poetry install --only=main --no-ansi --no-interaction | ||||||
|     pip install --force-reinstall /wheels/*" |  | ||||||
|  |  | ||||||
| # Stage 6: Run | # Stage 6: Run | ||||||
| FROM ghcr.io/goauthentik/fips-python:3.12.3-slim-bookworm-fips-full AS final-image | FROM docker.io/python:3.12.1-slim-bookworm AS final-image | ||||||
|  |  | ||||||
| ARG GIT_BUILD_HASH | ARG GIT_BUILD_HASH | ||||||
| ARG VERSION | ARG VERSION | ||||||
| @ -143,7 +125,7 @@ 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 && \ | ||||||
|     # Required for runtime |     # Required for runtime | ||||||
|     apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates && \ |     apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 ca-certificates && \ | ||||||
|     # Required for bootstrap & healtcheck |     # Required for bootstrap & healtcheck | ||||||
|     apt-get install -y --no-install-recommends runit && \ |     apt-get install -y --no-install-recommends runit && \ | ||||||
|     apt-get clean && \ |     apt-get clean && \ | ||||||
| @ -167,7 +149,7 @@ COPY --from=go-builder /go/authentik /bin/authentik | |||||||
| COPY --from=python-deps /ak-root/venv /ak-root/venv | COPY --from=python-deps /ak-root/venv /ak-root/venv | ||||||
| COPY --from=web-builder /work/web/dist/ /web/dist/ | COPY --from=web-builder /work/web/dist/ /web/dist/ | ||||||
| COPY --from=web-builder /work/web/authentik/ /web/authentik/ | COPY --from=web-builder /work/web/authentik/ /web/authentik/ | ||||||
| COPY --from=website-builder /work/website/build/ /website/help/ | COPY --from=website-builder /work/website/help/ /website/help/ | ||||||
| COPY --from=geoip /usr/share/GeoIP /geoip | COPY --from=geoip /usr/share/GeoIP /geoip | ||||||
|  |  | ||||||
| USER 1000 | USER 1000 | ||||||
| @ -179,8 +161,6 @@ ENV TMPDIR=/dev/shm/ \ | |||||||
|     VENV_PATH="/ak-root/venv" \ |     VENV_PATH="/ak-root/venv" \ | ||||||
|     POETRY_VIRTUALENVS_CREATE=false |     POETRY_VIRTUALENVS_CREATE=false | ||||||
|  |  | ||||||
| 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" ] | ||||||
|  |  | ||||||
| ENTRYPOINT [ "dumb-init", "--", "ak" ] | ENTRYPOINT [ "dumb-init", "--", "ak" ] | ||||||
|  | |||||||
							
								
								
									
										133
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										133
									
								
								Makefile
									
									
									
									
									
								
							| @ -5,13 +5,9 @@ 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 | ||||||
| DOCKER_IMAGE ?= "authentik:test" | DOCKER_IMAGE ?= "authentik:test" | ||||||
|  |  | ||||||
| GEN_API_TS = "gen-ts-api" |  | ||||||
| GEN_API_PY = "gen-py-api" |  | ||||||
| GEN_API_GO = "gen-go-api" |  | ||||||
|  |  | ||||||
| pg_user := $(shell python -m authentik.lib.config postgresql.user 2>/dev/null) | pg_user := $(shell python -m authentik.lib.config postgresql.user 2>/dev/null) | ||||||
| pg_host := $(shell python -m authentik.lib.config postgresql.host 2>/dev/null) | pg_host := $(shell python -m authentik.lib.config postgresql.host 2>/dev/null) | ||||||
| pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null) | pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null) | ||||||
| @ -19,7 +15,6 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null) | |||||||
| CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ | 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/developer-docs/api/reference/**' \ |  | ||||||
| 		authentik \ | 		authentik \ | ||||||
| 		internal \ | 		internal \ | ||||||
| 		cmd \ | 		cmd \ | ||||||
| @ -47,12 +42,12 @@ test-go: | |||||||
| 	go test -timeout 0 -v -race -cover ./... | 	go test -timeout 0 -v -race -cover ./... | ||||||
|  |  | ||||||
| test-docker:  ## Run all tests in a docker-compose | test-docker:  ## Run all tests in a docker-compose | ||||||
| 	echo "PG_PASS=$(shell openssl rand 32 | base64 -w 0)" >> .env | 	echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
| 	echo "AUTHENTIK_SECRET_KEY=$(shell openssl rand 32 | base64 -w 0)" >> .env | 	echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env | ||||||
| 	docker compose pull -q | 	docker-compose pull -q | ||||||
| 	docker compose up --no-start | 	docker-compose up --no-start | ||||||
| 	docker compose start postgresql redis | 	docker-compose start postgresql redis | ||||||
| 	docker compose run -u root server test-all | 	docker-compose run -u root server test-all | ||||||
| 	rm -f .env | 	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) | ||||||
| @ -60,37 +55,28 @@ test: ## Run the server tests and produce a coverage report (locally) | |||||||
| 	coverage html | 	coverage html | ||||||
| 	coverage report | 	coverage report | ||||||
|  |  | ||||||
| lint-fix: lint-codespell  ## Lint and automatically fix errors in the python source code. Reports spelling errors. | lint-fix:  ## Lint and automatically fix errors in the python source code. Reports spelling errors. | ||||||
|  | 	isort $(PY_SOURCES) | ||||||
| 	black $(PY_SOURCES) | 	black $(PY_SOURCES) | ||||||
| 	ruff check --fix $(PY_SOURCES) | 	ruff --fix $(PY_SOURCES) | ||||||
|  |  | ||||||
| lint-codespell:  ## Reports spelling errors. |  | ||||||
| 	codespell -w $(CODESPELL_ARGS) | 	codespell -w $(CODESPELL_ARGS) | ||||||
|  |  | ||||||
| lint: ## Lint the python and golang sources | lint: ## Lint the python and golang sources | ||||||
| 	bandit -r $(PY_SOURCES) -x web/node_modules -x tests/wdio/node_modules -x website/node_modules | 	bandit -r $(PY_SOURCES) -x node_modules | ||||||
|  | 	./web/node_modules/.bin/pyright $(PY_SOURCES) | ||||||
|  | 	pylint $(PY_SOURCES) | ||||||
| 	golangci-lint run -v | 	golangci-lint run -v | ||||||
|  |  | ||||||
| core-install: |  | ||||||
| 	poetry install |  | ||||||
|  |  | ||||||
| migrate: ## Run the Authentik Django server's migrations | migrate: ## Run the Authentik Django server's migrations | ||||||
| 	python -m lifecycle.migrate | 	python -m lifecycle.migrate | ||||||
|  |  | ||||||
| i18n-extract: core-i18n-extract web-i18n-extract  ## Extract strings that require translation into files to send to a translation service | i18n-extract: i18n-extract-core web-i18n-extract  ## Extract strings that require translation into files to send to a translation service | ||||||
|  |  | ||||||
| core-i18n-extract: | i18n-extract-core: | ||||||
| 	ak makemessages \ | 	ak makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en | ||||||
| 		--add-location file \ |  | ||||||
| 		--no-obsolete \ |  | ||||||
| 		--ignore web \ |  | ||||||
| 		--ignore internal \ |  | ||||||
| 		--ignore ${GEN_API_TS} \ |  | ||||||
| 		--ignore ${GEN_API_GO} \ |  | ||||||
| 		--ignore website \ |  | ||||||
| 		-l en |  | ||||||
|  |  | ||||||
| install: web-install website-install core-install  ## Install all requires dependencies for `web`, `website` and `core` | install: web-install website-install  ## Install all requires dependencies for `web`, `website` and `core` | ||||||
|  | 	poetry install | ||||||
|  |  | ||||||
| dev-drop-db: | dev-drop-db: | ||||||
| 	dropdb -U ${pg_user} -h ${pg_host} ${pg_name} | 	dropdb -U ${pg_user} -h ${pg_host} ${pg_name} | ||||||
| @ -108,14 +94,8 @@ dev-reset: dev-drop-db dev-create-db migrate  ## Drop and restore the Authentik | |||||||
| ######################### | ######################### | ||||||
|  |  | ||||||
| gen-build:  ## Extract the schema from the database | gen-build:  ## Extract the schema from the database | ||||||
| 	AUTHENTIK_DEBUG=true \ | 	AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json | ||||||
| 		AUTHENTIK_TENANTS__ENABLED=true \ | 	AUTHENTIK_DEBUG=true ak spectacular --file schema.yml | ||||||
| 		AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ |  | ||||||
| 		ak make_blueprint_schema > blueprints/schema.json |  | ||||||
| 	AUTHENTIK_DEBUG=true \ |  | ||||||
| 		AUTHENTIK_TENANTS__ENABLED=true \ |  | ||||||
| 		AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ |  | ||||||
| 		ak spectacular --file schema.yml |  | ||||||
|  |  | ||||||
| gen-changelog:  ## (Release) generate the changelog based from the commits since the last tag | gen-changelog:  ## (Release) generate the changelog based from the commits since the last tag | ||||||
| 	git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md | 	git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md | ||||||
| @ -126,7 +106,7 @@ gen-diff:  ## (Release) generate the changelog diff between the current schema a | |||||||
| 	docker run \ | 	docker run \ | ||||||
| 		--rm -v ${PWD}:/local \ | 		--rm -v ${PWD}:/local \ | ||||||
| 		--user ${UID}:${GID} \ | 		--user ${UID}:${GID} \ | ||||||
| 		docker.io/openapitools/openapi-diff:2.1.0-beta.8 \ | 		docker.io/openapitools/openapi-diff:2.1.0-beta.6 \ | ||||||
| 		--markdown /local/diff.md \ | 		--markdown /local/diff.md \ | ||||||
| 		/local/old_schema.yml /local/schema.yml | 		/local/old_schema.yml /local/schema.yml | ||||||
| 	rm old_schema.yml | 	rm old_schema.yml | ||||||
| @ -134,69 +114,48 @@ gen-diff:  ## (Release) generate the changelog diff between the current schema a | |||||||
| 	sed -i 's/}/}/g' diff.md | 	sed -i 's/}/}/g' diff.md | ||||||
| 	npx prettier --write diff.md | 	npx prettier --write diff.md | ||||||
|  |  | ||||||
| gen-clean-ts:  ## Remove generated API client for Typescript | gen-clean: | ||||||
| 	rm -rf ./${GEN_API_TS}/ | 	rm -rf gen-go-api/ | ||||||
| 	rm -rf ./web/node_modules/@goauthentik/api/ | 	rm -rf gen-ts-api/ | ||||||
|  | 	rm -rf web/node_modules/@goauthentik/api/ | ||||||
|  |  | ||||||
| gen-clean-go:  ## Remove generated API client for Go | gen-client-ts:  ## Build and install the authentik API for Typescript into the authentik UI Application | ||||||
| 	rm -rf ./${GEN_API_GO}/ |  | ||||||
|  |  | ||||||
| gen-clean-py:  ## Remove generated API client for Python |  | ||||||
| 	rm -rf ./${GEN_API_PY}/ |  | ||||||
|  |  | ||||||
| gen-clean: gen-clean-ts gen-clean-go gen-clean-py  ## Remove generated API clients |  | ||||||
|  |  | ||||||
| gen-client-ts: gen-clean-ts  ## Build and install the authentik API for Typescript into the authentik UI Application |  | ||||||
| 	docker run \ | 	docker run \ | ||||||
| 		--rm -v ${PWD}:/local \ | 		--rm -v ${PWD}:/local \ | ||||||
| 		--user ${UID}:${GID} \ | 		--user ${UID}:${GID} \ | ||||||
| 		docker.io/openapitools/openapi-generator-cli:v6.5.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-ts-api \ | ||||||
| 		-c /local/scripts/api-ts-config.yaml \ | 		-c /local/scripts/api-ts-config.yaml \ | ||||||
| 		--additional-properties=npmVersion=${NPM_VERSION} \ | 		--additional-properties=npmVersion=${NPM_VERSION} \ | ||||||
| 		--git-repo-id authentik \ | 		--git-repo-id authentik \ | ||||||
| 		--git-user-id goauthentik | 		--git-user-id goauthentik | ||||||
| 	mkdir -p web/node_modules/@goauthentik/api | 	mkdir -p web/node_modules/@goauthentik/api | ||||||
| 	cd ./${GEN_API_TS} && npm i | 	cd gen-ts-api && npm i | ||||||
| 	\cp -rf ./${GEN_API_TS}/* web/node_modules/@goauthentik/api | 	\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api | ||||||
|  |  | ||||||
| gen-client-py: gen-clean-py ## Build and install the authentik API for Python | gen-client-go:  ## Build and install the authentik API for Golang | ||||||
|  | 	mkdir -p ./gen-go-api ./gen-go-api/templates | ||||||
|  | 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./gen-go-api/config.yaml | ||||||
|  | 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./gen-go-api/templates/README.mustache | ||||||
|  | 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./gen-go-api/templates/go.mod.mustache | ||||||
|  | 	cp schema.yml ./gen-go-api/ | ||||||
| 	docker run \ | 	docker run \ | ||||||
| 		--rm -v ${PWD}:/local \ | 		--rm -v ${PWD}/gen-go-api:/local \ | ||||||
| 		--user ${UID}:${GID} \ |  | ||||||
| 		docker.io/openapitools/openapi-generator-cli:v7.4.0 generate \ |  | ||||||
| 		-i /local/schema.yml \ |  | ||||||
| 		-g python \ |  | ||||||
| 		-o /local/${GEN_API_PY} \ |  | ||||||
| 		-c /local/scripts/api-py-config.yaml \ |  | ||||||
| 		--additional-properties=packageVersion=${NPM_VERSION} \ |  | ||||||
| 		--git-repo-id authentik \ |  | ||||||
| 		--git-user-id goauthentik |  | ||||||
| 	pip install ./${GEN_API_PY} |  | ||||||
|  |  | ||||||
| gen-client-go: gen-clean-go  ## Build and install the authentik API for Golang |  | ||||||
| 	mkdir -p ./${GEN_API_GO} ./${GEN_API_GO}/templates |  | ||||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./${GEN_API_GO}/config.yaml |  | ||||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./${GEN_API_GO}/templates/README.mustache |  | ||||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./${GEN_API_GO}/templates/go.mod.mustache |  | ||||||
| 	cp schema.yml ./${GEN_API_GO}/ |  | ||||||
| 	docker run \ |  | ||||||
| 		--rm -v ${PWD}/${GEN_API_GO}:/local \ |  | ||||||
| 		--user ${UID}:${GID} \ | 		--user ${UID}:${GID} \ | ||||||
| 		docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \ | 		docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \ | ||||||
| 		-i /local/schema.yml \ | 		-i /local/schema.yml \ | ||||||
| 		-g go \ | 		-g go \ | ||||||
| 		-o /local/ \ | 		-o /local/ \ | ||||||
| 		-c /local/config.yaml | 		-c /local/config.yaml | ||||||
| 	go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO} | 	go mod edit -replace goauthentik.io/api/v3=./gen-go-api | ||||||
| 	rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/ | 	rm -rf ./gen-go-api/config.yaml ./gen-go-api/templates/ | ||||||
|  |  | ||||||
| gen-dev-config:  ## Generate a local development config file | gen-dev-config:  ## Generate a local development config file | ||||||
| 	python -m scripts.generate_config | 	python -m scripts.generate_config | ||||||
|  |  | ||||||
| gen: gen-build gen-client-ts | gen: gen-build gen-clean gen-client-ts | ||||||
|  |  | ||||||
| ######################### | ######################### | ||||||
| ## Web | ## Web | ||||||
| @ -205,7 +164,7 @@ gen: gen-build gen-client-ts | |||||||
| web-build: web-install  ## Build the Authentik UI | web-build: web-install  ## Build the Authentik UI | ||||||
| 	cd web && npm run build | 	cd web && npm run build | ||||||
|  |  | ||||||
| web: web-lint-fix web-lint web-check-compile  ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it | web: web-lint-fix web-lint web-check-compile web-i18n-extract  ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it | ||||||
|  |  | ||||||
| web-install:  ## Install the necessary libraries to build the Authentik UI | web-install:  ## Install the necessary libraries to build the Authentik UI | ||||||
| 	cd web && npm ci | 	cd web && npm ci | ||||||
| @ -241,7 +200,7 @@ website: website-lint-fix website-build  ## Automatically fix formatting issues | |||||||
| website-install: | website-install: | ||||||
| 	cd website && npm ci | 	cd website && npm ci | ||||||
|  |  | ||||||
| website-lint-fix: lint-codespell | website-lint-fix: | ||||||
| 	cd website && npm run prettier | 	cd website && npm run prettier | ||||||
|  |  | ||||||
| website-build: | website-build: | ||||||
| @ -255,7 +214,6 @@ website-watch:  ## Build and watch the documentation website, updating automatic | |||||||
| ######################### | ######################### | ||||||
|  |  | ||||||
| docker:  ## Build a docker image of the current source tree | docker:  ## Build a docker image of the current source tree | ||||||
| 	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} | ||||||
|  |  | ||||||
| ######################### | ######################### | ||||||
| @ -268,6 +226,9 @@ ci--meta-debug: | |||||||
| 	python -V | 	python -V | ||||||
| 	node --version | 	node --version | ||||||
|  |  | ||||||
|  | ci-pylint: ci--meta-debug | ||||||
|  | 	pylint $(PY_SOURCES) | ||||||
|  |  | ||||||
| ci-black: ci--meta-debug | ci-black: ci--meta-debug | ||||||
| 	black --check $(PY_SOURCES) | 	black --check $(PY_SOURCES) | ||||||
|  |  | ||||||
| @ -277,8 +238,14 @@ ci-ruff: ci--meta-debug | |||||||
| ci-codespell: ci--meta-debug | ci-codespell: ci--meta-debug | ||||||
| 	codespell $(CODESPELL_ARGS) -s | 	codespell $(CODESPELL_ARGS) -s | ||||||
|  |  | ||||||
|  | ci-isort: ci--meta-debug | ||||||
|  | 	isort --check $(PY_SOURCES) | ||||||
|  |  | ||||||
| ci-bandit: ci--meta-debug | ci-bandit: ci--meta-debug | ||||||
| 	bandit -r $(PY_SOURCES) | 	bandit -r $(PY_SOURCES) | ||||||
|  |  | ||||||
|  | ci-pyright: ci--meta-debug | ||||||
|  | 	./web/node_modules/.bin/pyright $(PY_SOURCES) | ||||||
|  |  | ||||||
| ci-pending-migrations: ci--meta-debug | ci-pending-migrations: ci--meta-debug | ||||||
| 	ak makemigrations --check | 	ak makemigrations --check | ||||||
|  | |||||||
| @ -25,10 +25,10 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h | |||||||
|  |  | ||||||
| ## Screenshots | ## Screenshots | ||||||
|  |  | ||||||
| | Light                                                       | Dark                                                       | | | Light                                                  | Dark                                                  | | ||||||
| | ----------------------------------------------------------- | ---------------------------------------------------------- | | | ------------------------------------------------------ | ----------------------------------------------------- | | ||||||
| |   |   | | |   |   | | ||||||
| |  |  | | |  |  | | ||||||
|  |  | ||||||
| ## Development | ## Development | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								SECURITY.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								SECURITY.md
									
									
									
									
									
								
							| @ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni | |||||||
|  |  | ||||||
| (.x being the latest patch release for each version) | (.x being the latest patch release for each version) | ||||||
|  |  | ||||||
| | Version  | Supported | | | Version | Supported | | ||||||
| | -------- | --------- | | | --- | --- | | ||||||
| | 2024.4.x | ✅        | | | 2023.6.x | ✅ | | ||||||
| | 2024.6.x | ✅        | | | 2023.8.x | ✅ | | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
| @ -31,12 +31,12 @@ To report a vulnerability, send an email to [security@goauthentik.io](mailto:se | |||||||
|  |  | ||||||
| authentik reserves the right to reclassify CVSS as necessary. To determine severity, we will use the CVSS calculator from NVD (https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator). The calculated CVSS score will then be translated into one of the following categories: | authentik reserves the right to reclassify CVSS as necessary. To determine severity, we will use the CVSS calculator from NVD (https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator). The calculated CVSS score will then be translated into one of the following categories: | ||||||
|  |  | ||||||
| | Score      | Severity | | | Score | Severity | | ||||||
| | ---------- | -------- | | | --- | --- | | ||||||
| | 0.0        | None     | | | 0.0 | None | | ||||||
| | 0.1 – 3.9  | Low      | | | 0.1 – 3.9 | Low | | ||||||
| | 4.0 – 6.9  | Medium   | | | 4.0 – 6.9 | Medium | | ||||||
| | 7.0 – 8.9  | High     | | | 7.0 – 8.9 | High | | ||||||
| | 9.0 – 10.0 | Critical | | | 9.0 – 10.0 | Critical | | ||||||
|  |  | ||||||
| ## Disclosure process | ## Disclosure process | ||||||
|  | |||||||
| @ -1,12 +1,12 @@ | |||||||
| """authentik root module""" | """authentik root module""" | ||||||
|  |  | ||||||
| from os import environ | from os import environ | ||||||
|  | from typing import Optional | ||||||
|  |  | ||||||
| __version__ = "2024.6.1" | __version__ = "2023.10.5" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_build_hash(fallback: str | None = None) -> str: | def get_build_hash(fallback: Optional[str] = None) -> str: | ||||||
|     """Get build hash""" |     """Get build hash""" | ||||||
|     build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "") |     build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "") | ||||||
|     return fallback if build_hash == "" and fallback else build_hash |     return fallback if build_hash == "" and fallback else build_hash | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Meta API""" | """Meta API""" | ||||||
|  |  | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAuthenticated | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """authentik administration metrics""" | """authentik administration metrics""" | ||||||
|  |  | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
|  |  | ||||||
| from django.db.models.functions import ExtractHour | from django.db.models.functions import ExtractHour | ||||||
|  | |||||||
| @ -1,23 +1,18 @@ | |||||||
| """authentik administration overview""" | """authentik administration overview""" | ||||||
|  |  | ||||||
| import platform | import platform | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from ssl import OPENSSL_VERSION |  | ||||||
| from sys import version as python_version | from sys import version as python_version | ||||||
| from typing import TypedDict | from typing import TypedDict | ||||||
|  |  | ||||||
| from cryptography.hazmat.backends.openssl.backend import backend |  | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
|  | from gunicorn import version_info as gunicorn_version | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
| 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.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.enterprise.license import LicenseKey |  | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
| from authentik.lib.utils.reflection import get_env | from authentik.lib.utils.reflection import get_env | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST | from authentik.outposts.apps import MANAGED_OUTPOST | ||||||
| from authentik.outposts.models import Outpost | from authentik.outposts.models import Outpost | ||||||
| @ -28,13 +23,11 @@ class RuntimeDict(TypedDict): | |||||||
|     """Runtime information""" |     """Runtime information""" | ||||||
|  |  | ||||||
|     python_version: str |     python_version: str | ||||||
|  |     gunicorn_version: str | ||||||
|     environment: str |     environment: str | ||||||
|     architecture: str |     architecture: str | ||||||
|     platform: str |     platform: str | ||||||
|     uname: str |     uname: str | ||||||
|     openssl_version: str |  | ||||||
|     openssl_fips_enabled: bool | None |  | ||||||
|     authentik_version: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SystemInfoSerializer(PassiveSerializer): | class SystemInfoSerializer(PassiveSerializer): | ||||||
| @ -44,9 +37,8 @@ class SystemInfoSerializer(PassiveSerializer): | |||||||
|     http_host = SerializerMethodField() |     http_host = SerializerMethodField() | ||||||
|     http_is_secure = SerializerMethodField() |     http_is_secure = SerializerMethodField() | ||||||
|     runtime = SerializerMethodField() |     runtime = SerializerMethodField() | ||||||
|     brand = SerializerMethodField() |     tenant = SerializerMethodField() | ||||||
|     server_time = SerializerMethodField() |     server_time = SerializerMethodField() | ||||||
|     embedded_outpost_disabled = SerializerMethodField() |  | ||||||
|     embedded_outpost_host = SerializerMethodField() |     embedded_outpost_host = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_http_headers(self, request: Request) -> dict[str, str]: |     def get_http_headers(self, request: Request) -> dict[str, str]: | ||||||
| @ -69,30 +61,22 @@ class SystemInfoSerializer(PassiveSerializer): | |||||||
|     def get_runtime(self, request: Request) -> RuntimeDict: |     def get_runtime(self, request: Request) -> RuntimeDict: | ||||||
|         """Get versions""" |         """Get versions""" | ||||||
|         return { |         return { | ||||||
|             "architecture": platform.machine(), |  | ||||||
|             "authentik_version": get_full_version(), |  | ||||||
|             "environment": get_env(), |  | ||||||
|             "openssl_fips_enabled": ( |  | ||||||
|                 backend._fips_enabled if LicenseKey.get_total().is_valid() else None |  | ||||||
|             ), |  | ||||||
|             "openssl_version": OPENSSL_VERSION, |  | ||||||
|             "platform": platform.platform(), |  | ||||||
|             "python_version": python_version, |             "python_version": python_version, | ||||||
|  |             "gunicorn_version": ".".join(str(x) for x in gunicorn_version), | ||||||
|  |             "environment": get_env(), | ||||||
|  |             "architecture": platform.machine(), | ||||||
|  |             "platform": platform.platform(), | ||||||
|             "uname": " ".join(platform.uname()), |             "uname": " ".join(platform.uname()), | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     def get_brand(self, request: Request) -> str: |     def get_tenant(self, request: Request) -> str: | ||||||
|         """Currently active brand""" |         """Currently active tenant""" | ||||||
|         return str(request._request.brand) |         return str(request._request.tenant) | ||||||
|  |  | ||||||
|     def get_server_time(self, request: Request) -> datetime: |     def get_server_time(self, request: Request) -> datetime: | ||||||
|         """Current server time""" |         """Current server time""" | ||||||
|         return now() |         return now() | ||||||
|  |  | ||||||
|     def get_embedded_outpost_disabled(self, request: Request) -> bool: |  | ||||||
|         """Whether the embedded outpost is disabled""" |  | ||||||
|         return CONFIG.get_bool("outposts.disable_embedded_outpost", False) |  | ||||||
|  |  | ||||||
|     def get_embedded_outpost_host(self, request: Request) -> str: |     def get_embedded_outpost_host(self, request: Request) -> str: | ||||||
|         """Get the FQDN configured on the embedded outpost""" |         """Get the FQDN configured on the embedded outpost""" | ||||||
|         outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) |         outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) | ||||||
|  | |||||||
							
								
								
									
										134
									
								
								authentik/admin/api/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								authentik/admin/api/tasks.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,134 @@ | |||||||
|  | """Tasks API""" | ||||||
|  | from importlib import import_module | ||||||
|  |  | ||||||
|  | from django.contrib import messages | ||||||
|  | from django.http.response import Http404 | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  | from drf_spectacular.types import OpenApiTypes | ||||||
|  | from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | ||||||
|  | from rest_framework.decorators import action | ||||||
|  | from rest_framework.fields import ( | ||||||
|  |     CharField, | ||||||
|  |     ChoiceField, | ||||||
|  |     DateTimeField, | ||||||
|  |     ListField, | ||||||
|  |     SerializerMethodField, | ||||||
|  | ) | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  | from rest_framework.viewsets import ViewSet | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.api.decorators import permission_required | ||||||
|  | from authentik.core.api.utils import PassiveSerializer | ||||||
|  | from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus | ||||||
|  | from authentik.rbac.permissions import HasPermission | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TaskSerializer(PassiveSerializer): | ||||||
|  |     """Serialize TaskInfo and TaskResult""" | ||||||
|  |  | ||||||
|  |     task_name = CharField() | ||||||
|  |     task_description = CharField() | ||||||
|  |     task_finish_timestamp = DateTimeField(source="finish_time") | ||||||
|  |     task_duration = SerializerMethodField() | ||||||
|  |  | ||||||
|  |     status = ChoiceField( | ||||||
|  |         source="result.status.name", | ||||||
|  |         choices=[(x.name, x.name) for x in TaskResultStatus], | ||||||
|  |     ) | ||||||
|  |     messages = ListField(source="result.messages") | ||||||
|  |  | ||||||
|  |     def get_task_duration(self, instance: TaskInfo) -> int: | ||||||
|  |         """Get the duration a task took to run""" | ||||||
|  |         return max(instance.finish_timestamp - instance.start_timestamp, 0) | ||||||
|  |  | ||||||
|  |     def to_representation(self, instance: TaskInfo): | ||||||
|  |         """When a new version of authentik adds fields to TaskInfo, | ||||||
|  |         the API will fail with an AttributeError, as the classes | ||||||
|  |         are pickled in cache. In that case, just delete the info""" | ||||||
|  |         try: | ||||||
|  |             return super().to_representation(instance) | ||||||
|  |         # pylint: disable=broad-except | ||||||
|  |         except Exception:  # pragma: no cover | ||||||
|  |             if isinstance(self.instance, list): | ||||||
|  |                 for inst in self.instance: | ||||||
|  |                     inst.delete() | ||||||
|  |             else: | ||||||
|  |                 self.instance.delete() | ||||||
|  |             return {} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TaskViewSet(ViewSet): | ||||||
|  |     """Read-only view set that returns all background tasks""" | ||||||
|  |  | ||||||
|  |     permission_classes = [HasPermission("authentik_rbac.view_system_tasks")] | ||||||
|  |     serializer_class = TaskSerializer | ||||||
|  |  | ||||||
|  |     @extend_schema( | ||||||
|  |         responses={ | ||||||
|  |             200: TaskSerializer(many=False), | ||||||
|  |             404: OpenApiResponse(description="Task not found"), | ||||||
|  |         }, | ||||||
|  |         parameters=[ | ||||||
|  |             OpenApiParameter( | ||||||
|  |                 "id", | ||||||
|  |                 type=OpenApiTypes.STR, | ||||||
|  |                 location=OpenApiParameter.PATH, | ||||||
|  |                 required=True, | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     def retrieve(self, request: Request, pk=None) -> Response: | ||||||
|  |         """Get a single system task""" | ||||||
|  |         task = TaskInfo.by_name(pk) | ||||||
|  |         if not task: | ||||||
|  |             raise Http404 | ||||||
|  |         return Response(TaskSerializer(task, many=False).data) | ||||||
|  |  | ||||||
|  |     @extend_schema(responses={200: TaskSerializer(many=True)}) | ||||||
|  |     def list(self, request: Request) -> Response: | ||||||
|  |         """List system tasks""" | ||||||
|  |         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) | ||||||
|  |         return Response(TaskSerializer(tasks, many=True).data) | ||||||
|  |  | ||||||
|  |     @permission_required(None, ["authentik_rbac.run_system_tasks"]) | ||||||
|  |     @extend_schema( | ||||||
|  |         request=OpenApiTypes.NONE, | ||||||
|  |         responses={ | ||||||
|  |             204: OpenApiResponse(description="Task retried successfully"), | ||||||
|  |             404: OpenApiResponse(description="Task not found"), | ||||||
|  |             500: OpenApiResponse(description="Failed to retry task"), | ||||||
|  |         }, | ||||||
|  |         parameters=[ | ||||||
|  |             OpenApiParameter( | ||||||
|  |                 "id", | ||||||
|  |                 type=OpenApiTypes.STR, | ||||||
|  |                 location=OpenApiParameter.PATH, | ||||||
|  |                 required=True, | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     @action(detail=True, methods=["post"]) | ||||||
|  |     def retry(self, request: Request, pk=None) -> Response: | ||||||
|  |         """Retry task""" | ||||||
|  |         task = TaskInfo.by_name(pk) | ||||||
|  |         if not task: | ||||||
|  |             raise Http404 | ||||||
|  |         try: | ||||||
|  |             task_module = import_module(task.task_call_module) | ||||||
|  |             task_func = getattr(task_module, task.task_call_func) | ||||||
|  |             LOGGER.debug("Running task", task=task_func) | ||||||
|  |             task_func.delay(*task.task_call_args, **task.task_call_kwargs) | ||||||
|  |             messages.success( | ||||||
|  |                 self.request, | ||||||
|  |                 _("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}), | ||||||
|  |             ) | ||||||
|  |             return Response(status=204) | ||||||
|  |         except (ImportError, AttributeError):  # pragma: no cover | ||||||
|  |             LOGGER.warning("Failed to run task, remove state", task=task) | ||||||
|  |             # if we get an import error, the module path has probably changed | ||||||
|  |             task.delete() | ||||||
|  |             return Response(status=500) | ||||||
| @ -1,5 +1,4 @@ | |||||||
| """authentik administration overview""" | """authentik administration overview""" | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from packaging.version import parse | from packaging.version import parse | ||||||
| @ -10,7 +9,7 @@ from rest_framework.response import Response | |||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from authentik import __version__, get_build_hash | from authentik import __version__, get_build_hash | ||||||
| from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version | from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -19,7 +18,6 @@ class VersionSerializer(PassiveSerializer): | |||||||
|  |  | ||||||
|     version_current = SerializerMethodField() |     version_current = SerializerMethodField() | ||||||
|     version_latest = SerializerMethodField() |     version_latest = SerializerMethodField() | ||||||
|     version_latest_valid = SerializerMethodField() |  | ||||||
|     build_hash = SerializerMethodField() |     build_hash = SerializerMethodField() | ||||||
|     outdated = SerializerMethodField() |     outdated = SerializerMethodField() | ||||||
|  |  | ||||||
| @ -39,10 +37,6 @@ class VersionSerializer(PassiveSerializer): | |||||||
|             return __version__ |             return __version__ | ||||||
|         return version_in_cache |         return version_in_cache | ||||||
|  |  | ||||||
|     def get_version_latest_valid(self, _) -> bool: |  | ||||||
|         """Check if latest version is valid""" |  | ||||||
|         return cache.get(VERSION_CACHE_KEY) != VERSION_NULL |  | ||||||
|  |  | ||||||
|     def get_outdated(self, instance) -> bool: |     def get_outdated(self, instance) -> bool: | ||||||
|         """Check if we're running the latest version""" |         """Check if we're running the latest version""" | ||||||
|         return parse(self.get_version_current(instance)) < parse(self.get_version_latest(instance)) |         return parse(self.get_version_current(instance)) < parse(self.get_version_latest(instance)) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """authentik administration overview""" | """authentik administration overview""" | ||||||
|  |  | ||||||
| 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 rest_framework.fields import IntegerField | from rest_framework.fields import IntegerField | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """authentik admin app config""" | """authentik admin app config""" | ||||||
|  |  | ||||||
| from prometheus_client import Gauge, Info | from prometheus_client import Gauge, Info | ||||||
|  |  | ||||||
| from authentik.blueprints.apps import ManagedAppConfig | from authentik.blueprints.apps import ManagedAppConfig | ||||||
| @ -15,3 +14,7 @@ class AuthentikAdminConfig(ManagedAppConfig): | |||||||
|     label = "authentik_admin" |     label = "authentik_admin" | ||||||
|     verbose_name = "authentik Admin" |     verbose_name = "authentik Admin" | ||||||
|     default = True |     default = True | ||||||
|  |  | ||||||
|  |     def reconcile_load_admin_signals(self): | ||||||
|  |         """Load admin signals""" | ||||||
|  |         self.import_module("authentik.admin.signals") | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """authentik admin settings""" | """authentik admin settings""" | ||||||
|  |  | ||||||
| from celery.schedules import crontab | from celery.schedules import crontab | ||||||
|  |  | ||||||
| from authentik.lib.utils.time import fqdn_rand | from authentik.lib.utils.time import fqdn_rand | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """admin signals""" | """admin signals""" | ||||||
|  |  | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
|  |  | ||||||
|  | from authentik.admin.api.tasks import TaskInfo | ||||||
| from authentik.admin.apps import GAUGE_WORKERS | 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 | ||||||
| @ -12,3 +12,10 @@ def monitoring_set_workers(sender, **kwargs): | |||||||
|     """Set worker gauge""" |     """Set worker gauge""" | ||||||
|     count = len(CELERY_APP.control.ping(timeout=0.5)) |     count = len(CELERY_APP.control.ping(timeout=0.5)) | ||||||
|     GAUGE_WORKERS.set(count) |     GAUGE_WORKERS.set(count) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(monitoring_set) | ||||||
|  | def monitoring_set_tasks(sender, **kwargs): | ||||||
|  |     """Set task gauges""" | ||||||
|  |     for task in TaskInfo.all().values(): | ||||||
|  |         task.update_metrics() | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """authentik admin tasks""" | """authentik admin tasks""" | ||||||
|  |  | ||||||
| import re | import re | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| @ -12,13 +11,17 @@ from structlog.stdlib import get_logger | |||||||
| from authentik import __version__, get_build_hash | from authentik import __version__, get_build_hash | ||||||
| from authentik.admin.apps import PROM_INFO | from authentik.admin.apps import PROM_INFO | ||||||
| from authentik.events.models import Event, EventAction, Notification | from authentik.events.models import Event, EventAction, Notification | ||||||
| from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task | from authentik.events.monitored_tasks import ( | ||||||
|  |     MonitoredTask, | ||||||
|  |     TaskResult, | ||||||
|  |     TaskResultStatus, | ||||||
|  |     prefill_task, | ||||||
|  | ) | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.http import get_http_session | from authentik.lib.utils.http import get_http_session | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| VERSION_NULL = "0.0.0" |  | ||||||
| VERSION_CACHE_KEY = "authentik_latest_version" | VERSION_CACHE_KEY = "authentik_latest_version" | ||||||
| VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | ||||||
| # Chop of the first ^ because we want to search the entire string | # Chop of the first ^ because we want to search the entire string | ||||||
| @ -51,13 +54,13 @@ def clear_update_notifications(): | |||||||
|             notification.delete() |             notification.delete() | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=SystemTask) | @CELERY_APP.task(bind=True, base=MonitoredTask) | ||||||
| @prefill_task | @prefill_task | ||||||
| def update_latest_version(self: SystemTask): | def update_latest_version(self: MonitoredTask): | ||||||
|     """Update latest version info""" |     """Update latest version info""" | ||||||
|     if CONFIG.get_bool("disable_update_check"): |     if CONFIG.get_bool("disable_update_check"): | ||||||
|         cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT) |         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) | ||||||
|         self.set_status(TaskStatus.WARNING, "Version check disabled.") |         self.set_status(TaskResult(TaskResultStatus.WARNING, messages=["Version check disabled."])) | ||||||
|         return |         return | ||||||
|     try: |     try: | ||||||
|         response = get_http_session().get( |         response = get_http_session().get( | ||||||
| @ -67,7 +70,9 @@ def update_latest_version(self: SystemTask): | |||||||
|         data = response.json() |         data = response.json() | ||||||
|         upstream_version = data.get("stable", {}).get("version") |         upstream_version = data.get("stable", {}).get("version") | ||||||
|         cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT) |         cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT) | ||||||
|         self.set_status(TaskStatus.SUCCESSFUL, "Successfully updated latest Version") |         self.set_status( | ||||||
|  |             TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]) | ||||||
|  |         ) | ||||||
|         _set_prom_info() |         _set_prom_info() | ||||||
|         # Check if upstream version is newer than what we're running, |         # Check if upstream version is newer than what we're running, | ||||||
|         # and if no event exists yet, create one. |         # and if no event exists yet, create one. | ||||||
| @ -83,8 +88,8 @@ def update_latest_version(self: SystemTask): | |||||||
|                 event_dict["message"] = f"Changelog: {match.group()}" |                 event_dict["message"] = f"Changelog: {match.group()}" | ||||||
|             Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save() |             Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save() | ||||||
|     except (RequestException, IndexError) as exc: |     except (RequestException, IndexError) as exc: | ||||||
|         cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT) |         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) | ||||||
|         self.set_error(exc) |         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||||
|  |  | ||||||
|  |  | ||||||
| _set_prom_info() | _set_prom_info() | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """test admin api""" | """test admin api""" | ||||||
|  |  | ||||||
| from json import loads | from json import loads | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| @ -8,6 +7,8 @@ from django.urls import reverse | |||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.blueprints.tests import reconcile_app | from authentik.blueprints.tests import reconcile_app | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
|  | from authentik.core.tasks import clean_expired_models | ||||||
|  | from authentik.events.monitored_tasks import TaskResultStatus | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -22,6 +23,53 @@ class TestAdminAPI(TestCase): | |||||||
|         self.group.save() |         self.group.save() | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|  |  | ||||||
|  |     def test_tasks(self): | ||||||
|  |         """Test Task API""" | ||||||
|  |         clean_expired_models.delay() | ||||||
|  |         response = self.client.get(reverse("authentik_api:admin_system_tasks-list")) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         body = loads(response.content) | ||||||
|  |         self.assertTrue(any(task["task_name"] == "clean_expired_models" for task in body)) | ||||||
|  |  | ||||||
|  |     def test_tasks_single(self): | ||||||
|  |         """Test Task API (read single)""" | ||||||
|  |         clean_expired_models.delay() | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:admin_system_tasks-detail", | ||||||
|  |                 kwargs={"pk": "clean_expired_models"}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         body = loads(response.content) | ||||||
|  |         self.assertEqual(body["status"], TaskResultStatus.SUCCESSFUL.name) | ||||||
|  |         self.assertEqual(body["task_name"], "clean_expired_models") | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:admin_system_tasks-detail", kwargs={"pk": "qwerqwer"}) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|  |     def test_tasks_retry(self): | ||||||
|  |         """Test Task API (retry)""" | ||||||
|  |         clean_expired_models.delay() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:admin_system_tasks-retry", | ||||||
|  |                 kwargs={"pk": "clean_expired_models"}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 204) | ||||||
|  |  | ||||||
|  |     def test_tasks_retry_404(self): | ||||||
|  |         """Test Task API (retry, 404)""" | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:admin_system_tasks-retry", | ||||||
|  |                 kwargs={"pk": "qwerqewrqrqewrqewr"}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|     def test_version(self): |     def test_version(self): | ||||||
|         """Test Version API""" |         """Test Version API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_version")) |         response = self.client.get(reverse("authentik_api:admin_version")) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """test admin tasks""" | """test admin tasks""" | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from requests_mock import Mocker | from requests_mock import Mocker | ||||||
|  | |||||||
| @ -1,14 +1,15 @@ | |||||||
| """API URLs""" | """API URLs""" | ||||||
|  |  | ||||||
| from django.urls import path | from django.urls import path | ||||||
|  |  | ||||||
| from authentik.admin.api.meta import AppsViewSet, ModelViewSet | from authentik.admin.api.meta import AppsViewSet, ModelViewSet | ||||||
| from authentik.admin.api.metrics import AdministrationMetricsViewSet | from authentik.admin.api.metrics import AdministrationMetricsViewSet | ||||||
| from authentik.admin.api.system import SystemView | from authentik.admin.api.system import SystemView | ||||||
|  | from authentik.admin.api.tasks import TaskViewSet | ||||||
| from authentik.admin.api.version import VersionView | from authentik.admin.api.version import VersionView | ||||||
| from authentik.admin.api.workers import WorkerView | from authentik.admin.api.workers import WorkerView | ||||||
|  |  | ||||||
| api_urlpatterns = [ | api_urlpatterns = [ | ||||||
|  |     ("admin/system_tasks", TaskViewSet, "admin_system_tasks"), | ||||||
|     ("admin/apps", AppsViewSet, "apps"), |     ("admin/apps", AppsViewSet, "apps"), | ||||||
|     ("admin/models", ModelViewSet, "models"), |     ("admin/models", ModelViewSet, "models"), | ||||||
|     path( |     path( | ||||||
|  | |||||||
| @ -10,3 +10,26 @@ class AuthentikAPIConfig(AppConfig): | |||||||
|     label = "authentik_api" |     label = "authentik_api" | ||||||
|     mountpoint = "api/" |     mountpoint = "api/" | ||||||
|     verbose_name = "authentik API" |     verbose_name = "authentik API" | ||||||
|  |  | ||||||
|  |     def ready(self) -> None: | ||||||
|  |         from drf_spectacular.extensions import OpenApiAuthenticationExtension | ||||||
|  |  | ||||||
|  |         from authentik.api.authentication import TokenAuthentication | ||||||
|  |  | ||||||
|  |         # Class is defined here as it needs to be created early enough that drf-spectacular will | ||||||
|  |         # find it, but also won't cause any import issues | ||||||
|  |         # pylint: disable=unused-variable | ||||||
|  |         class TokenSchema(OpenApiAuthenticationExtension): | ||||||
|  |             """Auth schema""" | ||||||
|  |  | ||||||
|  |             target_class = TokenAuthentication | ||||||
|  |             name = "authentik" | ||||||
|  |  | ||||||
|  |             def get_security_definition(self, auto_schema): | ||||||
|  |                 """Auth schema""" | ||||||
|  |                 return { | ||||||
|  |                     "type": "apiKey", | ||||||
|  |                     "in": "header", | ||||||
|  |                     "name": "Authorization", | ||||||
|  |                     "scheme": "bearer", | ||||||
|  |                 } | ||||||
|  | |||||||
| @ -1,10 +1,8 @@ | |||||||
| """API Authentication""" | """API Authentication""" | ||||||
|  |  | ||||||
| from hmac import compare_digest | from hmac import compare_digest | ||||||
| from typing import Any | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from drf_spectacular.extensions import OpenApiAuthenticationExtension |  | ||||||
| from rest_framework.authentication import BaseAuthentication, get_authorization_header | from rest_framework.authentication import BaseAuthentication, get_authorization_header | ||||||
| from rest_framework.exceptions import AuthenticationFailed | from rest_framework.exceptions import AuthenticationFailed | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -18,7 +16,7 @@ from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| def validate_auth(header: bytes) -> str | None: | def validate_auth(header: bytes) -> Optional[str]: | ||||||
|     """Validate that the header is in a correct format, |     """Validate that the header is in a correct format, | ||||||
|     returns type and credentials""" |     returns type and credentials""" | ||||||
|     auth_credentials = header.decode().strip() |     auth_credentials = header.decode().strip() | ||||||
| @ -33,7 +31,7 @@ def validate_auth(header: bytes) -> str | None: | |||||||
|     return auth_credentials |     return auth_credentials | ||||||
|  |  | ||||||
|  |  | ||||||
| def bearer_auth(raw_header: bytes) -> User | None: | def bearer_auth(raw_header: bytes) -> Optional[User]: | ||||||
|     """raw_header in the Format of `Bearer ....`""" |     """raw_header in the Format of `Bearer ....`""" | ||||||
|     user = auth_user_lookup(raw_header) |     user = auth_user_lookup(raw_header) | ||||||
|     if not user: |     if not user: | ||||||
| @ -43,7 +41,7 @@ def bearer_auth(raw_header: bytes) -> User | None: | |||||||
|     return user |     return user | ||||||
|  |  | ||||||
|  |  | ||||||
| def auth_user_lookup(raw_header: bytes) -> User | None: | def auth_user_lookup(raw_header: bytes) -> Optional[User]: | ||||||
|     """raw_header in the Format of `Bearer ....`""" |     """raw_header in the Format of `Bearer ....`""" | ||||||
|     from authentik.providers.oauth2.models import AccessToken |     from authentik.providers.oauth2.models import AccessToken | ||||||
|  |  | ||||||
| @ -76,7 +74,7 @@ def auth_user_lookup(raw_header: bytes) -> User | None: | |||||||
|     raise AuthenticationFailed("Token invalid/expired") |     raise AuthenticationFailed("Token invalid/expired") | ||||||
|  |  | ||||||
|  |  | ||||||
| def token_secret_key(value: str) -> User | None: | def token_secret_key(value: str) -> Optional[User]: | ||||||
|     """Check if the token is the secret key |     """Check if the token is the secret key | ||||||
|     and return the service account for the managed outpost""" |     and return the service account for the managed outpost""" | ||||||
|     from authentik.outposts.apps import MANAGED_OUTPOST |     from authentik.outposts.apps import MANAGED_OUTPOST | ||||||
| @ -103,14 +101,3 @@ class TokenAuthentication(BaseAuthentication): | |||||||
|             return None |             return None | ||||||
|  |  | ||||||
|         return (user, None)  # pragma: no cover |         return (user, None)  # pragma: no cover | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenSchema(OpenApiAuthenticationExtension): |  | ||||||
|     """Auth schema""" |  | ||||||
|  |  | ||||||
|     target_class = TokenAuthentication |  | ||||||
|     name = "authentik" |  | ||||||
|  |  | ||||||
|     def get_security_definition(self, auto_schema): |  | ||||||
|         """Auth schema""" |  | ||||||
|         return {"type": "http", "scheme": "bearer"} |  | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """API Authorization""" | """API Authorization""" | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.query import QuerySet | from django.db.models.query import QuerySet | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """API Decorators""" | """API Decorators""" | ||||||
| 
 |  | ||||||
| from collections.abc import Callable |  | ||||||
| from functools import wraps | from functools import wraps | ||||||
|  | from typing import Callable, Optional | ||||||
| 
 | 
 | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @ -11,26 +10,21 @@ from structlog.stdlib import get_logger | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def permission_required(obj_perm: str | None = None, global_perms: list[str] | None = None): | def permission_required(obj_perm: Optional[str] = None, global_perms: Optional[list[str]] = None): | ||||||
|     """Check permissions for a single custom action""" |     """Check permissions for a single custom action""" | ||||||
| 
 | 
 | ||||||
|     def _check_obj_perm(self: ModelViewSet, request: Request): |     def wrapper_outter(func: Callable): | ||||||
|         # Check obj_perm both globally and on the specific object |  | ||||||
|         # Having the global permission has higher priority |  | ||||||
|         if request.user.has_perm(obj_perm): |  | ||||||
|             return |  | ||||||
|         obj = self.get_object() |  | ||||||
|         if not request.user.has_perm(obj_perm, obj): |  | ||||||
|             LOGGER.debug("denying access for object", user=request.user, perm=obj_perm, obj=obj) |  | ||||||
|             self.permission_denied(request) |  | ||||||
| 
 |  | ||||||
|     def wrapper_outer(func: Callable): |  | ||||||
|         """Check permissions for a single custom action""" |         """Check permissions for a single custom action""" | ||||||
| 
 | 
 | ||||||
|         @wraps(func) |         @wraps(func) | ||||||
|         def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response: |         def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response: | ||||||
|             if obj_perm: |             if obj_perm: | ||||||
|                 _check_obj_perm(self, request) |                 obj = self.get_object() | ||||||
|  |                 if not request.user.has_perm(obj_perm, obj): | ||||||
|  |                     LOGGER.debug( | ||||||
|  |                         "denying access for object", user=request.user, perm=obj_perm, obj=obj | ||||||
|  |                     ) | ||||||
|  |                     return self.permission_denied(request) | ||||||
|             if global_perms: |             if global_perms: | ||||||
|                 for other_perm in global_perms: |                 for other_perm in global_perms: | ||||||
|                     if not request.user.has_perm(other_perm): |                     if not request.user.has_perm(other_perm): | ||||||
| @ -40,4 +34,4 @@ def permission_required(obj_perm: str | None = None, global_perms: list[str] | N | |||||||
| 
 | 
 | ||||||
|         return wrapper |         return wrapper | ||||||
| 
 | 
 | ||||||
|     return wrapper_outer |     return wrapper_outter | ||||||
| @ -1,5 +1,4 @@ | |||||||
| """Pagination which includes total pages and current page""" | """Pagination which includes total pages and current page""" | ||||||
|  |  | ||||||
| from rest_framework import pagination | from rest_framework import pagination | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224""" | """Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224""" | ||||||
|  |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_spectacular.generators import SchemaGenerator | from drf_spectacular.generators import SchemaGenerator | ||||||
| from drf_spectacular.plumbing import ( | from drf_spectacular.plumbing import ( | ||||||
| @ -12,7 +11,6 @@ from drf_spectacular.settings import spectacular_settings | |||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from rest_framework.settings import api_settings | from rest_framework.settings import api_settings | ||||||
|  |  | ||||||
| from authentik.api.apps import AuthentikAPIConfig |  | ||||||
| from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA | from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -102,12 +100,3 @@ def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs): | |||||||
|             comp = result["components"]["schemas"][component] |             comp = result["components"]["schemas"][component] | ||||||
|             comp["additionalProperties"] = {} |             comp["additionalProperties"] = {} | ||||||
|     return result |     return result | ||||||
|  |  | ||||||
|  |  | ||||||
| def preprocess_schema_exclude_non_api(endpoints, **kwargs): |  | ||||||
|     """Filter out all API Views which are not mounted under /api""" |  | ||||||
|     return [ |  | ||||||
|         (path, path_regex, method, callback) |  | ||||||
|         for path, path_regex, method, callback in endpoints |  | ||||||
|         if path.startswith("/" + AuthentikAPIConfig.mountpoint) |  | ||||||
|     ] |  | ||||||
|  | |||||||
| @ -1,13 +1,13 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
|  |  | ||||||
| {% load authentik_core %} | {% load static %} | ||||||
|  |  | ||||||
| {% block title %} | {% block title %} | ||||||
| API Browser - {{ brand.branding_title }} | API Browser - {{ tenant.branding_title }} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| {% versioned_script "dist/standalone/api-browser/index-%v.js" %} | <script src="{% static 'dist/standalone/api-browser/index.js' %}?version={{ version }}" type="module"></script> | ||||||
| <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> | ||||||
| <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test API Authentication""" | """Test API Authentication""" | ||||||
|  |  | ||||||
| import json | import json | ||||||
| from base64 import b64encode | from base64 import b64encode | ||||||
|  |  | ||||||
| @ -25,17 +24,17 @@ class TestAPIAuth(TestCase): | |||||||
|     def test_invalid_type(self): |     def test_invalid_type(self): | ||||||
|         """Test invalid type""" |         """Test invalid type""" | ||||||
|         with self.assertRaises(AuthenticationFailed): |         with self.assertRaises(AuthenticationFailed): | ||||||
|             bearer_auth(b"foo bar") |             bearer_auth("foo bar".encode()) | ||||||
|  |  | ||||||
|     def test_invalid_empty(self): |     def test_invalid_empty(self): | ||||||
|         """Test invalid type""" |         """Test invalid type""" | ||||||
|         self.assertIsNone(bearer_auth(b"Bearer ")) |         self.assertIsNone(bearer_auth("Bearer ".encode())) | ||||||
|         self.assertIsNone(bearer_auth(b"")) |         self.assertIsNone(bearer_auth("".encode())) | ||||||
|  |  | ||||||
|     def test_invalid_no_token(self): |     def test_invalid_no_token(self): | ||||||
|         """Test invalid with no token""" |         """Test invalid with no token""" | ||||||
|         with self.assertRaises(AuthenticationFailed): |         with self.assertRaises(AuthenticationFailed): | ||||||
|             auth = b64encode(b":abc").decode() |             auth = b64encode(":abc".encode()).decode() | ||||||
|             self.assertIsNone(bearer_auth(f"Basic :{auth}".encode())) |             self.assertIsNone(bearer_auth(f"Basic :{auth}".encode())) | ||||||
|  |  | ||||||
|     def test_bearer_valid(self): |     def test_bearer_valid(self): | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test config API""" | """Test config API""" | ||||||
|  |  | ||||||
| from json import loads | from json import loads | ||||||
|  |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  | |||||||
							
								
								
									
										34
									
								
								authentik/api/tests/test_decorators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								authentik/api/tests/test_decorators.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | |||||||
|  | """test decorators api""" | ||||||
|  | from django.urls import reverse | ||||||
|  | from guardian.shortcuts import assign_perm | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import Application, User | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestAPIDecorators(APITestCase): | ||||||
|  |     """test decorators api""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         super().setUp() | ||||||
|  |         self.user = User.objects.create(username="test-user") | ||||||
|  |  | ||||||
|  |     def test_obj_perm_denied(self): | ||||||
|  |         """Test object perm denied""" | ||||||
|  |         self.client.force_login(self.user) | ||||||
|  |         app = Application.objects.create(name=generate_id(), slug=generate_id()) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |     def test_other_perm_denied(self): | ||||||
|  |         """Test other perm denied""" | ||||||
|  |         self.client.force_login(self.user) | ||||||
|  |         app = Application.objects.create(name=generate_id(), slug=generate_id()) | ||||||
|  |         assign_perm("authentik_core.view_application", self.user, app) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
| @ -1,5 +1,4 @@ | |||||||
| """Schema generation tests""" | """Schema generation tests""" | ||||||
|  |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
| from yaml import safe_load | from yaml import safe_load | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """authentik API Modelviewset tests""" | """authentik API Modelviewset tests""" | ||||||
|  | from typing import Callable | ||||||
| from collections.abc import Callable |  | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | ||||||
| @ -26,6 +25,6 @@ def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable: | |||||||
|  |  | ||||||
|  |  | ||||||
| for _, viewset, _ in router.registry: | for _, viewset, _ in router.registry: | ||||||
|     if not issubclass(viewset, ModelViewSet | ReadOnlyModelViewSet): |     if not issubclass(viewset, (ModelViewSet, ReadOnlyModelViewSet)): | ||||||
|         continue |         continue | ||||||
|     setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset)) |     setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset)) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """authentik api urls""" | """authentik api urls""" | ||||||
|  |  | ||||||
| from django.urls import include, path | from django.urls import include, path | ||||||
|  |  | ||||||
| from authentik.api.v3.urls import urlpatterns as v3_urls | from authentik.api.v3.urls import urlpatterns as v3_urls | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """core Configs API""" | """core Configs API""" | ||||||
|  |  | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| @ -68,16 +67,12 @@ class ConfigView(APIView): | |||||||
|         """Get all capabilities this server instance supports""" |         """Get all capabilities this server instance supports""" | ||||||
|         caps = [] |         caps = [] | ||||||
|         deb_test = settings.DEBUG or settings.TEST |         deb_test = settings.DEBUG or settings.TEST | ||||||
|         if ( |         if Path(settings.MEDIA_ROOT).is_mount() or deb_test: | ||||||
|             CONFIG.get("storage.media.backend", "file") == "s3" |  | ||||||
|             or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount() |  | ||||||
|             or deb_test |  | ||||||
|         ): |  | ||||||
|             caps.append(Capabilities.CAN_SAVE_MEDIA) |             caps.append(Capabilities.CAN_SAVE_MEDIA) | ||||||
|         for processor in get_context_processors(): |         for processor in get_context_processors(): | ||||||
|             if cap := processor.capability(): |             if cap := processor.capability(): | ||||||
|                 caps.append(cap) |                 caps.append(cap) | ||||||
|         if self.request.tenant.impersonation: |         if CONFIG.get_bool("impersonation"): | ||||||
|             caps.append(Capabilities.CAN_IMPERSONATE) |             caps.append(Capabilities.CAN_IMPERSONATE) | ||||||
|         if settings.DEBUG:  # pragma: no cover |         if settings.DEBUG:  # pragma: no cover | ||||||
|             caps.append(Capabilities.CAN_DEBUG) |             caps.append(Capabilities.CAN_DEBUG) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """api v3 urls""" | """api v3 urls""" | ||||||
|  |  | ||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.urls import path | from django.urls import path | ||||||
| @ -33,7 +32,7 @@ for _authentik_app in get_apps(): | |||||||
|             app_name=_authentik_app.name, |             app_name=_authentik_app.name, | ||||||
|         ) |         ) | ||||||
|         continue |         continue | ||||||
|     urls: list = api_urls.api_urlpatterns |     urls: list = getattr(api_urls, "api_urlpatterns") | ||||||
|     for url in urls: |     for url in urls: | ||||||
|         if isinstance(url, URLPattern): |         if isinstance(url, URLPattern): | ||||||
|             _other_urls.append(url) |             _other_urls.append(url) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """General API Views""" | """General API Views""" | ||||||
|  |  | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Serializer mixin for managed models""" | """Serializer mixin for managed models""" | ||||||
|  |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_spectacular.utils import extend_schema, inline_serializer | from drf_spectacular.utils import extend_schema, inline_serializer | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| @ -10,13 +9,13 @@ from rest_framework.response import Response | |||||||
| from rest_framework.serializers import ListSerializer, ModelSerializer | from rest_framework.serializers import ListSerializer, ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.api.decorators import permission_required | ||||||
| from authentik.blueprints.models import BlueprintInstance | from authentik.blueprints.models import BlueprintInstance | ||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
| from authentik.blueprints.v1.oci import OCI_PREFIX | from authentik.blueprints.v1.oci import OCI_PREFIX | ||||||
| from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict | from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import JSONDictField, PassiveSerializer | from authentik.core.api.utils import JSONDictField, PassiveSerializer | ||||||
| from authentik.rbac.decorators import permission_required |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ManagedSerializer: | class ManagedSerializer: | ||||||
| @ -52,9 +51,7 @@ class BlueprintInstanceSerializer(ModelSerializer): | |||||||
|         valid, logs = Importer.from_string(content, context).validate() |         valid, logs = Importer.from_string(content, context).validate() | ||||||
|         if not valid: |         if not valid: | ||||||
|             text_logs = "\n".join([x["event"] for x in logs]) |             text_logs = "\n".join([x["event"] for x in logs]) | ||||||
|             raise ValidationError( |             raise ValidationError(_("Failed to validate blueprint: %(logs)s" % {"logs": text_logs})) | ||||||
|                 _("Failed to validate blueprint: {logs}".format_map({"logs": text_logs})) |  | ||||||
|             ) |  | ||||||
|         return content |         return content | ||||||
|  |  | ||||||
|     def validate(self, attrs: dict) -> dict: |     def validate(self, attrs: dict) -> dict: | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """authentik Blueprints app""" | """authentik Blueprints app""" | ||||||
|  |  | ||||||
| from collections.abc import Callable |  | ||||||
| from importlib import import_module | from importlib import import_module | ||||||
| from inspect import ismethod | from inspect import ismethod | ||||||
|  |  | ||||||
| @ -8,100 +7,40 @@ from django.apps import AppConfig | |||||||
| from django.db import DatabaseError, InternalError, ProgrammingError | from django.db import DatabaseError, InternalError, ProgrammingError | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
|  |  | ||||||
| from authentik.root.signals import startup |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ManagedAppConfig(AppConfig): | class ManagedAppConfig(AppConfig): | ||||||
|     """Basic reconciliation logic for apps""" |     """Basic reconciliation logic for apps""" | ||||||
|  |  | ||||||
|     logger: BoundLogger |     _logger: BoundLogger | ||||||
|  |  | ||||||
|     RECONCILE_GLOBAL_CATEGORY: str = "global" |  | ||||||
|     RECONCILE_TENANT_CATEGORY: str = "tenant" |  | ||||||
|  |  | ||||||
|     def __init__(self, app_name: str, *args, **kwargs) -> None: |     def __init__(self, app_name: str, *args, **kwargs) -> None: | ||||||
|         super().__init__(app_name, *args, **kwargs) |         super().__init__(app_name, *args, **kwargs) | ||||||
|         self.logger = get_logger().bind(app_name=app_name) |         self._logger = get_logger().bind(app_name=app_name) | ||||||
|  |  | ||||||
|     def ready(self) -> None: |     def ready(self) -> None: | ||||||
|         self.import_related() |         self.reconcile() | ||||||
|         startup.connect(self._on_startup_callback, dispatch_uid=self.label) |  | ||||||
|         return super().ready() |         return super().ready() | ||||||
|  |  | ||||||
|     def _on_startup_callback(self, sender, **_): |  | ||||||
|         self._reconcile_global() |  | ||||||
|         self._reconcile_tenant() |  | ||||||
|  |  | ||||||
|     def import_related(self): |  | ||||||
|         """Automatically import related modules which rely on just being imported |  | ||||||
|         to register themselves (mainly django signals and celery tasks)""" |  | ||||||
|  |  | ||||||
|         def import_relative(rel_module: str): |  | ||||||
|             try: |  | ||||||
|                 module_name = f"{self.name}.{rel_module}" |  | ||||||
|                 import_module(module_name) |  | ||||||
|                 self.logger.info("Imported related module", module=module_name) |  | ||||||
|             except ModuleNotFoundError: |  | ||||||
|                 pass |  | ||||||
|  |  | ||||||
|         import_relative("checks") |  | ||||||
|         import_relative("tasks") |  | ||||||
|         import_relative("signals") |  | ||||||
|  |  | ||||||
|     def import_module(self, path: str): |     def import_module(self, path: str): | ||||||
|         """Load module""" |         """Load module""" | ||||||
|         import_module(path) |         import_module(path) | ||||||
|  |  | ||||||
|     def _reconcile(self, prefix: str) -> None: |     def reconcile(self) -> None: | ||||||
|  |         """reconcile ourselves""" | ||||||
|  |         prefix = "reconcile_" | ||||||
|         for meth_name in dir(self): |         for meth_name in dir(self): | ||||||
|             meth = getattr(self, meth_name) |             meth = getattr(self, meth_name) | ||||||
|             if not ismethod(meth): |             if not ismethod(meth): | ||||||
|                 continue |                 continue | ||||||
|             category = getattr(meth, "_authentik_managed_reconcile", None) |             if not meth_name.startswith(prefix): | ||||||
|             if category != prefix: |  | ||||||
|                 continue |                 continue | ||||||
|             name = meth_name.replace(prefix, "") |             name = meth_name.replace(prefix, "") | ||||||
|             try: |             try: | ||||||
|                 self.logger.debug("Starting reconciler", name=name) |                 self._logger.debug("Starting reconciler", name=name) | ||||||
|                 meth() |                 meth() | ||||||
|                 self.logger.debug("Successfully reconciled", name=name) |                 self._logger.debug("Successfully reconciled", name=name) | ||||||
|             except (DatabaseError, ProgrammingError, InternalError) as exc: |             except (DatabaseError, ProgrammingError, InternalError) as exc: | ||||||
|                 self.logger.warning("Failed to run reconcile", name=name, exc=exc) |                 self._logger.warning("Failed to run reconcile", name=name, exc=exc) | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def reconcile_tenant(func: Callable): |  | ||||||
|         """Mark a function to be called on startup (for each tenant)""" |  | ||||||
|         func._authentik_managed_reconcile = ManagedAppConfig.RECONCILE_TENANT_CATEGORY |  | ||||||
|         return func |  | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def reconcile_global(func: Callable): |  | ||||||
|         """Mark a function to be called on startup (globally)""" |  | ||||||
|         func._authentik_managed_reconcile = ManagedAppConfig.RECONCILE_GLOBAL_CATEGORY |  | ||||||
|         return func |  | ||||||
|  |  | ||||||
|     def _reconcile_tenant(self) -> None: |  | ||||||
|         """reconcile ourselves for tenanted methods""" |  | ||||||
|         from authentik.tenants.models import Tenant |  | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             tenants = list(Tenant.objects.filter(ready=True)) |  | ||||||
|         except (DatabaseError, ProgrammingError, InternalError) as exc: |  | ||||||
|             self.logger.debug("Failed to get tenants to run reconcile", exc=exc) |  | ||||||
|             return |  | ||||||
|         for tenant in tenants: |  | ||||||
|             with tenant: |  | ||||||
|                 self._reconcile(self.RECONCILE_TENANT_CATEGORY) |  | ||||||
|  |  | ||||||
|     def _reconcile_global(self) -> None: |  | ||||||
|         """ |  | ||||||
|         reconcile ourselves for global methods. |  | ||||||
|         Used for signals, tasks, etc. Database queries should not be made in here. |  | ||||||
|         """ |  | ||||||
|         from django_tenants.utils import get_public_schema_name, schema_context |  | ||||||
|  |  | ||||||
|         with schema_context(get_public_schema_name()): |  | ||||||
|             self._reconcile(self.RECONCILE_GLOBAL_CATEGORY) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikBlueprintsConfig(ManagedAppConfig): | class AuthentikBlueprintsConfig(ManagedAppConfig): | ||||||
| @ -112,13 +51,11 @@ class AuthentikBlueprintsConfig(ManagedAppConfig): | |||||||
|     verbose_name = "authentik Blueprints" |     verbose_name = "authentik Blueprints" | ||||||
|     default = True |     default = True | ||||||
|  |  | ||||||
|     @ManagedAppConfig.reconcile_global |     def reconcile_load_blueprints_v1_tasks(self): | ||||||
|     def load_blueprints_v1_tasks(self): |  | ||||||
|         """Load v1 tasks""" |         """Load v1 tasks""" | ||||||
|         self.import_module("authentik.blueprints.v1.tasks") |         self.import_module("authentik.blueprints.v1.tasks") | ||||||
|  |  | ||||||
|     @ManagedAppConfig.reconcile_tenant |     def reconcile_blueprints_discovery(self): | ||||||
|     def blueprints_discovery(self): |  | ||||||
|         """Run blueprint discovery""" |         """Run blueprint discovery""" | ||||||
|         from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints |         from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Apply blueprint from commandline""" | """Apply blueprint from commandline""" | ||||||
|  |  | ||||||
| from sys import exit as sys_exit | from sys import exit as sys_exit | ||||||
|  |  | ||||||
| from django.core.management.base import BaseCommand, no_translations | from django.core.management.base import BaseCommand, no_translations | ||||||
| @ -7,7 +6,6 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.blueprints.models import BlueprintInstance | from authentik.blueprints.models import BlueprintInstance | ||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
| from authentik.tenants.models import Tenant |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -18,16 +16,14 @@ class Command(BaseCommand): | |||||||
|     @no_translations |     @no_translations | ||||||
|     def handle(self, *args, **options): |     def handle(self, *args, **options): | ||||||
|         """Apply all blueprints in order, abort when one fails to import""" |         """Apply all blueprints in order, abort when one fails to import""" | ||||||
|         for tenant in Tenant.objects.filter(ready=True): |         for blueprint_path in options.get("blueprints", []): | ||||||
|             with tenant: |             content = BlueprintInstance(path=blueprint_path).retrieve() | ||||||
|                 for blueprint_path in options.get("blueprints", []): |             importer = Importer.from_string(content) | ||||||
|                     content = BlueprintInstance(path=blueprint_path).retrieve() |             valid, _ = importer.validate() | ||||||
|                     importer = Importer.from_string(content) |             if not valid: | ||||||
|                     valid, _ = importer.validate() |                 self.stderr.write("blueprint invalid") | ||||||
|                     if not valid: |                 sys_exit(1) | ||||||
|                         self.stderr.write("blueprint invalid") |             importer.apply() | ||||||
|                         sys_exit(1) |  | ||||||
|                     importer.apply() |  | ||||||
|  |  | ||||||
|     def add_arguments(self, parser): |     def add_arguments(self, parser): | ||||||
|         parser.add_argument("blueprints", nargs="+", type=str) |         parser.add_argument("blueprints", nargs="+", type=str) | ||||||
|  | |||||||
| @ -1,19 +1,17 @@ | |||||||
| """Export blueprint of current authentik install""" | """Export blueprint of current authentik install""" | ||||||
|  | from django.core.management.base import BaseCommand, no_translations | ||||||
| from django.core.management.base import no_translations |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.blueprints.v1.exporter import Exporter | from authentik.blueprints.v1.exporter import Exporter | ||||||
| from authentik.tenants.management import TenantCommand |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class Command(TenantCommand): | class Command(BaseCommand): | ||||||
|     """Export blueprint of current authentik install""" |     """Export blueprint of current authentik install""" | ||||||
|  |  | ||||||
|     @no_translations |     @no_translations | ||||||
|     def handle_per_tenant(self, *args, **options): |     def handle(self, *args, **options): | ||||||
|         """Export blueprint of current authentik install""" |         """Export blueprint of current authentik install""" | ||||||
|         exporter = Exporter() |         exporter = Exporter() | ||||||
|         self.stdout.write(exporter.export_to_string()) |         self.stdout.write(exporter.export_to_string()) | ||||||
|  | |||||||
| @ -1,17 +1,14 @@ | |||||||
| """Generate JSON Schema for blueprints""" | """Generate JSON Schema for blueprints""" | ||||||
|  |  | ||||||
| from json import dumps | from json import dumps | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.core.management.base import BaseCommand, no_translations | from django.core.management.base import BaseCommand, no_translations | ||||||
| from django.db.models import Model, fields | from django.db.models import Model | ||||||
| from drf_jsonschema_serializer.convert import converter, field_to_converter | from drf_jsonschema_serializer.convert import field_to_converter | ||||||
| from rest_framework.fields import Field, JSONField, UUIDField | from rest_framework.fields import Field, JSONField, UUIDField | ||||||
| from rest_framework.relations import PrimaryKeyRelatedField |  | ||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik import __version__ |  | ||||||
| from authentik.blueprints.v1.common import BlueprintEntryDesiredState | from authentik.blueprints.v1.common import BlueprintEntryDesiredState | ||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, is_model_allowed | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, is_model_allowed | ||||||
| from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry | from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry | ||||||
| @ -20,23 +17,6 @@ from authentik.lib.models import SerializerModel | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| @converter |  | ||||||
| class PrimaryKeyRelatedFieldConverter: |  | ||||||
|     """Custom primary key field converter which is aware of non-integer based PKs |  | ||||||
|  |  | ||||||
|     This is not an exhaustive fix for other non-int PKs, however in authentik we either |  | ||||||
|     use UUIDs or ints""" |  | ||||||
|  |  | ||||||
|     field_class = PrimaryKeyRelatedField |  | ||||||
|  |  | ||||||
|     def convert(self, field: PrimaryKeyRelatedField): |  | ||||||
|         model: Model = field.queryset.model |  | ||||||
|         pk_field = model._meta.pk |  | ||||||
|         if isinstance(pk_field, fields.UUIDField): |  | ||||||
|             return {"type": "string", "format": "uuid"} |  | ||||||
|         return {"type": "integer"} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||||
|     """Generate JSON Schema for blueprints""" |     """Generate JSON Schema for blueprints""" | ||||||
|  |  | ||||||
| @ -48,7 +28,7 @@ class Command(BaseCommand): | |||||||
|             "$schema": "http://json-schema.org/draft-07/schema", |             "$schema": "http://json-schema.org/draft-07/schema", | ||||||
|             "$id": "https://goauthentik.io/blueprints/schema.json", |             "$id": "https://goauthentik.io/blueprints/schema.json", | ||||||
|             "type": "object", |             "type": "object", | ||||||
|             "title": f"authentik {__version__} Blueprint schema", |             "title": "authentik Blueprint schema", | ||||||
|             "required": ["version", "entries"], |             "required": ["version", "entries"], | ||||||
|             "properties": { |             "properties": { | ||||||
|                 "version": { |                 "version": { | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM | |||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
|  |  | ||||||
|  |  | ||||||
| def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path): | def check_blueprint_v1_file(BlueprintInstance: type, path: Path): | ||||||
|     """Check if blueprint should be imported""" |     """Check if blueprint should be imported""" | ||||||
|     from authentik.blueprints.models import BlueprintInstanceStatus |     from authentik.blueprints.models import BlueprintInstanceStatus | ||||||
|     from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata |     from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata | ||||||
| @ -29,9 +29,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path): | |||||||
|         if version != 1: |         if version != 1: | ||||||
|             return |             return | ||||||
|         blueprint_file.seek(0) |         blueprint_file.seek(0) | ||||||
|     instance: BlueprintInstance = ( |     instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first() | ||||||
|         BlueprintInstance.objects.using(db_alias).filter(path=path).first() |  | ||||||
|     ) |  | ||||||
|     rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir"))) |     rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir"))) | ||||||
|     meta = None |     meta = None | ||||||
|     if metadata: |     if metadata: | ||||||
| @ -39,7 +37,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path): | |||||||
|         if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false": |         if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false": | ||||||
|             return |             return | ||||||
|     if not instance: |     if not instance: | ||||||
|         BlueprintInstance.objects.using(db_alias).create( |         instance = BlueprintInstance( | ||||||
|             name=meta.name if meta else str(rel_path), |             name=meta.name if meta else str(rel_path), | ||||||
|             path=str(rel_path), |             path=str(rel_path), | ||||||
|             context={}, |             context={}, | ||||||
| @ -49,6 +47,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path): | |||||||
|             last_applied_hash="", |             last_applied_hash="", | ||||||
|             metadata=metadata or {}, |             metadata=metadata or {}, | ||||||
|         ) |         ) | ||||||
|  |         instance.save() | ||||||
|  |  | ||||||
|  |  | ||||||
| def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
| @ -57,7 +56,7 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit | |||||||
|  |  | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|     for file in glob(f"{CONFIG.get('blueprints_dir')}/**/*.yaml", recursive=True): |     for file in glob(f"{CONFIG.get('blueprints_dir')}/**/*.yaml", recursive=True): | ||||||
|         check_blueprint_v1_file(BlueprintInstance, db_alias, Path(file)) |         check_blueprint_v1_file(BlueprintInstance, Path(file)) | ||||||
|  |  | ||||||
|     for blueprint in BlueprintInstance.objects.using(db_alias).all(): |     for blueprint in BlueprintInstance.objects.using(db_alias).all(): | ||||||
|         # If we already have flows (and we should always run before flow migrations) |         # If we already have flows (and we should always run before flow migrations) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """blueprint models""" | """blueprint models""" | ||||||
|  |  | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| @ -71,19 +70,6 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel): | |||||||
|     enabled = models.BooleanField(default=True) |     enabled = models.BooleanField(default=True) | ||||||
|     managed_models = ArrayField(models.TextField(), default=list) |     managed_models = ArrayField(models.TextField(), default=list) | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Blueprint Instance") |  | ||||||
|         verbose_name_plural = _("Blueprint Instances") |  | ||||||
|         unique_together = ( |  | ||||||
|             ( |  | ||||||
|                 "name", |  | ||||||
|                 "path", |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Blueprint Instance {self.name}" |  | ||||||
|  |  | ||||||
|     def retrieve_oci(self) -> str: |     def retrieve_oci(self) -> str: | ||||||
|         """Get blueprint from an OCI registry""" |         """Get blueprint from an OCI registry""" | ||||||
|         client = BlueprintOCIClient(self.path.replace(OCI_PREFIX, "https://")) |         client = BlueprintOCIClient(self.path.replace(OCI_PREFIX, "https://")) | ||||||
| @ -102,7 +88,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel): | |||||||
|                 raise BlueprintRetrievalFailed("Invalid blueprint path") |                 raise BlueprintRetrievalFailed("Invalid blueprint path") | ||||||
|             with full_path.open("r", encoding="utf-8") as _file: |             with full_path.open("r", encoding="utf-8") as _file: | ||||||
|                 return _file.read() |                 return _file.read() | ||||||
|         except OSError as exc: |         except (IOError, OSError) as exc: | ||||||
|             raise BlueprintRetrievalFailed(exc) from exc |             raise BlueprintRetrievalFailed(exc) from exc | ||||||
|  |  | ||||||
|     def retrieve(self) -> str: |     def retrieve(self) -> str: | ||||||
| @ -118,3 +104,16 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel): | |||||||
|         from authentik.blueprints.api import BlueprintInstanceSerializer |         from authentik.blueprints.api import BlueprintInstanceSerializer | ||||||
|  |  | ||||||
|         return BlueprintInstanceSerializer |         return BlueprintInstanceSerializer | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"Blueprint Instance {self.name}" | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _("Blueprint Instance") | ||||||
|  |         verbose_name_plural = _("Blueprint Instances") | ||||||
|  |         unique_together = ( | ||||||
|  |             ( | ||||||
|  |                 "name", | ||||||
|  |                 "path", | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """blueprint Settings""" | """blueprint Settings""" | ||||||
|  |  | ||||||
| from celery.schedules import crontab | from celery.schedules import crontab | ||||||
|  |  | ||||||
| from authentik.lib.utils.time import fqdn_rand | from authentik.lib.utils.time import fqdn_rand | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """Blueprint helpers""" | """Blueprint helpers""" | ||||||
|  |  | ||||||
| from collections.abc import Callable |  | ||||||
| from functools import wraps | from functools import wraps | ||||||
|  | from typing import Callable | ||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
|  |  | ||||||
| @ -39,7 +38,7 @@ def reconcile_app(app_name: str): | |||||||
|         def wrapper(*args, **kwargs): |         def wrapper(*args, **kwargs): | ||||||
|             config = apps.get_app_config(app_name) |             config = apps.get_app_config(app_name) | ||||||
|             if isinstance(config, ManagedAppConfig): |             if isinstance(config, ManagedAppConfig): | ||||||
|                 config._on_startup_callback(None) |                 config.reconcile() | ||||||
|             return func(*args, **kwargs) |             return func(*args, **kwargs) | ||||||
|  |  | ||||||
|         return wrapper |         return wrapper | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """authentik managed models tests""" | """authentik managed models tests""" | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  |  | ||||||
| from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed | from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test blueprints OCI""" | """Test blueprints OCI""" | ||||||
|  |  | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
| from requests_mock import Mocker | from requests_mock import Mocker | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,23 +1,22 @@ | |||||||
| """test packaged blueprints""" | """test packaged blueprints""" | ||||||
|  |  | ||||||
| from collections.abc import Callable |  | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  | from typing import Callable | ||||||
|  |  | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
|  |  | ||||||
| from authentik.blueprints.models import BlueprintInstance | from authentik.blueprints.models import BlueprintInstance | ||||||
| from authentik.blueprints.tests import apply_blueprint | from authentik.blueprints.tests import apply_blueprint | ||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
| from authentik.brands.models import Brand | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestPackaged(TransactionTestCase): | class TestPackaged(TransactionTestCase): | ||||||
|     """Empty class, test methods are added dynamically""" |     """Empty class, test methods are added dynamically""" | ||||||
|  |  | ||||||
|     @apply_blueprint("default/default-brand.yaml") |     @apply_blueprint("default/default-tenant.yaml") | ||||||
|     def test_decorator_static(self): |     def test_decorator_static(self): | ||||||
|         """Test @apply_blueprint decorator""" |         """Test @apply_blueprint decorator""" | ||||||
|         self.assertTrue(Brand.objects.filter(domain="authentik-default").exists()) |         self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists()) | ||||||
|  |  | ||||||
|  |  | ||||||
| def blueprint_tester(file_name: Path) -> Callable: | def blueprint_tester(file_name: Path) -> Callable: | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """authentik managed models tests""" | """authentik managed models tests""" | ||||||
|  | from typing import Callable, Type | ||||||
| from collections.abc import Callable |  | ||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| @ -14,7 +13,7 @@ class TestModels(TestCase): | |||||||
|     """Test Models""" |     """Test Models""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def serializer_tester_factory(test_model: type[SerializerModel]) -> Callable: | def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable: | ||||||
|     """Test serializer""" |     """Test serializer""" | ||||||
|  |  | ||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test blueprints v1""" | """Test blueprints v1""" | ||||||
|  |  | ||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test blueprints v1 api""" | """Test blueprints v1 api""" | ||||||
|  |  | ||||||
| from json import loads | from json import loads | ||||||
| from tempfile import NamedTemporaryFile, mkdtemp | from tempfile import NamedTemporaryFile, mkdtemp | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test blueprints v1""" | """Test blueprints v1""" | ||||||
|  |  | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
|  |  | ||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test blueprints v1""" | """Test blueprints v1""" | ||||||
|  |  | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
|  |  | ||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test blueprints v1""" | """Test blueprints v1""" | ||||||
|  |  | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
|  |  | ||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Test blueprints v1 tasks""" | """Test blueprints v1 tasks""" | ||||||
|  |  | ||||||
| from hashlib import sha512 | from hashlib import sha512 | ||||||
| from tempfile import NamedTemporaryFile, mkdtemp | from tempfile import NamedTemporaryFile, mkdtemp | ||||||
|  |  | ||||||
| @ -54,7 +53,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase): | |||||||
|             file.seek(0) |             file.seek(0) | ||||||
|             file_hash = sha512(file.read().encode()).hexdigest() |             file_hash = sha512(file.read().encode()).hexdigest() | ||||||
|             file.flush() |             file.flush() | ||||||
|             blueprints_discovery() |             blueprints_discovery()  # pylint: disable=no-value-for-parameter | ||||||
|             instance = BlueprintInstance.objects.filter(name=blueprint_id).first() |             instance = BlueprintInstance.objects.filter(name=blueprint_id).first() | ||||||
|             self.assertEqual(instance.last_applied_hash, file_hash) |             self.assertEqual(instance.last_applied_hash, file_hash) | ||||||
|             self.assertEqual( |             self.assertEqual( | ||||||
| @ -82,7 +81,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase): | |||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             file.flush() |             file.flush() | ||||||
|             blueprints_discovery() |             blueprints_discovery()  # pylint: disable=no-value-for-parameter | ||||||
|             blueprint = BlueprintInstance.objects.filter(name="foo").first() |             blueprint = BlueprintInstance.objects.filter(name="foo").first() | ||||||
|             self.assertEqual( |             self.assertEqual( | ||||||
|                 blueprint.last_applied_hash, |                 blueprint.last_applied_hash, | ||||||
| @ -107,7 +106,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase): | |||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             file.flush() |             file.flush() | ||||||
|             blueprints_discovery() |             blueprints_discovery()  # pylint: disable=no-value-for-parameter | ||||||
|             blueprint.refresh_from_db() |             blueprint.refresh_from_db() | ||||||
|             self.assertEqual( |             self.assertEqual( | ||||||
|                 blueprint.last_applied_hash, |                 blueprint.last_applied_hash, | ||||||
| @ -149,7 +148,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase): | |||||||
|                 instance.status, |                 instance.status, | ||||||
|                 BlueprintInstanceStatus.UNKNOWN, |                 BlueprintInstanceStatus.UNKNOWN, | ||||||
|             ) |             ) | ||||||
|             apply_blueprint(instance.pk) |             apply_blueprint(instance.pk)  # pylint: disable=no-value-for-parameter | ||||||
|             instance.refresh_from_db() |             instance.refresh_from_db() | ||||||
|             self.assertEqual(instance.last_applied_hash, "") |             self.assertEqual(instance.last_applied_hash, "") | ||||||
|             self.assertEqual( |             self.assertEqual( | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """API URLs""" | """API URLs""" | ||||||
|  |  | ||||||
| from authentik.blueprints.api import BlueprintInstanceViewSet | from authentik.blueprints.api import BlueprintInstanceViewSet | ||||||
|  |  | ||||||
| api_urlpatterns = [ | api_urlpatterns = [ | ||||||
|  | |||||||
| @ -1,14 +1,12 @@ | |||||||
| """transfer common classes""" | """transfer common classes""" | ||||||
|  |  | ||||||
| from collections import OrderedDict | from collections import OrderedDict | ||||||
| from collections.abc import Iterable, Mapping |  | ||||||
| from copy import copy | from copy import copy | ||||||
| from dataclasses import asdict, dataclass, field, is_dataclass | from dataclasses import asdict, dataclass, field, is_dataclass | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from functools import reduce | from functools import reduce | ||||||
| from operator import ixor | from operator import ixor | ||||||
| from os import getenv | from os import getenv | ||||||
| from typing import Any, Literal, Union | from typing import Any, Iterable, Literal, Mapping, Optional, Union | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| from deepmerge import always_merger | from deepmerge import always_merger | ||||||
| @ -46,7 +44,7 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]: | |||||||
| class BlueprintEntryState: | class BlueprintEntryState: | ||||||
|     """State of a single instance""" |     """State of a single instance""" | ||||||
|  |  | ||||||
|     instance: Model | None = None |     instance: Optional[Model] = None | ||||||
|  |  | ||||||
|  |  | ||||||
| class BlueprintEntryDesiredState(Enum): | class BlueprintEntryDesiredState(Enum): | ||||||
| @ -68,14 +66,14 @@ class BlueprintEntry: | |||||||
|     ) |     ) | ||||||
|     conditions: list[Any] = field(default_factory=list) |     conditions: list[Any] = field(default_factory=list) | ||||||
|     identifiers: dict[str, Any] = field(default_factory=dict) |     identifiers: dict[str, Any] = field(default_factory=dict) | ||||||
|     attrs: dict[str, Any] | None = field(default_factory=dict) |     attrs: Optional[dict[str, Any]] = field(default_factory=dict) | ||||||
|  |  | ||||||
|     id: str | None = None |     id: Optional[str] = None | ||||||
|  |  | ||||||
|     _state: BlueprintEntryState = field(default_factory=BlueprintEntryState) |     _state: BlueprintEntryState = field(default_factory=BlueprintEntryState) | ||||||
|  |  | ||||||
|     def __post_init__(self, *args, **kwargs) -> None: |     def __post_init__(self, *args, **kwargs) -> None: | ||||||
|         self.__tag_contexts: list[YAMLTagContext] = [] |         self.__tag_contexts: list["YAMLTagContext"] = [] | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry": |     def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry": | ||||||
| @ -93,10 +91,10 @@ class BlueprintEntry: | |||||||
|             attrs=all_attrs, |             attrs=all_attrs, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def get_tag_context( |     def _get_tag_context( | ||||||
|         self, |         self, | ||||||
|         depth: int = 0, |         depth: int = 0, | ||||||
|         context_tag_type: type["YAMLTagContext"] | tuple["YAMLTagContext", ...] | None = None, |         context_tag_type: Optional[type["YAMLTagContext"] | tuple["YAMLTagContext", ...]] = None, | ||||||
|     ) -> "YAMLTagContext": |     ) -> "YAMLTagContext": | ||||||
|         """Get a YAMLTagContext object located at a certain depth in the tag tree""" |         """Get a YAMLTagContext object located at a certain depth in the tag tree""" | ||||||
|         if depth < 0: |         if depth < 0: | ||||||
| @ -109,8 +107,8 @@ class BlueprintEntry: | |||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             return contexts[-(depth + 1)] |             return contexts[-(depth + 1)] | ||||||
|         except IndexError as exc: |         except IndexError: | ||||||
|             raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}") from exc |             raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}") | ||||||
|  |  | ||||||
|     def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any: |     def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any: | ||||||
|         """Check if we have any special tags that need handling""" |         """Check if we have any special tags that need handling""" | ||||||
| @ -171,7 +169,7 @@ class Blueprint: | |||||||
|     entries: list[BlueprintEntry] = field(default_factory=list) |     entries: list[BlueprintEntry] = field(default_factory=list) | ||||||
|     context: dict = field(default_factory=dict) |     context: dict = field(default_factory=dict) | ||||||
|  |  | ||||||
|     metadata: BlueprintMetadata | None = field(default=None) |     metadata: Optional[BlueprintMetadata] = field(default=None) | ||||||
|  |  | ||||||
|  |  | ||||||
| class YAMLTag: | class YAMLTag: | ||||||
| @ -219,7 +217,7 @@ class Env(YAMLTag): | |||||||
|     """Lookup environment variable with optional default""" |     """Lookup environment variable with optional default""" | ||||||
|  |  | ||||||
|     key: str |     key: str | ||||||
|     default: Any | None |     default: Optional[Any] | ||||||
|  |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
| @ -238,7 +236,7 @@ class Context(YAMLTag): | |||||||
|     """Lookup key from instance context""" |     """Lookup key from instance context""" | ||||||
|  |  | ||||||
|     key: str |     key: str | ||||||
|     default: Any | None |     default: Optional[Any] | ||||||
|  |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
| @ -282,7 +280,7 @@ class Format(YAMLTag): | |||||||
|         try: |         try: | ||||||
|             return self.format_string % tuple(args) |             return self.format_string % tuple(args) | ||||||
|         except TypeError as exc: |         except TypeError as exc: | ||||||
|             raise EntryInvalidError.from_entry(exc, entry) from exc |             raise EntryInvalidError.from_entry(exc, entry) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Find(YAMLTag): | class Find(YAMLTag): | ||||||
| @ -367,7 +365,7 @@ class Condition(YAMLTag): | |||||||
|             comparator = self._COMPARATORS[self.mode.upper()] |             comparator = self._COMPARATORS[self.mode.upper()] | ||||||
|             return comparator(tuple(bool(x) for x in args)) |             return comparator(tuple(bool(x) for x in args)) | ||||||
|         except (TypeError, KeyError) as exc: |         except (TypeError, KeyError) as exc: | ||||||
|             raise EntryInvalidError.from_entry(exc, entry) from exc |             raise EntryInvalidError.from_entry(exc, entry) | ||||||
|  |  | ||||||
|  |  | ||||||
| class If(YAMLTag): | class If(YAMLTag): | ||||||
| @ -399,7 +397,7 @@ class If(YAMLTag): | |||||||
|                 blueprint, |                 blueprint, | ||||||
|             ) |             ) | ||||||
|         except TypeError as exc: |         except TypeError as exc: | ||||||
|             raise EntryInvalidError.from_entry(exc, entry) from exc |             raise EntryInvalidError.from_entry(exc, entry) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Enumerate(YAMLTag, YAMLTagContext): | class Enumerate(YAMLTag, YAMLTagContext): | ||||||
| @ -413,7 +411,9 @@ class Enumerate(YAMLTag, YAMLTagContext): | |||||||
|         "SEQ": (list, lambda a, b: [*a, b]), |         "SEQ": (list, lambda a, b: [*a, b]), | ||||||
|         "MAP": ( |         "MAP": ( | ||||||
|             dict, |             dict, | ||||||
|             lambda a, b: always_merger.merge(a, {b[0]: b[1]} if isinstance(b, tuple | list) else b), |             lambda a, b: always_merger.merge( | ||||||
|  |                 a, {b[0]: b[1]} if isinstance(b, (tuple, list)) else b | ||||||
|  |             ), | ||||||
|         ), |         ), | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -455,7 +455,7 @@ class Enumerate(YAMLTag, YAMLTagContext): | |||||||
|         try: |         try: | ||||||
|             output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()] |             output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()] | ||||||
|         except KeyError as exc: |         except KeyError as exc: | ||||||
|             raise EntryInvalidError.from_entry(exc, entry) from exc |             raise EntryInvalidError.from_entry(exc, entry) | ||||||
|  |  | ||||||
|         result = output_class() |         result = output_class() | ||||||
|  |  | ||||||
| @ -483,13 +483,13 @@ class EnumeratedItem(YAMLTag): | |||||||
|  |  | ||||||
|     _SUPPORTED_CONTEXT_TAGS = (Enumerate,) |     _SUPPORTED_CONTEXT_TAGS = (Enumerate,) | ||||||
|  |  | ||||||
|     def __init__(self, _loader: "BlueprintLoader", node: ScalarNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.depth = int(node.value) |         self.depth = int(node.value) | ||||||
|  |  | ||||||
|     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: |     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|         try: |         try: | ||||||
|             context_tag: Enumerate = entry.get_tag_context( |             context_tag: Enumerate = entry._get_tag_context( | ||||||
|                 depth=self.depth, |                 depth=self.depth, | ||||||
|                 context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS, |                 context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS, | ||||||
|             ) |             ) | ||||||
| @ -499,11 +499,9 @@ class EnumeratedItem(YAMLTag): | |||||||
|                     f"{self.__class__.__name__} tags are only usable " |                     f"{self.__class__.__name__} tags are only usable " | ||||||
|                     f"inside an {Enumerate.__name__} tag", |                     f"inside an {Enumerate.__name__} tag", | ||||||
|                     entry, |                     entry, | ||||||
|                 ) from exc |                 ) | ||||||
|  |  | ||||||
|             raise EntryInvalidError.from_entry( |             raise EntryInvalidError.from_entry(f"{self.__class__.__name__} tag: {exc}", entry) | ||||||
|                 f"{self.__class__.__name__} tag: {exc}", entry |  | ||||||
|             ) from exc |  | ||||||
|  |  | ||||||
|         return context_tag.get_context(entry, blueprint) |         return context_tag.get_context(entry, blueprint) | ||||||
|  |  | ||||||
| @ -516,8 +514,8 @@ class Index(EnumeratedItem): | |||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             return context[0] |             return context[0] | ||||||
|         except IndexError as exc:  # pragma: no cover |         except IndexError:  # pragma: no cover | ||||||
|             raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc |             raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Value(EnumeratedItem): | class Value(EnumeratedItem): | ||||||
| @ -528,8 +526,8 @@ class Value(EnumeratedItem): | |||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             return context[1] |             return context[1] | ||||||
|         except IndexError as exc:  # pragma: no cover |         except IndexError:  # pragma: no cover | ||||||
|             raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc |             raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) | ||||||
|  |  | ||||||
|  |  | ||||||
| class BlueprintDumper(SafeDumper): | class BlueprintDumper(SafeDumper): | ||||||
| @ -556,11 +554,7 @@ class BlueprintDumper(SafeDumper): | |||||||
|  |  | ||||||
|             def factory(items): |             def factory(items): | ||||||
|                 final_dict = dict(items) |                 final_dict = dict(items) | ||||||
|                 # Remove internal state variables |  | ||||||
|                 final_dict.pop("_state", None) |                 final_dict.pop("_state", None) | ||||||
|                 # Future-proof to only remove the ID if we don't set a value |  | ||||||
|                 if "id" in final_dict and final_dict.get("id") is None: |  | ||||||
|                     final_dict.pop("id") |  | ||||||
|                 return final_dict |                 return final_dict | ||||||
|  |  | ||||||
|             data = asdict(data, dict_factory=factory) |             data = asdict(data, dict_factory=factory) | ||||||
| @ -587,13 +581,13 @@ class BlueprintLoader(SafeLoader): | |||||||
| class EntryInvalidError(SentryIgnoredException): | class EntryInvalidError(SentryIgnoredException): | ||||||
|     """Error raised when an entry is invalid""" |     """Error raised when an entry is invalid""" | ||||||
|  |  | ||||||
|     entry_model: str | None |     entry_model: Optional[str] | ||||||
|     entry_id: str | None |     entry_id: Optional[str] | ||||||
|     validation_error: ValidationError | None |     validation_error: Optional[ValidationError] | ||||||
|     serializer: Serializer | None = None |     serializer: Optional[Serializer] = None | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, *args: object, validation_error: ValidationError | None = None, **kwargs |         self, *args: object, validation_error: Optional[ValidationError] = None, **kwargs | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         super().__init__(*args) |         super().__init__(*args) | ||||||
|         self.entry_model = None |         self.entry_model = None | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """Blueprint exporter""" | """Blueprint exporter""" | ||||||
|  | from typing import Iterable | ||||||
| from collections.abc import Iterable |  | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| @ -8,6 +7,7 @@ from django.contrib.auth import get_user_model | |||||||
| from django.db.models import Model, Q, QuerySet | from django.db.models import Model, Q, QuerySet | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
| from yaml import dump | from yaml import dump | ||||||
|  |  | ||||||
| from authentik.blueprints.v1.common import ( | from authentik.blueprints.v1.common import ( | ||||||
| @ -48,7 +48,7 @@ class Exporter: | |||||||
|         """Return a queryset for `model`. Can be used to filter some |         """Return a queryset for `model`. Can be used to filter some | ||||||
|         objects on some models""" |         objects on some models""" | ||||||
|         if model == get_user_model(): |         if model == get_user_model(): | ||||||
|             return model.objects.exclude_anonymous() |             return model.objects.exclude(pk=get_anonymous_user().pk) | ||||||
|         return model.objects.all() |         return model.objects.all() | ||||||
|  |  | ||||||
|     def _pre_export(self, blueprint: Blueprint): |     def _pre_export(self, blueprint: Blueprint): | ||||||
| @ -59,7 +59,7 @@ class Exporter: | |||||||
|         blueprint = Blueprint() |         blueprint = Blueprint() | ||||||
|         self._pre_export(blueprint) |         self._pre_export(blueprint) | ||||||
|         blueprint.metadata = BlueprintMetadata( |         blueprint.metadata = BlueprintMetadata( | ||||||
|             name=_("authentik Export - {date}".format_map({"date": str(now())})), |             name=_("authentik Export - %(date)s" % {"date": str(now())}), | ||||||
|             labels={ |             labels={ | ||||||
|                 LABEL_AUTHENTIK_GENERATED: "true", |                 LABEL_AUTHENTIK_GENERATED: "true", | ||||||
|             }, |             }, | ||||||
| @ -74,7 +74,7 @@ class Exporter: | |||||||
|  |  | ||||||
|  |  | ||||||
| class FlowExporter(Exporter): | class FlowExporter(Exporter): | ||||||
|     """Exporter customized to only return objects related to `flow`""" |     """Exporter customised to only return objects related to `flow`""" | ||||||
|  |  | ||||||
|     flow: Flow |     flow: Flow | ||||||
|     with_policies: bool |     with_policies: bool | ||||||
|  | |||||||
| @ -1,24 +1,22 @@ | |||||||
| """Blueprint importer""" | """Blueprint importer""" | ||||||
|  |  | ||||||
| from contextlib import contextmanager | from contextlib import contextmanager | ||||||
| from copy import deepcopy | from copy import deepcopy | ||||||
| from typing import Any | from typing import Any, Optional | ||||||
|  |  | ||||||
| from dacite.config import Config | from dacite.config import Config | ||||||
| from dacite.core import from_dict | from dacite.core import from_dict | ||||||
| from dacite.exceptions import DaciteError | from dacite.exceptions import DaciteError | ||||||
| from deepmerge import always_merger | from deepmerge import always_merger | ||||||
| from django.contrib.auth.models import Permission |  | ||||||
| from django.contrib.contenttypes.models import ContentType |  | ||||||
| from django.core.exceptions import FieldError | from django.core.exceptions import FieldError | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.query_utils import Q | from django.db.models.query_utils import Q | ||||||
| from django.db.transaction import atomic | from django.db.transaction import atomic | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| from guardian.models import UserObjectPermission |  | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.serializers import BaseSerializer, Serializer | from rest_framework.serializers import BaseSerializer, Serializer | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
|  | from structlog.testing import capture_logs | ||||||
|  | from structlog.types import EventDict | ||||||
| from yaml import load | from yaml import load | ||||||
|  |  | ||||||
| from authentik.blueprints.v1.common import ( | from authentik.blueprints.v1.common import ( | ||||||
| @ -37,31 +35,14 @@ from authentik.core.models import ( | |||||||
|     Source, |     Source, | ||||||
|     UserSourceConnection, |     UserSourceConnection, | ||||||
| ) | ) | ||||||
| from authentik.enterprise.license import LicenseKey |  | ||||||
| from authentik.enterprise.models import LicenseUsage | from authentik.enterprise.models import LicenseUsage | ||||||
| from authentik.enterprise.providers.google_workspace.models import ( |  | ||||||
|     GoogleWorkspaceProviderGroup, |  | ||||||
|     GoogleWorkspaceProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import ( |  | ||||||
|     MicrosoftEntraProviderGroup, |  | ||||||
|     MicrosoftEntraProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.rac.models import ConnectionToken |  | ||||||
| from authentik.events.logs import LogEvent, capture_logs |  | ||||||
| from authentik.events.models import SystemTask |  | ||||||
| from authentik.events.utils import cleanse_dict | from authentik.events.utils import cleanse_dict | ||||||
| from authentik.flows.models import FlowToken, Stage | from authentik.flows.models import FlowToken, Stage | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.outposts.models import OutpostServiceConnection | from authentik.outposts.models import OutpostServiceConnection | ||||||
| from authentik.policies.models import Policy, PolicyBindingModel | from authentik.policies.models import Policy, PolicyBindingModel | ||||||
| from authentik.policies.reputation.models import Reputation | from authentik.providers.scim.models import SCIMGroup, SCIMUser | ||||||
| from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken |  | ||||||
| from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser |  | ||||||
| from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser |  | ||||||
| from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType |  | ||||||
| from authentik.tenants.models import Tenant |  | ||||||
|  |  | ||||||
| # Context set when the serializer is created in a blueprint context | # Context set when the serializer is created in a blueprint context | ||||||
| # Update website/developer-docs/blueprints/v1/models.md when used | # Update website/developer-docs/blueprints/v1/models.md when used | ||||||
| @ -71,17 +52,13 @@ SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry" | |||||||
| def excluded_models() -> list[type[Model]]: | def excluded_models() -> list[type[Model]]: | ||||||
|     """Return a list of all excluded models that shouldn't be exposed via API |     """Return a list of all excluded models that shouldn't be exposed via API | ||||||
|     or other means (internal only, base classes, non-used objects, etc)""" |     or other means (internal only, base classes, non-used objects, etc)""" | ||||||
|  |     # pylint: disable=imported-auth-user | ||||||
|     from django.contrib.auth.models import Group as DjangoGroup |     from django.contrib.auth.models import Group as DjangoGroup | ||||||
|     from django.contrib.auth.models import User as DjangoUser |     from django.contrib.auth.models import User as DjangoUser | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         # Django only classes |  | ||||||
|         DjangoUser, |         DjangoUser, | ||||||
|         DjangoGroup, |         DjangoGroup, | ||||||
|         ContentType, |  | ||||||
|         Permission, |  | ||||||
|         UserObjectPermission, |  | ||||||
|         # Base classes |         # Base classes | ||||||
|         Provider, |         Provider, | ||||||
|         Source, |         Source, | ||||||
| @ -94,31 +71,16 @@ def excluded_models() -> list[type[Model]]: | |||||||
|         # Classes that have other dependencies |         # Classes that have other dependencies | ||||||
|         AuthenticatedSession, |         AuthenticatedSession, | ||||||
|         # Classes which are only internally managed |         # Classes which are only internally managed | ||||||
|         # FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin |  | ||||||
|         FlowToken, |         FlowToken, | ||||||
|         LicenseUsage, |         LicenseUsage, | ||||||
|         SCIMProviderGroup, |         SCIMGroup, | ||||||
|         SCIMProviderUser, |         SCIMUser, | ||||||
|         Tenant, |  | ||||||
|         SystemTask, |  | ||||||
|         ConnectionToken, |  | ||||||
|         AuthorizationCode, |  | ||||||
|         AccessToken, |  | ||||||
|         RefreshToken, |  | ||||||
|         Reputation, |  | ||||||
|         WebAuthnDeviceType, |  | ||||||
|         SCIMSourceUser, |  | ||||||
|         SCIMSourceGroup, |  | ||||||
|         GoogleWorkspaceProviderUser, |  | ||||||
|         GoogleWorkspaceProviderGroup, |  | ||||||
|         MicrosoftEntraProviderUser, |  | ||||||
|         MicrosoftEntraProviderGroup, |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def is_model_allowed(model: type[Model]) -> bool: | def is_model_allowed(model: type[Model]) -> bool: | ||||||
|     """Check if model is allowed""" |     """Check if model is allowed""" | ||||||
|     return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel) |     return model not in excluded_models() and issubclass(model, (SerializerModel, BaseMetaModel)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class DoRollback(SentryIgnoredException): | class DoRollback(SentryIgnoredException): | ||||||
| @ -142,20 +104,16 @@ class Importer: | |||||||
|     logger: BoundLogger |     logger: BoundLogger | ||||||
|     _import: Blueprint |     _import: Blueprint | ||||||
|  |  | ||||||
|     def __init__(self, blueprint: Blueprint, context: dict | None = None): |     def __init__(self, blueprint: Blueprint, context: Optional[dict] = None): | ||||||
|         self.__pk_map: dict[Any, Model] = {} |         self.__pk_map: dict[Any, Model] = {} | ||||||
|         self._import = blueprint |         self._import = blueprint | ||||||
|         self.logger = get_logger() |         self.logger = get_logger() | ||||||
|         ctx = self.default_context() |         ctx = {} | ||||||
|         always_merger.merge(ctx, self._import.context) |         always_merger.merge(ctx, self._import.context) | ||||||
|         if context: |         if context: | ||||||
|             always_merger.merge(ctx, context) |             always_merger.merge(ctx, context) | ||||||
|         self._import.context = ctx |         self._import.context = ctx | ||||||
|  |  | ||||||
|     def default_context(self): |  | ||||||
|         """Default context""" |  | ||||||
|         return {"goauthentik.io/enterprise/licensed": LicenseKey.get_total().is_valid()} |  | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def from_string(yaml_input: str, context: dict | None = None) -> "Importer": |     def from_string(yaml_input: str, context: dict | None = None) -> "Importer": | ||||||
|         """Parse YAML string and create blueprint importer from it""" |         """Parse YAML string and create blueprint importer from it""" | ||||||
| @ -178,14 +136,14 @@ class Importer: | |||||||
|  |  | ||||||
|         def updater(value) -> Any: |         def updater(value) -> Any: | ||||||
|             if value in self.__pk_map: |             if value in self.__pk_map: | ||||||
|                 self.logger.debug("Updating reference in entry", value=value) |                 self.logger.debug("updating reference in entry", value=value) | ||||||
|                 return self.__pk_map[value] |                 return self.__pk_map[value] | ||||||
|             return value |             return value | ||||||
|  |  | ||||||
|         for key, value in attrs.items(): |         for key, value in attrs.items(): | ||||||
|             try: |             try: | ||||||
|                 if isinstance(value, dict): |                 if isinstance(value, dict): | ||||||
|                     for _, _inner_key in enumerate(value): |                     for idx, _inner_key in enumerate(value): | ||||||
|                         value[_inner_key] = updater(value[_inner_key]) |                         value[_inner_key] = updater(value[_inner_key]) | ||||||
|                 elif isinstance(value, list): |                 elif isinstance(value, list): | ||||||
|                     for idx, _inner_value in enumerate(value): |                     for idx, _inner_value in enumerate(value): | ||||||
| @ -214,7 +172,8 @@ class Importer: | |||||||
|  |  | ||||||
|         return main_query | sub_query |         return main_query | sub_query | ||||||
|  |  | ||||||
|     def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer | None: |     # pylint: disable-msg=too-many-locals | ||||||
|  |     def _validate_single(self, entry: BlueprintEntry) -> Optional[BaseSerializer]: | ||||||
|         """Validate a single entry""" |         """Validate a single entry""" | ||||||
|         if not entry.check_all_conditions_match(self._import): |         if not entry.check_all_conditions_match(self._import): | ||||||
|             self.logger.debug("One or more conditions of this entry are not fulfilled, skipping") |             self.logger.debug("One or more conditions of this entry are not fulfilled, skipping") | ||||||
| @ -267,7 +226,7 @@ class Importer: | |||||||
|         model_instance = existing_models.first() |         model_instance = existing_models.first() | ||||||
|         if not isinstance(model(), BaseMetaModel) and model_instance: |         if not isinstance(model(), BaseMetaModel) and model_instance: | ||||||
|             self.logger.debug( |             self.logger.debug( | ||||||
|                 "Initialise serializer with instance", |                 "initialise serializer with instance", | ||||||
|                 model=model, |                 model=model, | ||||||
|                 instance=model_instance, |                 instance=model_instance, | ||||||
|                 pk=model_instance.pk, |                 pk=model_instance.pk, | ||||||
| @ -277,14 +236,14 @@ class Importer: | |||||||
|         elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED: |         elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED: | ||||||
|             raise EntryInvalidError.from_entry( |             raise EntryInvalidError.from_entry( | ||||||
|                 ( |                 ( | ||||||
|                     f"State is set to {BlueprintEntryDesiredState.MUST_CREATED} " |                     f"state is set to {BlueprintEntryDesiredState.MUST_CREATED} " | ||||||
|                     "and object exists already", |                     "and object exists already", | ||||||
|                 ), |                 ), | ||||||
|                 entry, |                 entry, | ||||||
|             ) |             ) | ||||||
|         else: |         else: | ||||||
|             self.logger.debug( |             self.logger.debug( | ||||||
|                 "Initialised new serializer instance", |                 "initialised new serializer instance", | ||||||
|                 model=model, |                 model=model, | ||||||
|                 **cleanse_dict(updated_identifiers), |                 **cleanse_dict(updated_identifiers), | ||||||
|             ) |             ) | ||||||
| @ -341,7 +300,7 @@ class Importer: | |||||||
|                 model: type[SerializerModel] = registry.get_model(model_app_label, model_name) |                 model: type[SerializerModel] = registry.get_model(model_app_label, model_name) | ||||||
|             except LookupError: |             except LookupError: | ||||||
|                 self.logger.warning( |                 self.logger.warning( | ||||||
|                     "App or Model does not exist", app=model_app_label, model=model_name |                     "app or model does not exist", app=model_app_label, model=model_name | ||||||
|                 ) |                 ) | ||||||
|                 return False |                 return False | ||||||
|             # Validate each single entry |             # Validate each single entry | ||||||
| @ -353,7 +312,7 @@ class Importer: | |||||||
|                 if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT: |                 if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT: | ||||||
|                     serializer = exc.serializer |                     serializer = exc.serializer | ||||||
|                 else: |                 else: | ||||||
|                     self.logger.warning(f"Entry invalid: {exc}", entry=entry, error=exc) |                     self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) | ||||||
|                     if raise_errors: |                     if raise_errors: | ||||||
|                         raise exc |                         raise exc | ||||||
|                     return False |                     return False | ||||||
| @ -373,27 +332,27 @@ class Importer: | |||||||
|                     and state == BlueprintEntryDesiredState.CREATED |                     and state == BlueprintEntryDesiredState.CREATED | ||||||
|                 ): |                 ): | ||||||
|                     self.logger.debug( |                     self.logger.debug( | ||||||
|                         "Instance exists, skipping", |                         "instance exists, skipping", | ||||||
|                         model=model, |                         model=model, | ||||||
|                         instance=instance, |                         instance=instance, | ||||||
|                         pk=instance.pk, |                         pk=instance.pk, | ||||||
|                     ) |                     ) | ||||||
|                 else: |                 else: | ||||||
|                     instance = serializer.save() |                     instance = serializer.save() | ||||||
|                     self.logger.debug("Updated model", model=instance) |                     self.logger.debug("updated model", model=instance) | ||||||
|                 if "pk" in entry.identifiers: |                 if "pk" in entry.identifiers: | ||||||
|                     self.__pk_map[entry.identifiers["pk"]] = instance.pk |                     self.__pk_map[entry.identifiers["pk"]] = instance.pk | ||||||
|                 entry._state = BlueprintEntryState(instance) |                 entry._state = BlueprintEntryState(instance) | ||||||
|             elif state == BlueprintEntryDesiredState.ABSENT: |             elif state == BlueprintEntryDesiredState.ABSENT: | ||||||
|                 instance: Model | None = serializer.instance |                 instance: Optional[Model] = serializer.instance | ||||||
|                 if instance.pk: |                 if instance.pk: | ||||||
|                     instance.delete() |                     instance.delete() | ||||||
|                     self.logger.debug("Deleted model", mode=instance) |                     self.logger.debug("deleted model", mode=instance) | ||||||
|                     continue |                     continue | ||||||
|                 self.logger.debug("Entry to delete with no instance, skipping") |                 self.logger.debug("entry to delete with no instance, skipping") | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     def validate(self, raise_validation_errors=False) -> tuple[bool, list[LogEvent]]: |     def validate(self, raise_validation_errors=False) -> tuple[bool, list[EventDict]]: | ||||||
|         """Validate loaded blueprint export, ensure all models are allowed |         """Validate loaded blueprint export, ensure all models are allowed | ||||||
|         and serializers have no errors""" |         and serializers have no errors""" | ||||||
|         self.logger.debug("Starting blueprint import validation") |         self.logger.debug("Starting blueprint import validation") | ||||||
| @ -407,7 +366,9 @@ class Importer: | |||||||
|         ): |         ): | ||||||
|             successful = self._apply_models(raise_errors=raise_validation_errors) |             successful = self._apply_models(raise_errors=raise_validation_errors) | ||||||
|             if not successful: |             if not successful: | ||||||
|                 self.logger.warning("Blueprint validation failed") |                 self.logger.debug("Blueprint validation failed") | ||||||
|  |         for log in logs: | ||||||
|  |             getattr(self.logger, log.get("log_level"))(**log) | ||||||
|         self.logger.debug("Finished blueprint import validation") |         self.logger.debug("Finished blueprint import validation") | ||||||
|         self._import = orig_import |         self._import = orig_import | ||||||
|         return successful, logs |         return successful, logs | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Apply Blueprint meta model""" | """Apply Blueprint meta model""" | ||||||
|  |  | ||||||
| from typing import TYPE_CHECKING | from typing import TYPE_CHECKING | ||||||
|  |  | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| @ -43,7 +42,7 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer): | |||||||
|             LOGGER.info("Blueprint does not exist, but not required") |             LOGGER.info("Blueprint does not exist, but not required") | ||||||
|             return MetaResult() |             return MetaResult() | ||||||
|         LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance) |         LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance) | ||||||
|  |         # pylint: disable=no-value-for-parameter | ||||||
|         apply_blueprint(str(self.blueprint_instance.pk)) |         apply_blueprint(str(self.blueprint_instance.pk)) | ||||||
|         return MetaResult() |         return MetaResult() | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Base models""" | """Base models""" | ||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
| @ -8,15 +7,15 @@ from rest_framework.serializers import Serializer | |||||||
| class BaseMetaModel(Model): | class BaseMetaModel(Model): | ||||||
|     """Base models""" |     """Base models""" | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         abstract = True |  | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def serializer() -> Serializer: |     def serializer() -> Serializer: | ||||||
|         """Serializer similar to SerializerModel, but as a static method since |         """Serializer similar to SerializerModel, but as a static method since | ||||||
|         this is an abstract model""" |         this is an abstract model""" | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         abstract = True | ||||||
|  |  | ||||||
|  |  | ||||||
| class MetaResult: | class MetaResult: | ||||||
|     """Result returned by Meta Models' serializers. Empty class but we can't return none as |     """Result returned by Meta Models' serializers. Empty class but we can't return none as | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """OCI Client""" | """OCI Client""" | ||||||
|  |  | ||||||
| from typing import Any | from typing import Any | ||||||
| from urllib.parse import ParseResult, urlparse | from urllib.parse import ParseResult, urlparse | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,9 +1,8 @@ | |||||||
| """v1 blueprints tasks""" | """v1 blueprints tasks""" | ||||||
|  |  | ||||||
| from dataclasses import asdict, dataclass, field | from dataclasses import asdict, dataclass, field | ||||||
| from hashlib import sha512 | from hashlib import sha512 | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from sys import platform | from typing import Optional | ||||||
|  |  | ||||||
| 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 | ||||||
| @ -30,13 +29,15 @@ from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata, E | |||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
| from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE | from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE | ||||||
| from authentik.blueprints.v1.oci import OCI_PREFIX | from authentik.blueprints.v1.oci import OCI_PREFIX | ||||||
| from authentik.events.logs import capture_logs | from authentik.events.monitored_tasks import ( | ||||||
| from authentik.events.models import TaskStatus |     MonitoredTask, | ||||||
| from authentik.events.system_tasks import SystemTask, prefill_task |     TaskResult, | ||||||
|  |     TaskResultStatus, | ||||||
|  |     prefill_task, | ||||||
|  | ) | ||||||
| from authentik.events.utils import sanitize_dict | from authentik.events.utils import sanitize_dict | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| from authentik.tenants.models import Tenant |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| _file_watcher_started = False | _file_watcher_started = False | ||||||
| @ -50,23 +51,18 @@ class BlueprintFile: | |||||||
|     version: int |     version: int | ||||||
|     hash: str |     hash: str | ||||||
|     last_m: int |     last_m: int | ||||||
|     meta: BlueprintMetadata | None = field(default=None) |     meta: Optional[BlueprintMetadata] = field(default=None) | ||||||
|  |  | ||||||
|  |  | ||||||
| def start_blueprint_watcher(): | def start_blueprint_watcher(): | ||||||
|     """Start blueprint watcher, if it's not running already.""" |     """Start blueprint watcher, if it's not running already.""" | ||||||
|     # This function might be called twice since it's called on celery startup |     # This function might be called twice since it's called on celery startup | ||||||
|  |     # pylint: disable=global-statement | ||||||
|     global _file_watcher_started  # noqa: PLW0603 |     global _file_watcher_started | ||||||
|     if _file_watcher_started: |     if _file_watcher_started: | ||||||
|         return |         return | ||||||
|     observer = Observer() |     observer = Observer() | ||||||
|     kwargs = {} |     observer.schedule(BlueprintEventHandler(), CONFIG.get("blueprints_dir"), recursive=True) | ||||||
|     if platform.startswith("linux"): |  | ||||||
|         kwargs["event_filter"] = (FileCreatedEvent, FileModifiedEvent) |  | ||||||
|     observer.schedule( |  | ||||||
|         BlueprintEventHandler(), CONFIG.get("blueprints_dir"), recursive=True, **kwargs |  | ||||||
|     ) |  | ||||||
|     observer.start() |     observer.start() | ||||||
|     _file_watcher_started = True |     _file_watcher_started = True | ||||||
|  |  | ||||||
| @ -74,36 +70,21 @@ def start_blueprint_watcher(): | |||||||
| class BlueprintEventHandler(FileSystemEventHandler): | class BlueprintEventHandler(FileSystemEventHandler): | ||||||
|     """Event handler for blueprint events""" |     """Event handler for blueprint events""" | ||||||
|  |  | ||||||
|     # We only ever get creation and modification events. |     def on_any_event(self, event: FileSystemEvent): | ||||||
|     # See the creation of the Observer instance above for the event filtering. |         if not isinstance(event, (FileCreatedEvent, FileModifiedEvent)): | ||||||
|  |             return | ||||||
|     # Even though we filter to only get file events, we might still get |  | ||||||
|     # directory events as some implementations such as inotify do not support |  | ||||||
|     # filtering on file/directory. |  | ||||||
|  |  | ||||||
|     def dispatch(self, event: FileSystemEvent) -> None: |  | ||||||
|         """Call specific event handler method. Ignores directory changes.""" |  | ||||||
|         if event.is_directory: |         if event.is_directory: | ||||||
|             return None |             return | ||||||
|         return super().dispatch(event) |  | ||||||
|  |  | ||||||
|     def on_created(self, event: FileSystemEvent): |  | ||||||
|         """Process file creation""" |  | ||||||
|         LOGGER.debug("new blueprint file created, starting discovery") |  | ||||||
|         for tenant in Tenant.objects.filter(ready=True): |  | ||||||
|             with tenant: |  | ||||||
|                 blueprints_discovery.delay() |  | ||||||
|  |  | ||||||
|     def on_modified(self, event: FileSystemEvent): |  | ||||||
|         """Process file modification""" |  | ||||||
|         path = Path(event.src_path) |  | ||||||
|         root = Path(CONFIG.get("blueprints_dir")).absolute() |         root = Path(CONFIG.get("blueprints_dir")).absolute() | ||||||
|  |         path = Path(event.src_path).absolute() | ||||||
|         rel_path = str(path.relative_to(root)) |         rel_path = str(path.relative_to(root)) | ||||||
|         for tenant in Tenant.objects.filter(ready=True): |         if isinstance(event, FileCreatedEvent): | ||||||
|             with tenant: |             LOGGER.debug("new blueprint file created, starting discovery", path=rel_path) | ||||||
|                 for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True): |             blueprints_discovery.delay(rel_path) | ||||||
|                     LOGGER.debug("modified blueprint file, starting apply", instance=instance) |         if isinstance(event, FileModifiedEvent): | ||||||
|                     apply_blueprint.delay(instance.pk.hex) |             for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True): | ||||||
|  |                 LOGGER.debug("modified blueprint file, starting apply", instance=instance) | ||||||
|  |                 apply_blueprint.delay(instance.pk.hex) | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task( | @CELERY_APP.task( | ||||||
| @ -126,7 +107,7 @@ def blueprints_find() -> list[BlueprintFile]: | |||||||
|         # Check if any part in the path starts with a dot and assume a hidden file |         # Check if any part in the path starts with a dot and assume a hidden file | ||||||
|         if any(part for part in path.parts if part.startswith(".")): |         if any(part for part in path.parts if part.startswith(".")): | ||||||
|             continue |             continue | ||||||
|         with open(path, encoding="utf-8") as blueprint_file: |         with open(path, "r", encoding="utf-8") as blueprint_file: | ||||||
|             try: |             try: | ||||||
|                 raw_blueprint = load(blueprint_file.read(), BlueprintLoader) |                 raw_blueprint = load(blueprint_file.read(), BlueprintLoader) | ||||||
|             except YAMLError as exc: |             except YAMLError as exc: | ||||||
| @ -147,10 +128,10 @@ def blueprints_find() -> list[BlueprintFile]: | |||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task( | @CELERY_APP.task( | ||||||
|     throws=(DatabaseError, ProgrammingError, InternalError), base=SystemTask, bind=True |     throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True | ||||||
| ) | ) | ||||||
| @prefill_task | @prefill_task | ||||||
| def blueprints_discovery(self: SystemTask, path: str | None = None): | def blueprints_discovery(self: MonitoredTask, path: Optional[str] = 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 |     count = 0 | ||||||
|     for blueprint in blueprints_find(): |     for blueprint in blueprints_find(): | ||||||
| @ -159,7 +140,10 @@ def blueprints_discovery(self: SystemTask, path: str | None = None): | |||||||
|         check_blueprint_v1_file(blueprint) |         check_blueprint_v1_file(blueprint) | ||||||
|         count += 1 |         count += 1 | ||||||
|     self.set_status( |     self.set_status( | ||||||
|         TaskStatus.SUCCESSFUL, _("Successfully imported %(count)d files." % {"count": count}) |         TaskResult( | ||||||
|  |             TaskResultStatus.SUCCESSFUL, | ||||||
|  |             messages=[_("Successfully imported %(count)d files." % {"count": count})], | ||||||
|  |         ) | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -192,12 +176,12 @@ def check_blueprint_v1_file(blueprint: BlueprintFile): | |||||||
|  |  | ||||||
| @CELERY_APP.task( | @CELERY_APP.task( | ||||||
|     bind=True, |     bind=True, | ||||||
|     base=SystemTask, |     base=MonitoredTask, | ||||||
| ) | ) | ||||||
| def apply_blueprint(self: SystemTask, instance_pk: str): | def apply_blueprint(self: MonitoredTask, instance_pk: str): | ||||||
|     """Apply single blueprint""" |     """Apply single blueprint""" | ||||||
|     self.save_on_success = False |     self.save_on_success = False | ||||||
|     instance: BlueprintInstance | None = None |     instance: Optional[BlueprintInstance] = None | ||||||
|     try: |     try: | ||||||
|         instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first() |         instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first() | ||||||
|         if not instance or not instance.enabled: |         if not instance or not instance.enabled: | ||||||
| @ -212,30 +196,29 @@ def apply_blueprint(self: SystemTask, instance_pk: str): | |||||||
|         if not valid: |         if not valid: | ||||||
|             instance.status = BlueprintInstanceStatus.ERROR |             instance.status = BlueprintInstanceStatus.ERROR | ||||||
|             instance.save() |             instance.save() | ||||||
|             self.set_status(TaskStatus.ERROR, *logs) |             self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs])) | ||||||
|  |             return | ||||||
|  |         applied = importer.apply() | ||||||
|  |         if not applied: | ||||||
|  |             instance.status = BlueprintInstanceStatus.ERROR | ||||||
|  |             instance.save() | ||||||
|  |             self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply")) | ||||||
|             return |             return | ||||||
|         with capture_logs() as logs: |  | ||||||
|             applied = importer.apply() |  | ||||||
|             if not applied: |  | ||||||
|                 instance.status = BlueprintInstanceStatus.ERROR |  | ||||||
|                 instance.save() |  | ||||||
|                 self.set_status(TaskStatus.ERROR, *logs) |  | ||||||
|                 return |  | ||||||
|         instance.status = BlueprintInstanceStatus.SUCCESSFUL |         instance.status = BlueprintInstanceStatus.SUCCESSFUL | ||||||
|         instance.last_applied_hash = file_hash |         instance.last_applied_hash = file_hash | ||||||
|         instance.last_applied = now() |         instance.last_applied = now() | ||||||
|         self.set_status(TaskStatus.SUCCESSFUL) |         self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) | ||||||
|     except ( |     except ( | ||||||
|         OSError, |  | ||||||
|         DatabaseError, |         DatabaseError, | ||||||
|         ProgrammingError, |         ProgrammingError, | ||||||
|         InternalError, |         InternalError, | ||||||
|  |         IOError, | ||||||
|         BlueprintRetrievalFailed, |         BlueprintRetrievalFailed, | ||||||
|         EntryInvalidError, |         EntryInvalidError, | ||||||
|     ) as exc: |     ) as exc: | ||||||
|         if instance: |         if instance: | ||||||
|             instance.status = BlueprintInstanceStatus.ERROR |             instance.status = BlueprintInstanceStatus.ERROR | ||||||
|         self.set_error(exc) |         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||||
|     finally: |     finally: | ||||||
|         if instance: |         if instance: | ||||||
|             instance.save() |             instance.save() | ||||||
|  | |||||||
| @ -1,11 +0,0 @@ | |||||||
| """authentik brands app""" |  | ||||||
|  |  | ||||||
| from django.apps import AppConfig |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikBrandsConfig(AppConfig): |  | ||||||
|     """authentik Brand app""" |  | ||||||
|  |  | ||||||
|     name = "authentik.brands" |  | ||||||
|     label = "authentik_brands" |  | ||||||
|     verbose_name = "authentik Brands" |  | ||||||
| @ -1,27 +0,0 @@ | |||||||
| """Inject brand into current request""" |  | ||||||
|  |  | ||||||
| from collections.abc import Callable |  | ||||||
|  |  | ||||||
| from django.http.request import HttpRequest |  | ||||||
| from django.http.response import HttpResponse |  | ||||||
| from django.utils.translation import activate |  | ||||||
|  |  | ||||||
| from authentik.brands.utils import get_brand_for_request |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BrandMiddleware: |  | ||||||
|     """Add current brand to http request""" |  | ||||||
|  |  | ||||||
|     get_response: Callable[[HttpRequest], HttpResponse] |  | ||||||
|  |  | ||||||
|     def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): |  | ||||||
|         self.get_response = get_response |  | ||||||
|  |  | ||||||
|     def __call__(self, request: HttpRequest) -> HttpResponse: |  | ||||||
|         if not hasattr(request, "brand"): |  | ||||||
|             brand = get_brand_for_request(request) |  | ||||||
|             request.brand = brand |  | ||||||
|             locale = brand.default_locale |  | ||||||
|             if locale != "": |  | ||||||
|                 activate(locale) |  | ||||||
|         return self.get_response(request) |  | ||||||
| @ -1,21 +0,0 @@ | |||||||
| # Generated by Django 4.2.7 on 2023-12-12 06:41 |  | ||||||
|  |  | ||||||
| from django.db import migrations |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_brands", "0004_tenant_flow_device_code"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.RenameField( |  | ||||||
|             model_name="brand", |  | ||||||
|             old_name="tenant_uuid", |  | ||||||
|             new_name="brand_uuid", |  | ||||||
|         ), |  | ||||||
|         migrations.RemoveField( |  | ||||||
|             model_name="brand", |  | ||||||
|             name="event_retention", |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,21 +0,0 @@ | |||||||
| # Generated by Django 5.0.4 on 2024-04-18 18:56 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_brands", "0005_tenantuuid_to_branduuid"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddIndex( |  | ||||||
|             model_name="brand", |  | ||||||
|             index=models.Index(fields=["domain"], name="authentik_b_domain_b9b24a_idx"), |  | ||||||
|         ), |  | ||||||
|         migrations.AddIndex( |  | ||||||
|             model_name="brand", |  | ||||||
|             index=models.Index(fields=["default"], name="authentik_b_default_3ccf12_idx"), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,90 +0,0 @@ | |||||||
| """brand models""" |  | ||||||
|  |  | ||||||
| from uuid import uuid4 |  | ||||||
|  |  | ||||||
| from django.db import models |  | ||||||
| from django.utils.translation import gettext_lazy as _ |  | ||||||
| from rest_framework.serializers import Serializer |  | ||||||
| from structlog.stdlib import get_logger |  | ||||||
|  |  | ||||||
| from authentik.crypto.models import CertificateKeyPair |  | ||||||
| from authentik.flows.models import Flow |  | ||||||
| from authentik.lib.models import SerializerModel |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Brand(SerializerModel): |  | ||||||
|     """Single brand""" |  | ||||||
|  |  | ||||||
|     brand_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     domain = models.TextField( |  | ||||||
|         help_text=_( |  | ||||||
|             "Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`" |  | ||||||
|         ) |  | ||||||
|     ) |  | ||||||
|     default = models.BooleanField( |  | ||||||
|         default=False, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     branding_title = models.TextField(default="authentik") |  | ||||||
|  |  | ||||||
|     branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg") |  | ||||||
|     branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png") |  | ||||||
|  |  | ||||||
|     flow_authentication = models.ForeignKey( |  | ||||||
|         Flow, null=True, on_delete=models.SET_NULL, related_name="brand_authentication" |  | ||||||
|     ) |  | ||||||
|     flow_invalidation = models.ForeignKey( |  | ||||||
|         Flow, null=True, on_delete=models.SET_NULL, related_name="brand_invalidation" |  | ||||||
|     ) |  | ||||||
|     flow_recovery = models.ForeignKey( |  | ||||||
|         Flow, null=True, on_delete=models.SET_NULL, related_name="brand_recovery" |  | ||||||
|     ) |  | ||||||
|     flow_unenrollment = models.ForeignKey( |  | ||||||
|         Flow, null=True, on_delete=models.SET_NULL, related_name="brand_unenrollment" |  | ||||||
|     ) |  | ||||||
|     flow_user_settings = models.ForeignKey( |  | ||||||
|         Flow, null=True, on_delete=models.SET_NULL, related_name="brand_user_settings" |  | ||||||
|     ) |  | ||||||
|     flow_device_code = models.ForeignKey( |  | ||||||
|         Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code" |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     web_certificate = models.ForeignKey( |  | ||||||
|         CertificateKeyPair, |  | ||||||
|         null=True, |  | ||||||
|         default=None, |  | ||||||
|         on_delete=models.SET_DEFAULT, |  | ||||||
|         help_text=_("Web Certificate used by the authentik Core webserver."), |  | ||||||
|     ) |  | ||||||
|     attributes = models.JSONField(default=dict, blank=True) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> Serializer: |  | ||||||
|         from authentik.brands.api import BrandSerializer |  | ||||||
|  |  | ||||||
|         return BrandSerializer |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def default_locale(self) -> str: |  | ||||||
|         """Get default locale""" |  | ||||||
|         try: |  | ||||||
|             return self.attributes.get("settings", {}).get("locale", "") |  | ||||||
|  |  | ||||||
|         except Exception as exc: |  | ||||||
|             LOGGER.warning("Failed to get default locale", exc=exc) |  | ||||||
|             return "" |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         if self.default: |  | ||||||
|             return "Default brand" |  | ||||||
|         return f"Brand {self.domain}" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Brand") |  | ||||||
|         verbose_name_plural = _("Brands") |  | ||||||
|         indexes = [ |  | ||||||
|             models.Index(fields=["domain"]), |  | ||||||
|             models.Index(fields=["default"]), |  | ||||||
|         ] |  | ||||||
| @ -1,77 +0,0 @@ | |||||||
| """Test brands""" |  | ||||||
|  |  | ||||||
| from django.urls import reverse |  | ||||||
| from rest_framework.test import APITestCase |  | ||||||
|  |  | ||||||
| from authentik.brands.api import Themes |  | ||||||
| from authentik.brands.models import Brand |  | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_brand |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestBrands(APITestCase): |  | ||||||
|     """Test brands""" |  | ||||||
|  |  | ||||||
|     def test_current_brand(self): |  | ||||||
|         """Test Current brand API""" |  | ||||||
|         brand = create_test_brand() |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             self.client.get(reverse("authentik_api:brand-current")).content.decode(), |  | ||||||
|             { |  | ||||||
|                 "branding_logo": "/static/dist/assets/icons/icon_left_brand.svg", |  | ||||||
|                 "branding_favicon": "/static/dist/assets/icons/icon.png", |  | ||||||
|                 "branding_title": "authentik", |  | ||||||
|                 "matched_domain": brand.domain, |  | ||||||
|                 "ui_footer_links": [], |  | ||||||
|                 "ui_theme": Themes.AUTOMATIC, |  | ||||||
|                 "default_locale": "", |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_brand_subdomain(self): |  | ||||||
|         """Test Current brand API""" |  | ||||||
|         Brand.objects.all().delete() |  | ||||||
|         Brand.objects.create(domain="bar.baz", branding_title="custom") |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             self.client.get( |  | ||||||
|                 reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz" |  | ||||||
|             ).content.decode(), |  | ||||||
|             { |  | ||||||
|                 "branding_logo": "/static/dist/assets/icons/icon_left_brand.svg", |  | ||||||
|                 "branding_favicon": "/static/dist/assets/icons/icon.png", |  | ||||||
|                 "branding_title": "custom", |  | ||||||
|                 "matched_domain": "bar.baz", |  | ||||||
|                 "ui_footer_links": [], |  | ||||||
|                 "ui_theme": Themes.AUTOMATIC, |  | ||||||
|                 "default_locale": "", |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_fallback(self): |  | ||||||
|         """Test fallback brand""" |  | ||||||
|         Brand.objects.all().delete() |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             self.client.get(reverse("authentik_api:brand-current")).content.decode(), |  | ||||||
|             { |  | ||||||
|                 "branding_logo": "/static/dist/assets/icons/icon_left_brand.svg", |  | ||||||
|                 "branding_favicon": "/static/dist/assets/icons/icon.png", |  | ||||||
|                 "branding_title": "authentik", |  | ||||||
|                 "matched_domain": "fallback", |  | ||||||
|                 "ui_footer_links": [], |  | ||||||
|                 "ui_theme": Themes.AUTOMATIC, |  | ||||||
|                 "default_locale": "", |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_create_default_multiple(self): |  | ||||||
|         """Test attempted creation of multiple default brands""" |  | ||||||
|         Brand.objects.create( |  | ||||||
|             domain="foo", |  | ||||||
|             default=True, |  | ||||||
|             branding_title="custom", |  | ||||||
|         ) |  | ||||||
|         user = create_test_admin_user() |  | ||||||
|         self.client.force_login(user) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_api:brand-list"), data={"domain": "bar", "default": True} |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
| @ -1,7 +0,0 @@ | |||||||
| """API URLs""" |  | ||||||
|  |  | ||||||
| from authentik.brands.api import BrandViewSet |  | ||||||
|  |  | ||||||
| api_urlpatterns = [ |  | ||||||
|     ("core/brands", BrandViewSet), |  | ||||||
| ] |  | ||||||
| @ -1,44 +0,0 @@ | |||||||
| """Brand utilities""" |  | ||||||
|  |  | ||||||
| from typing import Any |  | ||||||
|  |  | ||||||
| from django.db.models import F, Q |  | ||||||
| from django.db.models import Value as V |  | ||||||
| from django.http.request import HttpRequest |  | ||||||
| from sentry_sdk.hub import Hub |  | ||||||
|  |  | ||||||
| from authentik import get_full_version |  | ||||||
| from authentik.brands.models import Brand |  | ||||||
| from authentik.tenants.models import Tenant |  | ||||||
|  |  | ||||||
| _q_default = Q(default=True) |  | ||||||
| DEFAULT_BRAND = Brand(domain="fallback") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_brand_for_request(request: HttpRequest) -> Brand: |  | ||||||
|     """Get brand object for current request""" |  | ||||||
|     db_brands = ( |  | ||||||
|         Brand.objects.annotate(host_domain=V(request.get_host())) |  | ||||||
|         .filter(Q(host_domain__iendswith=F("domain")) | _q_default) |  | ||||||
|         .order_by("default") |  | ||||||
|     ) |  | ||||||
|     brands = list(db_brands.all()) |  | ||||||
|     if len(brands) < 1: |  | ||||||
|         return DEFAULT_BRAND |  | ||||||
|     return brands[0] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def context_processor(request: HttpRequest) -> dict[str, Any]: |  | ||||||
|     """Context Processor that injects brand object into every template""" |  | ||||||
|     brand = getattr(request, "brand", DEFAULT_BRAND) |  | ||||||
|     tenant = getattr(request, "tenant", Tenant()) |  | ||||||
|     trace = "" |  | ||||||
|     span = Hub.current.scope.span |  | ||||||
|     if span: |  | ||||||
|         trace = span.to_traceparent() |  | ||||||
|     return { |  | ||||||
|         "brand": brand, |  | ||||||
|         "footer_links": tenant.footer_links, |  | ||||||
|         "sentry_trace": trace, |  | ||||||
|         "version": get_full_version(), |  | ||||||
|     } |  | ||||||
| @ -1,34 +1,33 @@ | |||||||
| """Application API Views""" | """Application API Views""" | ||||||
|  |  | ||||||
| from collections.abc import Iterator |  | ||||||
| from copy import copy |  | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
|  | from typing import Optional | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
| from django.db.models.functions import ExtractHour | from django.db.models.functions import ExtractHour | ||||||
|  | from django.http.response import HttpResponseBadRequest | ||||||
| from django.shortcuts import get_object_or_404 | from django.shortcuts import get_object_or_404 | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.exceptions import ValidationError |  | ||||||
| from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField | from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField | ||||||
| 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.serializers import ModelSerializer | ||||||
| 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 structlog.testing import capture_logs | ||||||
|  |  | ||||||
| from authentik.admin.api.metrics import CoordinateSerializer | from authentik.admin.api.metrics import CoordinateSerializer | ||||||
| from authentik.api.pagination import Pagination | from authentik.api.decorators import permission_required | ||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| 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.core.api.utils import ModelSerializer |  | ||||||
| from authentik.core.models import Application, User | from authentik.core.models import Application, User | ||||||
| from authentik.events.logs import LogEventSerializer, capture_logs |  | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
|  | from authentik.events.utils import sanitize_dict | ||||||
| from authentik.lib.utils.file import ( | from authentik.lib.utils.file import ( | ||||||
|     FilePathSerializer, |     FilePathSerializer, | ||||||
|     FileUploadSerializer, |     FileUploadSerializer, | ||||||
| @ -37,19 +36,15 @@ from authentik.lib.utils.file import ( | |||||||
| ) | ) | ||||||
| from authentik.policies.api.exec import PolicyTestResultSerializer | from authentik.policies.api.exec import PolicyTestResultSerializer | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.types import CACHE_PREFIX, PolicyResult | from authentik.policies.types import PolicyResult | ||||||
| from authentik.rbac.decorators import permission_required |  | ||||||
| from authentik.rbac.filters import ObjectFilter | from authentik.rbac.filters import ObjectFilter | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str: | def user_app_cache_key(user_pk: str) -> str: | ||||||
|     """Cache key where application list for user is saved""" |     """Cache key where application list for user is saved""" | ||||||
|     key = f"{CACHE_PREFIX}/app_access/{user_pk}" |     return f"goauthentik.io/core/app_access/{user_pk}" | ||||||
|     if page_number: |  | ||||||
|         key += f"/{page_number}" |  | ||||||
|     return key |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationSerializer(ModelSerializer): | class ApplicationSerializer(ModelSerializer): | ||||||
| @ -63,7 +58,7 @@ class ApplicationSerializer(ModelSerializer): | |||||||
|  |  | ||||||
|     meta_icon = ReadOnlyField(source="get_meta_icon") |     meta_icon = ReadOnlyField(source="get_meta_icon") | ||||||
|  |  | ||||||
|     def get_launch_url(self, app: Application) -> str | None: |     def get_launch_url(self, app: Application) -> Optional[str]: | ||||||
|         """Allow formatting of launch URL""" |         """Allow formatting of launch URL""" | ||||||
|         user = None |         user = None | ||||||
|         if "request" in self.context: |         if "request" in self.context: | ||||||
| @ -103,7 +98,8 @@ class ApplicationSerializer(ModelSerializer): | |||||||
| class ApplicationViewSet(UsedByMixin, ModelViewSet): | class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Application Viewset""" |     """Application Viewset""" | ||||||
|  |  | ||||||
|     queryset = Application.objects.all().prefetch_related("provider").prefetch_related("policies") |     # pylint: disable=no-member | ||||||
|  |     queryset = Application.objects.all().prefetch_related("provider") | ||||||
|     serializer_class = ApplicationSerializer |     serializer_class = ApplicationSerializer | ||||||
|     search_fields = [ |     search_fields = [ | ||||||
|         "name", |         "name", | ||||||
| @ -132,16 +128,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|             queryset = backend().filter_queryset(self.request, queryset, self) |             queryset = backend().filter_queryset(self.request, queryset, self) | ||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
|     def _get_allowed_applications( |     def _get_allowed_applications(self, queryset: QuerySet) -> list[Application]: | ||||||
|         self, pagined_apps: Iterator[Application], user: User | None = None |  | ||||||
|     ) -> list[Application]: |  | ||||||
|         applications = [] |         applications = [] | ||||||
|         request = self.request._request |         for application in queryset: | ||||||
|         if user: |             engine = PolicyEngine(application, self.request.user, self.request) | ||||||
|             request = copy(request) |  | ||||||
|             request.user = user |  | ||||||
|         for application in pagined_apps: |  | ||||||
|             engine = PolicyEngine(application, request.user, request) |  | ||||||
|             engine.build() |             engine.build() | ||||||
|             if engine.passing: |             if engine.passing: | ||||||
|                 applications.append(application) |                 applications.append(application) | ||||||
| @ -157,6 +147,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         ], |         ], | ||||||
|         responses={ |         responses={ | ||||||
|             200: PolicyTestResultSerializer(), |             200: PolicyTestResultSerializer(), | ||||||
|  |             404: OpenApiResponse(description="for_user user not found"), | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["GET"]) |     @action(detail=True, methods=["GET"]) | ||||||
| @ -169,11 +160,9 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         for_user = request.user |         for_user = request.user | ||||||
|         if request.user.is_superuser and "for_user" in request.query_params: |         if request.user.is_superuser and "for_user" in request.query_params: | ||||||
|             try: |             try: | ||||||
|                 for_user = User.objects.filter(pk=request.query_params.get("for_user")).first() |                 for_user = get_object_or_404(User, pk=request.query_params.get("for_user")) | ||||||
|             except ValueError: |             except ValueError: | ||||||
|                 raise ValidationError({"for_user": "for_user must be numerical"}) from None |                 return HttpResponseBadRequest("for_user must be numerical") | ||||||
|             if not for_user: |  | ||||||
|                 raise ValidationError({"for_user": "User not found"}) |  | ||||||
|         engine = PolicyEngine(application, for_user, request) |         engine = PolicyEngine(application, for_user, request) | ||||||
|         engine.use_cache = False |         engine.use_cache = False | ||||||
|         with capture_logs() as logs: |         with capture_logs() as logs: | ||||||
| @ -185,9 +174,9 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         if request.user.is_superuser: |         if request.user.is_superuser: | ||||||
|             log_messages = [] |             log_messages = [] | ||||||
|             for log in logs: |             for log in logs: | ||||||
|                 if log.attributes.get("process", "") == "PolicyProcess": |                 if log.get("process", "") == "PolicyProcess": | ||||||
|                     continue |                     continue | ||||||
|                 log_messages.append(LogEventSerializer(log).data) |                 log_messages.append(sanitize_dict(log)) | ||||||
|             result.log_messages = log_messages |             result.log_messages = log_messages | ||||||
|             response = PolicyTestResultSerializer(result) |             response = PolicyTestResultSerializer(result) | ||||||
|         return Response(response.data) |         return Response(response.data) | ||||||
| @ -198,56 +187,30 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|                 name="superuser_full_list", |                 name="superuser_full_list", | ||||||
|                 location=OpenApiParameter.QUERY, |                 location=OpenApiParameter.QUERY, | ||||||
|                 type=OpenApiTypes.BOOL, |                 type=OpenApiTypes.BOOL, | ||||||
|             ), |             ) | ||||||
|             OpenApiParameter( |  | ||||||
|                 name="for_user", |  | ||||||
|                 location=OpenApiParameter.QUERY, |  | ||||||
|                 type=OpenApiTypes.INT, |  | ||||||
|             ), |  | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """Custom list method that checks Policy based access instead of guardian""" |         """Custom list method that checks Policy based access instead of guardian""" | ||||||
|         should_cache = request.query_params.get("search", "") == "" |         should_cache = request.GET.get("search", "") == "" | ||||||
|  |  | ||||||
|         superuser_full_list = ( |         superuser_full_list = str(request.GET.get("superuser_full_list", "false")).lower() == "true" | ||||||
|             str(request.query_params.get("superuser_full_list", "false")).lower() == "true" |  | ||||||
|         ) |  | ||||||
|         if superuser_full_list and request.user.is_superuser: |         if superuser_full_list and request.user.is_superuser: | ||||||
|             return super().list(request) |             return super().list(request) | ||||||
|  |  | ||||||
|         queryset = self._filter_queryset_for_list(self.get_queryset()) |         queryset = self._filter_queryset_for_list(self.get_queryset()) | ||||||
|         paginator: Pagination = self.paginator |         self.paginate_queryset(queryset) | ||||||
|         paginated_apps = paginator.paginate_queryset(queryset, request) |  | ||||||
|  |  | ||||||
|         if "for_user" in request.query_params: |  | ||||||
|             try: |  | ||||||
|                 for_user: int = int(request.query_params.get("for_user", 0)) |  | ||||||
|                 for_user = ( |  | ||||||
|                     get_objects_for_user(request.user, "authentik_core.view_user_applications") |  | ||||||
|                     .filter(pk=for_user) |  | ||||||
|                     .first() |  | ||||||
|                 ) |  | ||||||
|                 if not for_user: |  | ||||||
|                     raise ValidationError({"for_user": "User not found"}) |  | ||||||
|             except ValueError as exc: |  | ||||||
|                 raise ValidationError from exc |  | ||||||
|             allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user) |  | ||||||
|             serializer = self.get_serializer(allowed_applications, many=True) |  | ||||||
|             return self.get_paginated_response(serializer.data) |  | ||||||
|  |  | ||||||
|         allowed_applications = [] |         allowed_applications = [] | ||||||
|         if not should_cache: |         if not should_cache: | ||||||
|             allowed_applications = self._get_allowed_applications(paginated_apps) |             allowed_applications = self._get_allowed_applications(queryset) | ||||||
|         if should_cache: |         if should_cache: | ||||||
|             allowed_applications = cache.get( |             allowed_applications = cache.get(user_app_cache_key(self.request.user.pk)) | ||||||
|                 user_app_cache_key(self.request.user.pk, paginator.page.number) |  | ||||||
|             ) |  | ||||||
|             if not allowed_applications: |             if not allowed_applications: | ||||||
|                 LOGGER.debug("Caching allowed application list", page=paginator.page.number) |                 LOGGER.debug("Caching allowed application list") | ||||||
|                 allowed_applications = self._get_allowed_applications(paginated_apps) |                 allowed_applications = self._get_allowed_applications(queryset) | ||||||
|                 cache.set( |                 cache.set( | ||||||
|                     user_app_cache_key(self.request.user.pk, paginator.page.number), |                     user_app_cache_key(self.request.user.pk), | ||||||
|                     allowed_applications, |                     allowed_applications, | ||||||
|                     timeout=86400, |                     timeout=86400, | ||||||
|                 ) |                 ) | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """AuthenticatedSessions API Viewset""" | """AuthenticatedSessions API Viewset""" | ||||||
|  | from typing import Optional, TypedDict | ||||||
| from typing import TypedDict |  | ||||||
|  |  | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| from guardian.utils import get_anonymous_user | from guardian.utils import get_anonymous_user | ||||||
| @ -8,12 +7,12 @@ 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.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
|  | from rest_framework.serializers import ModelSerializer | ||||||
| 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.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.models import AuthenticatedSession | from authentik.core.models import AuthenticatedSession | ||||||
| from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict | from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict | ||||||
| from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict | from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict | ||||||
| @ -72,11 +71,11 @@ class AuthenticatedSessionSerializer(ModelSerializer): | |||||||
|         """Get parsed user agent""" |         """Get parsed user agent""" | ||||||
|         return user_agent_parser.Parse(instance.last_user_agent) |         return user_agent_parser.Parse(instance.last_user_agent) | ||||||
|  |  | ||||||
|     def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None:  # pragma: no cover |     def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]:  # pragma: no cover | ||||||
|         """Get GeoIP Data""" |         """Get GeoIP Data""" | ||||||
|         return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip) |         return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip) | ||||||
|  |  | ||||||
|     def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None:  # pragma: no cover |     def get_asn(self, instance: AuthenticatedSession) -> Optional[ASNDict]:  # pragma: no cover | ||||||
|         """Get ASN Data""" |         """Get ASN Data""" | ||||||
|         return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip) |         return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip) | ||||||
|  |  | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	