Compare commits
	
		
			6 Commits
		
	
	
		
			enterprise
			...
			policies/p
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b3883f7fbf | |||
| 87c6b0128a | |||
| b243c97916 | |||
| 3f66527521 | |||
| 2f7c258657 | |||
| 917c90374f | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2025.2.1 | current_version = 2024.12.3 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							| @ -28,11 +28,7 @@ Output of docker-compose logs or kubectl logs respectively | |||||||
|  |  | ||||||
| **Version and Deployment (please complete the following information):** | **Version and Deployment (please complete the following information):** | ||||||
|  |  | ||||||
| <!-- | -   authentik version: [e.g. 2021.8.5] | ||||||
| Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/. |  | ||||||
| --> |  | ||||||
|  |  | ||||||
| -   authentik version: [e.g. 2025.2.0] |  | ||||||
| -   Deployment: [e.g. docker-compose, helm] | -   Deployment: [e.g. docker-compose, helm] | ||||||
|  |  | ||||||
| **Additional context** | **Additional context** | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
								
							| @ -20,12 +20,7 @@ Output of docker-compose logs or kubectl logs respectively | |||||||
|  |  | ||||||
| **Version and Deployment (please complete the following information):** | **Version and Deployment (please complete the following information):** | ||||||
|  |  | ||||||
| <!-- | -   authentik version: [e.g. 2021.8.5] | ||||||
| Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/. |  | ||||||
| --> |  | ||||||
|  |  | ||||||
|  |  | ||||||
| -   authentik version: [e.g. 2025.2.0] |  | ||||||
| -   Deployment: [e.g. docker-compose, helm] | -   Deployment: [e.g. docker-compose, helm] | ||||||
|  |  | ||||||
| **Additional context** | **Additional context** | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -30,16 +30,12 @@ runs: | |||||||
|       uses: actions/setup-go@v5 |       uses: actions/setup-go@v5 | ||||||
|       with: |       with: | ||||||
|         go-version-file: "go.mod" |         go-version-file: "go.mod" | ||||||
|     - name: Setup docker cache |  | ||||||
|       uses: ScribeMD/docker-cache@0.5.0 |  | ||||||
|       with: |  | ||||||
|         key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }} |  | ||||||
|     - 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 sync |         poetry install --sync | ||||||
|         cd web && npm ci |         cd web && npm ci | ||||||
|     - name: Generate config |     - name: Generate config | ||||||
|       shell: poetry run python {0} |       shell: poetry run python {0} | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/actions/setup/docker-compose.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/setup/docker-compose.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,7 +11,7 @@ services: | |||||||
|       - 5432:5432 |       - 5432:5432 | ||||||
|     restart: always |     restart: always | ||||||
|   redis: |   redis: | ||||||
|     image: docker.io/library/redis:7 |     image: docker.io/library/redis | ||||||
|     ports: |     ports: | ||||||
|       - 6379:6379 |       - 6379:6379 | ||||||
|     restart: always |     restart: always | ||||||
|  | |||||||
							
								
								
									
										33
									
								
								.github/codespell-words.txt
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										33
									
								
								.github/codespell-words.txt
									
									
									
									
										vendored
									
									
								
							| @ -1,32 +1,7 @@ | |||||||
| akadmin |  | ||||||
| asgi |  | ||||||
| assertIn |  | ||||||
| authentik |  | ||||||
| authn |  | ||||||
| crate |  | ||||||
| docstrings |  | ||||||
| entra |  | ||||||
| goauthentik |  | ||||||
| gunicorn |  | ||||||
| hass |  | ||||||
| jwe |  | ||||||
| jwks |  | ||||||
| keypair | keypair | ||||||
| keypairs | keypairs | ||||||
| kubernetes | hass | ||||||
| oidc |  | ||||||
| ontext |  | ||||||
| openid |  | ||||||
| passwordless |  | ||||||
| plex |  | ||||||
| saml |  | ||||||
| scim |  | ||||||
| singed |  | ||||||
| slo |  | ||||||
| sso |  | ||||||
| totp |  | ||||||
| traefik |  | ||||||
| # https://github.com/codespell-project/codespell/issues/1224 |  | ||||||
| upToDate |  | ||||||
| warmup | warmup | ||||||
| webauthn | ontext | ||||||
|  | singed | ||||||
|  | assertIn | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -82,12 +82,6 @@ updates: | |||||||
|       docusaurus: |       docusaurus: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@docusaurus/*" |           - "@docusaurus/*" | ||||||
|       build: |  | ||||||
|         patterns: |  | ||||||
|           - "@swc/*" |  | ||||||
|           - "swc-*" |  | ||||||
|           - "lightningcss*" |  | ||||||
|           - "@rspack/binding*" |  | ||||||
|   - package-ecosystem: npm |   - package-ecosystem: npm | ||||||
|     directory: "/lifecycle/aws" |     directory: "/lifecycle/aws" | ||||||
|     schedule: |     schedule: | ||||||
|  | |||||||
| @ -40,7 +40,7 @@ jobs: | |||||||
|       attestations: write |       attestations: write | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: docker/setup-qemu-action@v3.6.0 |       - uses: docker/setup-qemu-action@v3.4.0 | ||||||
|       - 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 | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							| @ -15,8 +15,8 @@ jobs: | |||||||
|       matrix: |       matrix: | ||||||
|         version: |         version: | ||||||
|           - docs |           - docs | ||||||
|           - version-2025-2 |  | ||||||
|           - version-2024-12 |           - version-2024-12 | ||||||
|  |           - version-2024-10 | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - run: | |       - run: | | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -82,7 +82,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           ref: ${{ github.event.pull_request.head.sha }} |           ref: ${{ github.event.pull_request.head.sha }} | ||||||
|       - name: Set up QEMU |       - name: Set up QEMU | ||||||
|         uses: docker/setup-qemu-action@v3.6.0 |         uses: docker/setup-qemu-action@v3.4.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 | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -42,7 +42,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           go-version-file: "go.mod" |           go-version-file: "go.mod" | ||||||
|       - name: Set up QEMU |       - name: Set up QEMU | ||||||
|         uses: docker/setup-qemu-action@v3.6.0 |         uses: docker/setup-qemu-action@v3.4.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 | ||||||
| @ -186,7 +186,7 @@ jobs: | |||||||
|           container=$(docker container create ${{ steps.ev.outputs.imageMainName }}) |           container=$(docker container create ${{ steps.ev.outputs.imageMainName }}) | ||||||
|           docker cp ${container}:web/ . |           docker cp ${container}:web/ . | ||||||
|       - name: Create a Sentry.io release |       - name: Create a Sentry.io release | ||||||
|         uses: getsentry/action-release@v3 |         uses: getsentry/action-release@v1 | ||||||
|         continue-on-error: true |         continue-on-error: true | ||||||
|         env: |         env: | ||||||
|           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} |           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | ||||||
|  | |||||||
| @ -1,13 +1,9 @@ | |||||||
| --- | --- | ||||||
| name: authentik-translate-extract-compile | name: authentik-backend-translate-extract-compile | ||||||
| on: | on: | ||||||
|   schedule: |   schedule: | ||||||
|     - cron: "0 0 * * *" # every day at midnight |     - cron: "0 0 * * *" # every day at midnight | ||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
|   pull_request: |  | ||||||
|     branches: |  | ||||||
|       - main |  | ||||||
|       - version-* |  | ||||||
|  |  | ||||||
| env: | env: | ||||||
|   POSTGRES_DB: authentik |   POSTGRES_DB: authentik | ||||||
| @ -19,21 +15,15 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - id: generate_token |       - id: generate_token | ||||||
|         if: ${{ github.event_name != 'pull_request' }} |  | ||||||
|         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 }} | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|         if: ${{ github.event_name != 'pull_request' }} |  | ||||||
|         with: |         with: | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |           token: ${{ steps.generate_token.outputs.token }} | ||||||
|       - uses: actions/checkout@v4 |  | ||||||
|         if: ${{ github.event_name == 'pull_request' }} |  | ||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - name: Generate API |  | ||||||
|         run: make gen-client-ts |  | ||||||
|       - name: run extract |       - name: run extract | ||||||
|         run: | |         run: | | ||||||
|           poetry run make i18n-extract |           poetry run make i18n-extract | ||||||
| @ -42,7 +32,6 @@ jobs: | |||||||
|           poetry run ak compilemessages |           poetry run ak compilemessages | ||||||
|           make web-check-compile |           make web-check-compile | ||||||
|       - name: Create Pull Request |       - name: Create Pull Request | ||||||
|         if: ${{ github.event_name != 'pull_request' }} |  | ||||||
|         uses: peter-evans/create-pull-request@v7 |         uses: peter-evans/create-pull-request@v7 | ||||||
|         with: |         with: | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |           token: ${{ steps.generate_token.outputs.token }} | ||||||
|  | |||||||
							
								
								
									
										22
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,26 @@ | |||||||
| { | { | ||||||
|  |     "cSpell.words": [ | ||||||
|  |         "akadmin", | ||||||
|  |         "asgi", | ||||||
|  |         "authentik", | ||||||
|  |         "authn", | ||||||
|  |         "entra", | ||||||
|  |         "goauthentik", | ||||||
|  |         "jwe", | ||||||
|  |         "jwks", | ||||||
|  |         "kubernetes", | ||||||
|  |         "oidc", | ||||||
|  |         "openid", | ||||||
|  |         "passwordless", | ||||||
|  |         "plex", | ||||||
|  |         "saml", | ||||||
|  |         "scim", | ||||||
|  |         "slo", | ||||||
|  |         "sso", | ||||||
|  |         "totp", | ||||||
|  |         "traefik", | ||||||
|  |         "webauthn" | ||||||
|  |     ], | ||||||
|     "todo-tree.tree.showCountsInTree": true, |     "todo-tree.tree.showCountsInTree": true, | ||||||
|     "todo-tree.tree.showBadges": true, |     "todo-tree.tree.showBadges": true, | ||||||
|     "yaml.customTags": [ |     "yaml.customTags": [ | ||||||
|  | |||||||
							
								
								
									
										65
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										65
									
								
								Makefile
									
									
									
									
									
								
							| @ -4,17 +4,34 @@ | |||||||
| PWD = $(shell pwd) | PWD = $(shell pwd) | ||||||
| UID = $(shell id -u) | UID = $(shell id -u) | ||||||
| GID = $(shell id -g) | GID = $(shell id -g) | ||||||
| NPM_VERSION = $(shell poetry run python -m scripts.generate_semver) | NPM_VERSION = $(shell python -m scripts.npm_version) | ||||||
| PY_SOURCES = authentik tests scripts lifecycle .github | PY_SOURCES = authentik tests scripts lifecycle .github | ||||||
|  | GO_SOURCES = cmd internal | ||||||
|  | WEB_SOURCES = web/src web/packages | ||||||
| DOCKER_IMAGE ?= "authentik:test" | DOCKER_IMAGE ?= "authentik:test" | ||||||
|  |  | ||||||
| GEN_API_TS = "gen-ts-api" | GEN_API_TS = "gen-ts-api" | ||||||
| GEN_API_PY = "gen-py-api" | GEN_API_PY = "gen-py-api" | ||||||
| GEN_API_GO = "gen-go-api" | GEN_API_GO = "gen-go-api" | ||||||
|  |  | ||||||
| pg_user := $(shell poetry run 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 poetry run 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 poetry run python -m authentik.lib.config postgresql.name 2>/dev/null) | pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null) | ||||||
|  |  | ||||||
|  | CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ | ||||||
|  | 		-I .github/codespell-words.txt \ | ||||||
|  | 		-S 'web/src/locales/**' \ | ||||||
|  | 		-S 'website/developer-docs/api/reference/**' \ | ||||||
|  | 		-S '**/node_modules/**' \ | ||||||
|  | 		-S '**/dist/**' \ | ||||||
|  | 		$(PY_SOURCES) \ | ||||||
|  | 		$(GO_SOURCES) \ | ||||||
|  | 		$(WEB_SOURCES) \ | ||||||
|  | 		website/src \ | ||||||
|  | 		website/blog \ | ||||||
|  | 		website/docs \ | ||||||
|  | 		website/integrations \ | ||||||
|  | 		website/src | ||||||
|  |  | ||||||
| all: lint-fix lint test gen web  ## Lint, build, and test everything | all: lint-fix lint test gen web  ## Lint, build, and test everything | ||||||
|  |  | ||||||
| @ -32,26 +49,26 @@ go-test: | |||||||
| 	go test -timeout 0 -v -race -cover ./... | 	go test -timeout 0 -v -race -cover ./... | ||||||
|  |  | ||||||
| test: ## Run the server tests and produce a coverage report (locally) | test: ## Run the server tests and produce a coverage report (locally) | ||||||
| 	poetry run coverage run manage.py test --keepdb authentik | 	coverage run manage.py test --keepdb authentik | ||||||
| 	poetry run coverage html | 	coverage html | ||||||
| 	poetry run coverage report | 	coverage report | ||||||
|  |  | ||||||
| lint-fix: lint-codespell  ## Lint and automatically fix errors in the python source code. Reports spelling errors. | lint-fix: lint-codespell  ## Lint and automatically fix errors in the python source code. Reports spelling errors. | ||||||
| 	poetry run black $(PY_SOURCES) | 	black $(PY_SOURCES) | ||||||
| 	poetry run ruff check --fix $(PY_SOURCES) | 	ruff check --fix $(PY_SOURCES) | ||||||
|  |  | ||||||
| lint-codespell:  ## Reports spelling errors. | lint-codespell:  ## Reports spelling errors. | ||||||
| 	poetry run codespell -w | 	codespell -w $(CODESPELL_ARGS) | ||||||
|  |  | ||||||
| lint: ## Lint the python and golang sources | lint: ## Lint the python and golang sources | ||||||
| 	poetry run bandit -c pyproject.toml -r $(PY_SOURCES) | 	bandit -r $(PY_SOURCES) -x web/node_modules -x tests/wdio/node_modules -x website/node_modules | ||||||
| 	golangci-lint run -v | 	golangci-lint run -v | ||||||
|  |  | ||||||
| core-install: | core-install: | ||||||
| 	poetry install | 	poetry install | ||||||
|  |  | ||||||
| migrate: ## Run the Authentik Django server's migrations | migrate: ## Run the Authentik Django server's migrations | ||||||
| 	poetry run 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: core-i18n-extract web-i18n-extract  ## Extract strings that require translation into files to send to a translation service | ||||||
|  |  | ||||||
| @ -59,7 +76,7 @@ aws-cfn: | |||||||
| 	cd lifecycle/aws && npm run aws-cfn | 	cd lifecycle/aws && npm run aws-cfn | ||||||
|  |  | ||||||
| core-i18n-extract: | core-i18n-extract: | ||||||
| 	poetry run ak makemessages \ | 	ak makemessages \ | ||||||
| 		--add-location file \ | 		--add-location file \ | ||||||
| 		--no-obsolete \ | 		--no-obsolete \ | ||||||
| 		--ignore web \ | 		--ignore web \ | ||||||
| @ -90,11 +107,11 @@ gen-build:  ## Extract the schema from the database | |||||||
| 	AUTHENTIK_DEBUG=true \ | 	AUTHENTIK_DEBUG=true \ | ||||||
| 		AUTHENTIK_TENANTS__ENABLED=true \ | 		AUTHENTIK_TENANTS__ENABLED=true \ | ||||||
| 		AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ | 		AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ | ||||||
| 		poetry run ak make_blueprint_schema > blueprints/schema.json | 		ak make_blueprint_schema > blueprints/schema.json | ||||||
| 	AUTHENTIK_DEBUG=true \ | 	AUTHENTIK_DEBUG=true \ | ||||||
| 		AUTHENTIK_TENANTS__ENABLED=true \ | 		AUTHENTIK_TENANTS__ENABLED=true \ | ||||||
| 		AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ | 		AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ | ||||||
| 		poetry run ak spectacular --file schema.yml | 		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 | ||||||
| @ -173,7 +190,7 @@ gen-client-go: gen-clean-go  ## Build and install the authentik API for Golang | |||||||
| 	rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/ | 	rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/ | ||||||
|  |  | ||||||
| gen-dev-config:  ## Generate a local development config file | gen-dev-config:  ## Generate a local development config file | ||||||
| 	poetry run scripts/generate_config.py | 	python -m scripts.generate_config | ||||||
|  |  | ||||||
| gen: gen-build gen-client-ts | gen: gen-build gen-client-ts | ||||||
|  |  | ||||||
| @ -254,21 +271,21 @@ ci--meta-debug: | |||||||
| 	node --version | 	node --version | ||||||
|  |  | ||||||
| ci-black: ci--meta-debug | ci-black: ci--meta-debug | ||||||
| 	poetry run black --check $(PY_SOURCES) | 	black --check $(PY_SOURCES) | ||||||
|  |  | ||||||
| ci-ruff: ci--meta-debug | ci-ruff: ci--meta-debug | ||||||
| 	poetry run ruff check $(PY_SOURCES) | 	ruff check $(PY_SOURCES) | ||||||
|  |  | ||||||
| ci-codespell: ci--meta-debug | ci-codespell: ci--meta-debug | ||||||
| 	poetry run codespell -s | 	codespell $(CODESPELL_ARGS) -s | ||||||
|  |  | ||||||
| ci-bandit: ci--meta-debug | ci-bandit: ci--meta-debug | ||||||
| 	poetry run bandit -r $(PY_SOURCES) | 	bandit -r $(PY_SOURCES) | ||||||
|  |  | ||||||
| ci-pending-migrations: ci--meta-debug | ci-pending-migrations: ci--meta-debug | ||||||
| 	poetry run ak makemigrations --check | 	ak makemigrations --check | ||||||
|  |  | ||||||
| ci-test: ci--meta-debug | ci-test: ci--meta-debug | ||||||
| 	poetry run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik | 	coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik | ||||||
| 	poetry run coverage report | 	coverage report | ||||||
| 	poetry run coverage xml | 	coverage xml | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ authentik takes security very seriously. We follow the rules of [responsible di | |||||||
|  |  | ||||||
| ## Independent audits and pentests | ## Independent audits and pentests | ||||||
|  |  | ||||||
| We are committed to engaging in regular pentesting and security audits of authentik. Defining and adhering to a cadence of external testing ensures a stronger probability that our code base, our features, and our architecture is as secure and non-exploitable as possible. For more details about specific audits and pentests, refer to "Audits and Certificates" in our [Security documentation](https://docs.goauthentik.io/docs/security). | We are committed to engaging in regular pentesting and security audits of authentik. Defining and adhering to a cadence of external testing ensures a stronger probability that our code base, our features, and our architecture is as secure and non-exploitable as possible. For more details about specfic audits and pentests, refer to "Audits and Certificates" in our [Security documentation](https://docs.goauthentik.io/docs/security). | ||||||
|  |  | ||||||
| ## What authentik classifies as a CVE | ## What authentik classifies as a CVE | ||||||
|  |  | ||||||
| @ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni | |||||||
|  |  | ||||||
| | Version   | Supported | | | Version   | Supported | | ||||||
| | --------- | --------- | | | --------- | --------- | | ||||||
|  | | 2024.10.x | ✅        | | ||||||
| | 2024.12.x | ✅        | | | 2024.12.x | ✅        | | ||||||
| | 2025.2.x  | ✅        | |  | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| __version__ = "2025.2.1" | __version__ = "2024.12.3" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -50,6 +50,7 @@ from authentik.enterprise.providers.microsoft_entra.models import ( | |||||||
|     MicrosoftEntraProviderGroup, |     MicrosoftEntraProviderGroup, | ||||||
|     MicrosoftEntraProviderUser, |     MicrosoftEntraProviderUser, | ||||||
| ) | ) | ||||||
|  | from authentik.enterprise.providers.rac.models import ConnectionToken | ||||||
| from authentik.enterprise.providers.ssf.models import StreamEvent | from authentik.enterprise.providers.ssf.models import StreamEvent | ||||||
| from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( | from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( | ||||||
|     EndpointDevice, |     EndpointDevice, | ||||||
| @ -71,7 +72,6 @@ from authentik.providers.oauth2.models import ( | |||||||
|     DeviceToken, |     DeviceToken, | ||||||
|     RefreshToken, |     RefreshToken, | ||||||
| ) | ) | ||||||
| from authentik.providers.rac.models import ConnectionToken |  | ||||||
| from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser | from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser | ||||||
| from authentik.rbac.models import Role | from authentik.rbac.models import Role | ||||||
| from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | ||||||
|  | |||||||
| @ -4,7 +4,6 @@ from json import loads | |||||||
|  |  | ||||||
| from django.db.models import Prefetch | from django.db.models import Prefetch | ||||||
| from django.http import Http404 | from django.http import Http404 | ||||||
| from django.utils.translation import gettext as _ |  | ||||||
| from django_filters.filters import CharFilter, ModelMultipleChoiceFilter | from django_filters.filters import CharFilter, ModelMultipleChoiceFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from drf_spectacular.utils import ( | from drf_spectacular.utils import ( | ||||||
| @ -82,37 +81,9 @@ class GroupSerializer(ModelSerializer): | |||||||
|         if not self.instance or not parent: |         if not self.instance or not parent: | ||||||
|             return parent |             return parent | ||||||
|         if str(parent.group_uuid) == str(self.instance.group_uuid): |         if str(parent.group_uuid) == str(self.instance.group_uuid): | ||||||
|             raise ValidationError(_("Cannot set group as parent of itself.")) |             raise ValidationError("Cannot set group as parent of itself.") | ||||||
|         return parent |         return parent | ||||||
|  |  | ||||||
|     def validate_is_superuser(self, superuser: bool): |  | ||||||
|         """Ensure that the user creating this group has permissions to set the superuser flag""" |  | ||||||
|         request: Request = self.context.get("request", None) |  | ||||||
|         if not request: |  | ||||||
|             return superuser |  | ||||||
|         # If we're updating an instance, and the state hasn't changed, we don't need to check perms |  | ||||||
|         if self.instance and superuser == self.instance.is_superuser: |  | ||||||
|             return superuser |  | ||||||
|         user: User = request.user |  | ||||||
|         perm = ( |  | ||||||
|             "authentik_core.enable_group_superuser" |  | ||||||
|             if superuser |  | ||||||
|             else "authentik_core.disable_group_superuser" |  | ||||||
|         ) |  | ||||||
|         has_perm = user.has_perm(perm) |  | ||||||
|         if self.instance and not has_perm: |  | ||||||
|             has_perm = user.has_perm(perm, self.instance) |  | ||||||
|         if not has_perm: |  | ||||||
|             raise ValidationError( |  | ||||||
|                 _( |  | ||||||
|                     ( |  | ||||||
|                         "User does not have permission to set " |  | ||||||
|                         "superuser status to {superuser_status}." |  | ||||||
|                     ).format_map({"superuser_status": superuser}) |  | ||||||
|                 ) |  | ||||||
|             ) |  | ||||||
|         return superuser |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Group |         model = Group | ||||||
|         fields = [ |         fields = [ | ||||||
|  | |||||||
| @ -1,26 +0,0 @@ | |||||||
| # Generated by Django 5.0.11 on 2025-01-30 23:55 |  | ||||||
|  |  | ||||||
| from django.db import migrations |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterModelOptions( |  | ||||||
|             name="group", |  | ||||||
|             options={ |  | ||||||
|                 "permissions": [ |  | ||||||
|                     ("add_user_to_group", "Add user to group"), |  | ||||||
|                     ("remove_user_from_group", "Remove user from group"), |  | ||||||
|                     ("enable_group_superuser", "Enable superuser status"), |  | ||||||
|                     ("disable_group_superuser", "Disable superuser status"), |  | ||||||
|                 ], |  | ||||||
|                 "verbose_name": "Group", |  | ||||||
|                 "verbose_name_plural": "Groups", |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -204,8 +204,6 @@ class Group(SerializerModel, AttributesMixin): | |||||||
|         permissions = [ |         permissions = [ | ||||||
|             ("add_user_to_group", _("Add user to group")), |             ("add_user_to_group", _("Add user to group")), | ||||||
|             ("remove_user_from_group", _("Remove user from group")), |             ("remove_user_from_group", _("Remove user from group")), | ||||||
|             ("enable_group_superuser", _("Enable superuser status")), |  | ||||||
|             ("disable_group_superuser", _("Disable superuser status")), |  | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|  | |||||||
| @ -35,7 +35,8 @@ from authentik.flows.planner import ( | |||||||
|     FlowPlanner, |     FlowPlanner, | ||||||
| ) | ) | ||||||
| from authentik.flows.stage import StageView | from authentik.flows.stage import StageView | ||||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET | from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.lib.views import bad_request_message | from authentik.lib.views import bad_request_message | ||||||
| from authentik.policies.denied import AccessDeniedResponse | from authentik.policies.denied import AccessDeniedResponse | ||||||
| from authentik.policies.utils import delete_none_values | from authentik.policies.utils import delete_none_values | ||||||
| @ -46,9 +47,8 @@ from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH | |||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| PLAN_CONTEXT_SOURCE_GROUPS = "source_groups" |  | ||||||
| SESSION_KEY_SOURCE_FLOW_STAGES = "authentik/flows/source_flow_stages" |  | ||||||
| SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token"  # nosec | SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token"  # nosec | ||||||
|  | PLAN_CONTEXT_SOURCE_GROUPS = "source_groups" | ||||||
|  |  | ||||||
|  |  | ||||||
| class MessageStage(StageView): | class MessageStage(StageView): | ||||||
| @ -219,28 +219,28 @@ class SourceFlowManager: | |||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|         flow_context.update(self.policy_context) |         flow_context.update(self.policy_context) | ||||||
|  |         if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: | ||||||
|  |             token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) | ||||||
|  |             self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) | ||||||
|  |             plan = token.plan | ||||||
|  |             plan.context[PLAN_CONTEXT_IS_RESTORED] = token | ||||||
|  |             plan.context.update(flow_context) | ||||||
|  |             for stage in self.get_stages_to_append(flow): | ||||||
|  |                 plan.append_stage(stage) | ||||||
|  |             if stages: | ||||||
|  |                 for stage in stages: | ||||||
|  |                     plan.append_stage(stage) | ||||||
|  |             self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |             flow_slug = token.flow.slug | ||||||
|  |             token.delete() | ||||||
|  |             return redirect_with_qs( | ||||||
|  |                 "authentik_core:if-flow", | ||||||
|  |                 self.request.GET, | ||||||
|  |                 flow_slug=flow_slug, | ||||||
|  |             ) | ||||||
|         flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect) |         flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect) | ||||||
|  |  | ||||||
|         if not flow: |         if not flow: | ||||||
|             # We only check for the flow token here if we don't have a flow, otherwise we rely on |  | ||||||
|             # SESSION_KEY_SOURCE_FLOW_STAGES to delegate the usage of this token and dynamically add |  | ||||||
|             # stages that deal with this token to return to another flow |  | ||||||
|             if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: |  | ||||||
|                 token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) |  | ||||||
|                 self._logger.info( |  | ||||||
|                     "Replacing source flow with overridden flow", flow=token.flow.slug |  | ||||||
|                 ) |  | ||||||
|                 plan = token.plan |  | ||||||
|                 plan.context[PLAN_CONTEXT_IS_RESTORED] = token |  | ||||||
|                 plan.context.update(flow_context) |  | ||||||
|                 for stage in self.get_stages_to_append(flow): |  | ||||||
|                     plan.append_stage(stage) |  | ||||||
|                 if stages: |  | ||||||
|                     for stage in stages: |  | ||||||
|                         plan.append_stage(stage) |  | ||||||
|                 redirect = plan.to_redirect(self.request, token.flow) |  | ||||||
|                 token.delete() |  | ||||||
|                 return redirect |  | ||||||
|             return bad_request_message( |             return bad_request_message( | ||||||
|                 self.request, |                 self.request, | ||||||
|                 _("Configured flow does not exist."), |                 _("Configured flow does not exist."), | ||||||
| @ -259,8 +259,6 @@ class SourceFlowManager: | |||||||
|         if stages: |         if stages: | ||||||
|             for stage in stages: |             for stage in stages: | ||||||
|                 plan.append_stage(stage) |                 plan.append_stage(stage) | ||||||
|         for stage in self.request.session.get(SESSION_KEY_SOURCE_FLOW_STAGES, []): |  | ||||||
|             plan.append_stage(stage) |  | ||||||
|         return plan.to_redirect(self.request, flow) |         return plan.to_redirect(self.request, flow) | ||||||
|  |  | ||||||
|     def handle_auth( |     def handle_auth( | ||||||
| @ -297,8 +295,6 @@ class SourceFlowManager: | |||||||
|         # When request isn't authenticated we jump straight to auth |         # When request isn't authenticated we jump straight to auth | ||||||
|         if not self.request.user.is_authenticated: |         if not self.request.user.is_authenticated: | ||||||
|             return self.handle_auth(connection) |             return self.handle_auth(connection) | ||||||
|         # When an override flow token exists we actually still use a flow for link |  | ||||||
|         # to continue the existing flow we came from |  | ||||||
|         if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: |         if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: | ||||||
|             return self._prepare_flow(None, connection) |             return self._prepare_flow(None, connection) | ||||||
|         connection.save() |         connection.save() | ||||||
|  | |||||||
| @ -67,8 +67,6 @@ def clean_expired_models(self: SystemTask): | |||||||
|                 raise ImproperlyConfigured( |                 raise ImproperlyConfigured( | ||||||
|                     "Invalid session_storage setting, allowed values are db and cache" |                     "Invalid session_storage setting, allowed values are db and cache" | ||||||
|                 ) |                 ) | ||||||
|     if CONFIG.get("session_storage", "cache") == "db": |  | ||||||
|         DBSessionStore.clear_expired() |  | ||||||
|     LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount) |     LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount) | ||||||
|  |  | ||||||
|     messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}") |     messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}") | ||||||
|  | |||||||
| @ -11,7 +11,6 @@ | |||||||
|         build: "{{ build }}", |         build: "{{ build }}", | ||||||
|         api: { |         api: { | ||||||
|             base: "{{ base_url }}", |             base: "{{ base_url }}", | ||||||
|             relBase: "{{ base_url_rel }}", |  | ||||||
|         }, |         }, | ||||||
|     }; |     }; | ||||||
|     window.addEventListener("DOMContentLoaded", function () { |     window.addEventListener("DOMContentLoaded", function () { | ||||||
|  | |||||||
| @ -8,8 +8,6 @@ | |||||||
|     <head> |     <head> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|         {# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #} |  | ||||||
|         <meta name="darkreader-lock"> |  | ||||||
|         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> |         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||||
|         <link rel="icon" href="{{ brand.branding_favicon_url }}"> |         <link rel="icon" href="{{ brand.branding_favicon_url }}"> | ||||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> |         <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from django.urls.base import reverse | |||||||
| from guardian.shortcuts import assign_perm | from guardian.shortcuts import assign_perm | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import Group | from authentik.core.models import Group, User | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_user | from authentik.core.tests.utils import create_test_admin_user, create_test_user | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
|  |  | ||||||
| @ -14,7 +14,7 @@ class TestGroupsAPI(APITestCase): | |||||||
|  |  | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         self.login_user = create_test_user() |         self.login_user = create_test_user() | ||||||
|         self.user = create_test_user() |         self.user = User.objects.create(username="test-user") | ||||||
|  |  | ||||||
|     def test_list_with_users(self): |     def test_list_with_users(self): | ||||||
|         """Test listing with users""" |         """Test listing with users""" | ||||||
| @ -109,57 +109,3 @@ class TestGroupsAPI(APITestCase): | |||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(res.status_code, 400) |         self.assertEqual(res.status_code, 400) | ||||||
|  |  | ||||||
|     def test_superuser_no_perm(self): |  | ||||||
|         """Test creating a superuser group without permission""" |  | ||||||
|         assign_perm("authentik_core.add_group", self.login_user) |  | ||||||
|         self.client.force_login(self.login_user) |  | ||||||
|         res = self.client.post( |  | ||||||
|             reverse("authentik_api:group-list"), |  | ||||||
|             data={"name": generate_id(), "is_superuser": True}, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(res.status_code, 400) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             res.content, |  | ||||||
|             {"is_superuser": ["User does not have permission to set superuser status to True."]}, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_superuser_update_no_perm(self): |  | ||||||
|         """Test updating a superuser group without permission""" |  | ||||||
|         group = Group.objects.create(name=generate_id(), is_superuser=True) |  | ||||||
|         assign_perm("view_group", self.login_user, group) |  | ||||||
|         assign_perm("change_group", self.login_user, group) |  | ||||||
|         self.client.force_login(self.login_user) |  | ||||||
|         res = self.client.patch( |  | ||||||
|             reverse("authentik_api:group-detail", kwargs={"pk": group.pk}), |  | ||||||
|             data={"is_superuser": False}, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(res.status_code, 400) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             res.content, |  | ||||||
|             {"is_superuser": ["User does not have permission to set superuser status to False."]}, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_superuser_update_no_change(self): |  | ||||||
|         """Test updating a superuser group without permission |  | ||||||
|         and without changing the superuser status""" |  | ||||||
|         group = Group.objects.create(name=generate_id(), is_superuser=True) |  | ||||||
|         assign_perm("view_group", self.login_user, group) |  | ||||||
|         assign_perm("change_group", self.login_user, group) |  | ||||||
|         self.client.force_login(self.login_user) |  | ||||||
|         res = self.client.patch( |  | ||||||
|             reverse("authentik_api:group-detail", kwargs={"pk": group.pk}), |  | ||||||
|             data={"name": generate_id(), "is_superuser": True}, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(res.status_code, 200) |  | ||||||
|  |  | ||||||
|     def test_superuser_create(self): |  | ||||||
|         """Test creating a superuser group with permission""" |  | ||||||
|         assign_perm("authentik_core.add_group", self.login_user) |  | ||||||
|         assign_perm("authentik_core.enable_group_superuser", self.login_user) |  | ||||||
|         self.client.force_login(self.login_user) |  | ||||||
|         res = self.client.post( |  | ||||||
|             reverse("authentik_api:group-list"), |  | ||||||
|             data={"name": generate_id(), "is_superuser": True}, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(res.status_code, 201) |  | ||||||
|  | |||||||
| @ -55,7 +55,7 @@ class RedirectToAppLaunch(View): | |||||||
|             ) |             ) | ||||||
|         except FlowNonApplicableException: |         except FlowNonApplicableException: | ||||||
|             raise Http404 from None |             raise Http404 from None | ||||||
|         plan.append_stage(in_memory_stage(RedirectToAppStage)) |         plan.insert_stage(in_memory_stage(RedirectToAppStage)) | ||||||
|         return plan.to_redirect(request, flow) |         return plan.to_redirect(request, flow) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -53,7 +53,6 @@ class InterfaceView(TemplateView): | |||||||
|         kwargs["build"] = get_build_hash() |         kwargs["build"] = get_build_hash() | ||||||
|         kwargs["url_kwargs"] = self.kwargs |         kwargs["url_kwargs"] = self.kwargs | ||||||
|         kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/")) |         kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/")) | ||||||
|         kwargs["base_url_rel"] = CONFIG.get("web.path", "/") |  | ||||||
|         return super().get_context_data(**kwargs) |         return super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ class AuthentikEnterpriseAuditConfig(EnterpriseConfig): | |||||||
|     """Enterprise app config""" |     """Enterprise app config""" | ||||||
|  |  | ||||||
|     name = "authentik.enterprise.audit" |     name = "authentik.enterprise.audit" | ||||||
|     label = "authentik_audit" |     label = "authentik_enterprise_audit" | ||||||
|     verbose_name = "authentik Enterprise.Audit" |     verbose_name = "authentik Enterprise.Audit" | ||||||
|     default = True |     default = True | ||||||
|  |  | ||||||
|  | |||||||
| @ -97,8 +97,6 @@ class EnterpriseAuditMiddleware(AuditMiddleware): | |||||||
|         thread_kwargs: dict | None = None, |         thread_kwargs: dict | None = None, | ||||||
|         **_, |         **_, | ||||||
|     ): |     ): | ||||||
|         if not self.enabled: |  | ||||||
|             return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_) |  | ||||||
|         if not should_log_model(instance): |         if not should_log_model(instance): | ||||||
|             return None |             return None | ||||||
|         thread_kwargs = {} |         thread_kwargs = {} | ||||||
| @ -124,8 +122,6 @@ class EnterpriseAuditMiddleware(AuditMiddleware): | |||||||
|     ): |     ): | ||||||
|         thread_kwargs = {} |         thread_kwargs = {} | ||||||
|         m2m_field = None |         m2m_field = None | ||||||
|         if not self.enabled: |  | ||||||
|             return super().m2m_changed_handler(request, sender, instance, action, thread_kwargs) |  | ||||||
|         # For the audit log we don't care about `pre_` or `post_` so we trim that part off |         # For the audit log we don't care about `pre_` or `post_` so we trim that part off | ||||||
|         _, _, action_direction = action.partition("_") |         _, _, action_direction = action.partition("_") | ||||||
|         # resolve the "through" model to an actual field |         # resolve the "through" model to an actual field | ||||||
|  | |||||||
| @ -1,107 +0,0 @@ | |||||||
| from django.contrib.contenttypes.models import ContentType |  | ||||||
| from django.contrib.contenttypes.fields import GenericForeignKey |  | ||||||
| from authentik.lib.models import SerializerModel |  | ||||||
| from django.db import models |  | ||||||
| from uuid import uuid4 |  | ||||||
| from authentik.core.models import Group, User |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # # Names |  | ||||||
| # Lifecycle |  | ||||||
| # Access reviews |  | ||||||
| # Access lifecycle |  | ||||||
| # Governance |  | ||||||
| # Audit |  | ||||||
| # Compliance |  | ||||||
|  |  | ||||||
| # Lifecycle |  | ||||||
| # Lifecycle review |  | ||||||
| # Review |  | ||||||
| # Access review |  | ||||||
| # Compliance review |  | ||||||
| # X Scheduled review |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Only some objects supported? |  | ||||||
| # |  | ||||||
| # For disabling support: |  | ||||||
| # Application |  | ||||||
| # Provider |  | ||||||
| # Outpost (simply setting the list of providers to empty in the outpost itself) |  | ||||||
| # Flow |  | ||||||
| # Users |  | ||||||
| # Groups <- will get tricky |  | ||||||
| # Roles |  | ||||||
| # Sources |  | ||||||
| # Tokens (api, app_pass) |  | ||||||
| # Brands |  | ||||||
| # Outpost integrations |  | ||||||
| # |  | ||||||
| # w/o disabling support |  | ||||||
| # System Settings |  | ||||||
| # everything else |  | ||||||
| #   would need to show in an audit dashboard cause not all have pages to get details |  | ||||||
|  |  | ||||||
| # "default" policy for objects, by default, everlasting |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuditPolicyFailAction(models.TextChoices): |  | ||||||
|     # For preview |  | ||||||
|     NOTHING = "nothing" |  | ||||||
|     # Disable the thing failing, HOW |  | ||||||
|     DISABLE = "disable" |  | ||||||
|     # Emit events |  | ||||||
|     WARN = "warn" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class LifecycleRule(SerializerModel): |  | ||||||
|     pass |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReviewRule(SerializerModel): |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|  |  | ||||||
|     # Check every 6 months, allow for daily/weekly/first of month, etc. |  | ||||||
|     interval = models.TextField()  # timedelta |  | ||||||
|     # Preventive notification |  | ||||||
|     reminder_interval = models.TextField()  # timedelta |  | ||||||
|  |  | ||||||
|     # Must be checked by these |  | ||||||
|     groups = models.ManyToManyField(Group) |  | ||||||
|     users = models.ManyToManyField(User) |  | ||||||
|  |  | ||||||
|     # How many of the above must approve |  | ||||||
|     required_approvals = models.IntegerField(default=1) |  | ||||||
|  |  | ||||||
|     # How long to wait before executing fail action |  | ||||||
|     grace_period = models.TextField()  # timedelta |  | ||||||
|  |  | ||||||
|     # What to do if not reviewed in time |  | ||||||
|     fail_action = models.CharField(choices=AuditPolicyFailAction) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuditPolicyBinding(SerializerModel): |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|  |  | ||||||
|     # Many to many ? Bind users/groups here instead of on the policy ? |  | ||||||
|     policy = models.ForeignKey(AuditPolicy, on_delete=models.PROTECT) |  | ||||||
|  |  | ||||||
|     content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) |  | ||||||
|     object_id = models.TextField(blank=True)  # optional to apply on all objects of specific type |  | ||||||
|     content_object = GenericForeignKey("content_type", "object_id") |  | ||||||
|  |  | ||||||
|     # valid -> waiting review -> valid |  | ||||||
|     # valid -> waiting review -> review overdue -> valid |  | ||||||
|     # valid -> waiting review -> review overdue -> failed -> valid |  | ||||||
|     # look at django-fsm or django-viewflow |  | ||||||
|     status = models.TextField() |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         indexes = ( |  | ||||||
|             models.Index(fields=["content_type"]), |  | ||||||
|             models.Index(fields=["content_type", "object_id"]), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuditHistory: |  | ||||||
|     pass |  | ||||||
| @ -37,7 +37,6 @@ class GoogleWorkspaceProviderSerializer(EnterpriseRequiredMixin, ProviderSeriali | |||||||
|             "user_delete_action", |             "user_delete_action", | ||||||
|             "group_delete_action", |             "group_delete_action", | ||||||
|             "default_group_email_domain", |             "default_group_email_domain", | ||||||
|             "dry_run", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = {} |         extra_kwargs = {} | ||||||
|  |  | ||||||
|  | |||||||
| @ -8,10 +8,9 @@ from httplib2 import HttpLib2Error, HttpLib2ErrorWithResponse | |||||||
|  |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider | from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider | ||||||
| from authentik.lib.sync.outgoing import HTTP_CONFLICT | from authentik.lib.sync.outgoing import HTTP_CONFLICT | ||||||
| from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient | from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( | from authentik.lib.sync.outgoing.exceptions import ( | ||||||
|     BadRequestSyncException, |     BadRequestSyncException, | ||||||
|     DryRunRejected, |  | ||||||
|     NotFoundSyncException, |     NotFoundSyncException, | ||||||
|     ObjectExistsSyncException, |     ObjectExistsSyncException, | ||||||
|     StopSync, |     StopSync, | ||||||
| @ -44,8 +43,6 @@ class GoogleWorkspaceSyncClient[TModel: Model, TConnection: Model, TSchema: dict | |||||||
|             self.domains.append(domain_name) |             self.domains.append(domain_name) | ||||||
|  |  | ||||||
|     def _request(self, request: HttpRequest): |     def _request(self, request: HttpRequest): | ||||||
|         if self.provider.dry_run and request.method.upper() not in SAFE_METHODS: |  | ||||||
|             raise DryRunRejected(request.uri, request.method, request.body) |  | ||||||
|         try: |         try: | ||||||
|             response = request.execute() |             response = request.execute() | ||||||
|         except GoogleAuthError as exc: |         except GoogleAuthError as exc: | ||||||
|  | |||||||
| @ -1,24 +0,0 @@ | |||||||
| # Generated by Django 5.0.12 on 2025-02-24 19:43 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ( |  | ||||||
|             "authentik_providers_google_workspace", |  | ||||||
|             "0003_googleworkspaceprovidergroup_attributes_and_more", |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="googleworkspaceprovider", |  | ||||||
|             name="dry_run", |  | ||||||
|             field=models.BooleanField( |  | ||||||
|                 default=False, |  | ||||||
|                 help_text="When enabled, provider will not modify or create objects in the remote system.", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -36,7 +36,6 @@ class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializ | |||||||
|             "filter_group", |             "filter_group", | ||||||
|             "user_delete_action", |             "user_delete_action", | ||||||
|             "group_delete_action", |             "group_delete_action", | ||||||
|             "dry_run", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = {} |         extra_kwargs = {} | ||||||
|  |  | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ from collections.abc import Coroutine | |||||||
| from dataclasses import asdict | from dataclasses import asdict | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| import httpx |  | ||||||
| from azure.core.exceptions import ( | from azure.core.exceptions import ( | ||||||
|     ClientAuthenticationError, |     ClientAuthenticationError, | ||||||
|     ServiceRequestError, |     ServiceRequestError, | ||||||
| @ -13,7 +12,6 @@ from azure.identity.aio import ClientSecretCredential | |||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.http import HttpResponseBadRequest, HttpResponseNotFound | from django.http import HttpResponseBadRequest, HttpResponseNotFound | ||||||
| from kiota_abstractions.api_error import APIError | from kiota_abstractions.api_error import APIError | ||||||
| from kiota_abstractions.request_information import RequestInformation |  | ||||||
| from kiota_authentication_azure.azure_identity_authentication_provider import ( | from kiota_authentication_azure.azure_identity_authentication_provider import ( | ||||||
|     AzureIdentityAuthenticationProvider, |     AzureIdentityAuthenticationProvider, | ||||||
| ) | ) | ||||||
| @ -23,15 +21,13 @@ from msgraph.generated.models.o_data_errors.o_data_error import ODataError | |||||||
| from msgraph.graph_request_adapter import GraphRequestAdapter, options | from msgraph.graph_request_adapter import GraphRequestAdapter, options | ||||||
| from msgraph.graph_service_client import GraphServiceClient | from msgraph.graph_service_client import GraphServiceClient | ||||||
| from msgraph_core import GraphClientFactory | from msgraph_core import GraphClientFactory | ||||||
| from opentelemetry import trace |  | ||||||
|  |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider | from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider | ||||||
| from authentik.events.utils import sanitize_item | from authentik.events.utils import sanitize_item | ||||||
| from authentik.lib.sync.outgoing import HTTP_CONFLICT | from authentik.lib.sync.outgoing import HTTP_CONFLICT | ||||||
| from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient | from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( | from authentik.lib.sync.outgoing.exceptions import ( | ||||||
|     BadRequestSyncException, |     BadRequestSyncException, | ||||||
|     DryRunRejected, |  | ||||||
|     NotFoundSyncException, |     NotFoundSyncException, | ||||||
|     ObjectExistsSyncException, |     ObjectExistsSyncException, | ||||||
|     StopSync, |     StopSync, | ||||||
| @ -39,24 +35,20 @@ from authentik.lib.sync.outgoing.exceptions import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikRequestAdapter(GraphRequestAdapter): | def get_request_adapter( | ||||||
|     def __init__(self, auth_provider, provider: MicrosoftEntraProvider, client=None): |     credentials: ClientSecretCredential, scopes: list[str] | None = None | ||||||
|         super().__init__(auth_provider, client) | ) -> GraphRequestAdapter: | ||||||
|         self._provider = provider |     if scopes: | ||||||
|  |         auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials, scopes=scopes) | ||||||
|  |     else: | ||||||
|  |         auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials) | ||||||
|  |  | ||||||
|     async def get_http_response_message( |     return GraphRequestAdapter( | ||||||
|         self, |         auth_provider=auth_provider, | ||||||
|         request_info: RequestInformation, |         client=GraphClientFactory.create_with_default_middleware( | ||||||
|         parent_span: trace.Span, |             options=options, client=KiotaClientFactory.get_default_client() | ||||||
|         claims: str = "", |         ), | ||||||
|     ) -> httpx.Response: |     ) | ||||||
|         if self._provider.dry_run and request_info.http_method.value.upper() not in SAFE_METHODS: |  | ||||||
|             raise DryRunRejected( |  | ||||||
|                 url=request_info.url, |  | ||||||
|                 method=request_info.http_method.value, |  | ||||||
|                 body=request_info.content.decode("utf-8"), |  | ||||||
|             ) |  | ||||||
|         return await super().get_http_response_message(request_info, parent_span, claims=claims) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict]( | class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict]( | ||||||
| @ -71,27 +63,9 @@ class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict] | |||||||
|         self.credentials = provider.microsoft_credentials() |         self.credentials = provider.microsoft_credentials() | ||||||
|         self.__prefetch_domains() |         self.__prefetch_domains() | ||||||
|  |  | ||||||
|     def get_request_adapter( |  | ||||||
|         self, credentials: ClientSecretCredential, scopes: list[str] | None = None |  | ||||||
|     ) -> AuthentikRequestAdapter: |  | ||||||
|         if scopes: |  | ||||||
|             auth_provider = AzureIdentityAuthenticationProvider( |  | ||||||
|                 credentials=credentials, scopes=scopes |  | ||||||
|             ) |  | ||||||
|         else: |  | ||||||
|             auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials) |  | ||||||
|  |  | ||||||
|         return AuthentikRequestAdapter( |  | ||||||
|             auth_provider=auth_provider, |  | ||||||
|             provider=self.provider, |  | ||||||
|             client=GraphClientFactory.create_with_default_middleware( |  | ||||||
|                 options=options, client=KiotaClientFactory.get_default_client() |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def client(self): |     def client(self): | ||||||
|         return GraphServiceClient(request_adapter=self.get_request_adapter(**self.credentials)) |         return GraphServiceClient(request_adapter=get_request_adapter(**self.credentials)) | ||||||
|  |  | ||||||
|     def _request[T](self, request: Coroutine[Any, Any, T]) -> T: |     def _request[T](self, request: Coroutine[Any, Any, T]) -> T: | ||||||
|         try: |         try: | ||||||
|  | |||||||
| @ -1,24 +0,0 @@ | |||||||
| # Generated by Django 5.0.12 on 2025-02-24 19:43 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ( |  | ||||||
|             "authentik_providers_microsoft_entra", |  | ||||||
|             "0002_microsoftentraprovidergroup_attributes_and_more", |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="microsoftentraprovider", |  | ||||||
|             name="dry_run", |  | ||||||
|             field=models.BooleanField( |  | ||||||
|                 default=False, |  | ||||||
|                 help_text="When enabled, provider will not modify or create objects in the remote system.", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -32,6 +32,7 @@ class MicrosoftEntraUserTests(APITestCase): | |||||||
|  |  | ||||||
|     @apply_blueprint("system/providers-microsoft-entra.yaml") |     @apply_blueprint("system/providers-microsoft-entra.yaml") | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|  |  | ||||||
|         # Delete all users and groups as the mocked HTTP responses only return one ID |         # Delete all users and groups as the mocked HTTP responses only return one ID | ||||||
|         # which will cause errors with multiple users |         # which will cause errors with multiple users | ||||||
|         Tenant.objects.update(avatars="none") |         Tenant.objects.update(avatars="none") | ||||||
| @ -96,38 +97,6 @@ class MicrosoftEntraUserTests(APITestCase): | |||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) | ||||||
|             user_create.assert_called_once() |             user_create.assert_called_once() | ||||||
|  |  | ||||||
|     def test_user_create_dry_run(self): |  | ||||||
|         """Test user creation (dry run)""" |  | ||||||
|         self.provider.dry_run = True |  | ||||||
|         self.provider.save() |  | ||||||
|         uid = generate_id() |  | ||||||
|         with ( |  | ||||||
|             patch( |  | ||||||
|                 "authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials", |  | ||||||
|                 MagicMock(return_value={"credentials": self.creds}), |  | ||||||
|             ), |  | ||||||
|             patch( |  | ||||||
|                 "msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get", |  | ||||||
|                 AsyncMock( |  | ||||||
|                     return_value=OrganizationCollectionResponse( |  | ||||||
|                         value=[ |  | ||||||
|                             Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")]) |  | ||||||
|                         ] |  | ||||||
|                     ) |  | ||||||
|                 ), |  | ||||||
|             ), |  | ||||||
|         ): |  | ||||||
|             user = User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|             microsoft_user = MicrosoftEntraProviderUser.objects.filter( |  | ||||||
|                 provider=self.provider, user=user |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNone(microsoft_user) |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|  |  | ||||||
|     def test_user_not_created(self): |     def test_user_not_created(self): | ||||||
|         """Test without property mappings, no group is created""" |         """Test without property mappings, no group is created""" | ||||||
|         self.provider.property_mappings.clear() |         self.provider.property_mappings.clear() | ||||||
|  | |||||||
| @ -6,12 +6,13 @@ from rest_framework.viewsets import GenericViewSet | |||||||
| from authentik.core.api.groups import GroupMemberSerializer | from authentik.core.api.groups import GroupMemberSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ModelSerializer | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.providers.rac.api.endpoints import EndpointSerializer | from authentik.enterprise.api import EnterpriseRequiredMixin | ||||||
| from authentik.providers.rac.api.providers import RACProviderSerializer | from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer | ||||||
| from authentik.providers.rac.models import ConnectionToken | from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | ||||||
|  | from authentik.enterprise.providers.rac.models import ConnectionToken | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ConnectionTokenSerializer(ModelSerializer): | class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer): | ||||||
|     """ConnectionToken Serializer""" |     """ConnectionToken Serializer""" | ||||||
| 
 | 
 | ||||||
|     provider_obj = RACProviderSerializer(source="provider", read_only=True) |     provider_obj = RACProviderSerializer(source="provider", read_only=True) | ||||||
| @ -14,9 +14,10 @@ from structlog.stdlib import get_logger | |||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ModelSerializer | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
|  | from authentik.enterprise.api import EnterpriseRequiredMixin | ||||||
|  | from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | ||||||
|  | from authentik.enterprise.providers.rac.models import Endpoint | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.providers.rac.api.providers import RACProviderSerializer |  | ||||||
| from authentik.providers.rac.models import Endpoint |  | ||||||
| from authentik.rbac.filters import ObjectFilter | from authentik.rbac.filters import ObjectFilter | ||||||
| 
 | 
 | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -27,7 +28,7 @@ def user_endpoint_cache_key(user_pk: str) -> str: | |||||||
|     return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}" |     return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class EndpointSerializer(ModelSerializer): | class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer): | ||||||
|     """Endpoint Serializer""" |     """Endpoint Serializer""" | ||||||
| 
 | 
 | ||||||
|     provider_obj = RACProviderSerializer(source="provider", read_only=True) |     provider_obj = RACProviderSerializer(source="provider", read_only=True) | ||||||
| @ -10,7 +10,7 @@ from rest_framework.viewsets import ModelViewSet | |||||||
| from authentik.core.api.property_mappings import PropertyMappingSerializer | from authentik.core.api.property_mappings import PropertyMappingSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import JSONDictField | from authentik.core.api.utils import JSONDictField | ||||||
| from authentik.providers.rac.models import RACPropertyMapping | from authentik.enterprise.providers.rac.models import RACPropertyMapping | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class RACPropertyMappingSerializer(PropertyMappingSerializer): | class RACPropertyMappingSerializer(PropertyMappingSerializer): | ||||||
| @ -5,10 +5,11 @@ from rest_framework.viewsets import ModelViewSet | |||||||
| 
 | 
 | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.providers.rac.models import RACProvider | from authentik.enterprise.api import EnterpriseRequiredMixin | ||||||
|  | from authentik.enterprise.providers.rac.models import RACProvider | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class RACProviderSerializer(ProviderSerializer): | class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): | ||||||
|     """RACProvider Serializer""" |     """RACProvider Serializer""" | ||||||
| 
 | 
 | ||||||
|     outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all") |     outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all") | ||||||
							
								
								
									
										14
									
								
								authentik/enterprise/providers/rac/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								authentik/enterprise/providers/rac/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | |||||||
|  | """RAC app config""" | ||||||
|  |  | ||||||
|  | from authentik.enterprise.apps import EnterpriseConfig | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AuthentikEnterpriseProviderRAC(EnterpriseConfig): | ||||||
|  |     """authentik enterprise rac app config""" | ||||||
|  |  | ||||||
|  |     name = "authentik.enterprise.providers.rac" | ||||||
|  |     label = "authentik_providers_rac" | ||||||
|  |     verbose_name = "authentik Enterprise.Providers.RAC" | ||||||
|  |     default = True | ||||||
|  |     mountpoint = "" | ||||||
|  |     ws_mountpoint = "authentik.enterprise.providers.rac.urls" | ||||||
| @ -7,22 +7,22 @@ from channels.generic.websocket import AsyncWebsocketConsumer | |||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
| 
 | 
 | ||||||
|  | from authentik.enterprise.providers.rac.models import ConnectionToken, RACProvider | ||||||
| from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE | from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE | ||||||
| from authentik.outposts.models import Outpost, OutpostState, OutpostType | from authentik.outposts.models import Outpost, OutpostState, OutpostType | ||||||
| from authentik.providers.rac.models import ConnectionToken, RACProvider |  | ||||||
| 
 | 
 | ||||||
| # Global broadcast group, which messages are sent to when the outpost connects back | # Global broadcast group, which messages are sent to when the outpost connects back | ||||||
| # to authentik for a specific connection | # to authentik for a specific connection | ||||||
| # The `RACClientConsumer` consumer adds itself to this group on connection, | # The `RACClientConsumer` consumer adds itself to this group on connection, | ||||||
| # and removes itself once it has been assigned a specific outpost channel | # and removes itself once it has been assigned a specific outpost channel | ||||||
| RAC_CLIENT_GROUP = "group_rac_client" | RAC_CLIENT_GROUP = "group_enterprise_rac_client" | ||||||
| # A group for all connections in a given authentik session ID | # A group for all connections in a given authentik session ID | ||||||
| # A disconnect message is sent to this group when the session expires/is deleted | # A disconnect message is sent to this group when the session expires/is deleted | ||||||
| RAC_CLIENT_GROUP_SESSION = "group_rac_client_%(session)s" | RAC_CLIENT_GROUP_SESSION = "group_enterprise_rac_client_%(session)s" | ||||||
| # A group for all connections with a specific token, which in almost all cases | # A group for all connections with a specific token, which in almost all cases | ||||||
| # is just one connection, however this is used to disconnect the connection | # is just one connection, however this is used to disconnect the connection | ||||||
| # when the token is deleted | # when the token is deleted | ||||||
| RAC_CLIENT_GROUP_TOKEN = "group_rac_token_%(token)s"  # nosec | RAC_CLIENT_GROUP_TOKEN = "group_enterprise_rac_token_%(token)s"  # nosec | ||||||
| 
 | 
 | ||||||
| # Step 1: Client connects to this websocket endpoint | # Step 1: Client connects to this websocket endpoint | ||||||
| # Step 2: We prepare all the connection args for Guac | # Step 2: We prepare all the connection args for Guac | ||||||
| @ -3,7 +3,7 @@ | |||||||
| from channels.exceptions import ChannelFull | from channels.exceptions import ChannelFull | ||||||
| from channels.generic.websocket import AsyncWebsocketConsumer | from channels.generic.websocket import AsyncWebsocketConsumer | ||||||
| 
 | 
 | ||||||
| from authentik.providers.rac.consumer_client import RAC_CLIENT_GROUP | from authentik.enterprise.providers.rac.consumer_client import RAC_CLIENT_GROUP | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class RACOutpostConsumer(AsyncWebsocketConsumer): | class RACOutpostConsumer(AsyncWebsocketConsumer): | ||||||
| @ -74,7 +74,7 @@ class RACProvider(Provider): | |||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.rac.api.providers import RACProviderSerializer |         from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | ||||||
| 
 | 
 | ||||||
|         return RACProviderSerializer |         return RACProviderSerializer | ||||||
| 
 | 
 | ||||||
| @ -100,7 +100,7 @@ class Endpoint(SerializerModel, PolicyBindingModel): | |||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.rac.api.endpoints import EndpointSerializer |         from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer | ||||||
| 
 | 
 | ||||||
|         return EndpointSerializer |         return EndpointSerializer | ||||||
| 
 | 
 | ||||||
| @ -129,7 +129,7 @@ class RACPropertyMapping(PropertyMapping): | |||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.rac.api.property_mappings import ( |         from authentik.enterprise.providers.rac.api.property_mappings import ( | ||||||
|             RACPropertyMappingSerializer, |             RACPropertyMappingSerializer, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
| @ -10,12 +10,12 @@ from django.dispatch import receiver | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| 
 | 
 | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.providers.rac.api.endpoints import user_endpoint_cache_key | from authentik.enterprise.providers.rac.api.endpoints import user_endpoint_cache_key | ||||||
| from authentik.providers.rac.consumer_client import ( | from authentik.enterprise.providers.rac.consumer_client import ( | ||||||
|     RAC_CLIENT_GROUP_SESSION, |     RAC_CLIENT_GROUP_SESSION, | ||||||
|     RAC_CLIENT_GROUP_TOKEN, |     RAC_CLIENT_GROUP_TOKEN, | ||||||
| ) | ) | ||||||
| from authentik.providers.rac.models import ConnectionToken, Endpoint | from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @receiver(user_logged_out) | @receiver(user_logged_out) | ||||||
| @ -3,7 +3,7 @@ | |||||||
| {% load authentik_core %} | {% load authentik_core %} | ||||||
| 
 | 
 | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% versioned_script 'dist/rac/index-%v.js' %}" type="module"></script> | <script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script> | ||||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||||
| <link rel="icon" href="{{ tenant.branding_favicon_url }}"> | <link rel="icon" href="{{ tenant.branding_favicon_url }}"> | ||||||
| @ -1,9 +1,16 @@ | |||||||
| """Test RAC Provider""" | """Test RAC Provider""" | ||||||
| 
 | 
 | ||||||
|  | from datetime import timedelta | ||||||
|  | from time import mktime | ||||||
|  | from unittest.mock import MagicMock, patch | ||||||
|  | 
 | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  | from django.utils.timezone import now | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
| 
 | 
 | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||||
|  | from authentik.enterprise.license import LicenseKey | ||||||
|  | from authentik.enterprise.models import License | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -13,8 +20,21 @@ class TestAPI(APITestCase): | |||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         self.user = create_test_admin_user() |         self.user = create_test_admin_user() | ||||||
| 
 | 
 | ||||||
|  |     @patch( | ||||||
|  |         "authentik.enterprise.license.LicenseKey.validate", | ||||||
|  |         MagicMock( | ||||||
|  |             return_value=LicenseKey( | ||||||
|  |                 aud="", | ||||||
|  |                 exp=int(mktime((now() + timedelta(days=3000)).timetuple())), | ||||||
|  |                 name=generate_id(), | ||||||
|  |                 internal_users=100, | ||||||
|  |                 external_users=100, | ||||||
|  |             ) | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|     def test_create(self): |     def test_create(self): | ||||||
|         """Test creation of RAC Provider""" |         """Test creation of RAC Provider""" | ||||||
|  |         License.objects.create(key=generate_id()) | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:racprovider-list"), |             reverse("authentik_api:racprovider-list"), | ||||||
| @ -5,10 +5,10 @@ from rest_framework.test import APITestCase | |||||||
| 
 | 
 | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_admin_user | from authentik.core.tests.utils import create_test_admin_user | ||||||
|  | from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.policies.dummy.models import DummyPolicy | from authentik.policies.dummy.models import DummyPolicy | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
| from authentik.providers.rac.models import Endpoint, Protocols, RACProvider |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TestEndpointsAPI(APITestCase): | class TestEndpointsAPI(APITestCase): | ||||||
| @ -4,14 +4,14 @@ from django.test import TransactionTestCase | |||||||
| 
 | 
 | ||||||
| from authentik.core.models import Application, AuthenticatedSession | from authentik.core.models import Application, AuthenticatedSession | ||||||
| from authentik.core.tests.utils import create_test_admin_user | from authentik.core.tests.utils import create_test_admin_user | ||||||
| from authentik.lib.generators import generate_id | from authentik.enterprise.providers.rac.models import ( | ||||||
| from authentik.providers.rac.models import ( |  | ||||||
|     ConnectionToken, |     ConnectionToken, | ||||||
|     Endpoint, |     Endpoint, | ||||||
|     Protocols, |     Protocols, | ||||||
|     RACPropertyMapping, |     RACPropertyMapping, | ||||||
|     RACProvider, |     RACProvider, | ||||||
| ) | ) | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TestModels(TransactionTestCase): | class TestModels(TransactionTestCase): | ||||||
| @ -1,17 +1,23 @@ | |||||||
| """RAC Views tests""" | """RAC Views tests""" | ||||||
| 
 | 
 | ||||||
|  | from datetime import timedelta | ||||||
| from json import loads | from json import loads | ||||||
|  | from time import mktime | ||||||
|  | from unittest.mock import MagicMock, patch | ||||||
| 
 | 
 | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  | from django.utils.timezone import now | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
| 
 | 
 | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||||
|  | from authentik.enterprise.license import LicenseKey | ||||||
|  | from authentik.enterprise.models import License | ||||||
|  | from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.policies.denied import AccessDeniedResponse | from authentik.policies.denied import AccessDeniedResponse | ||||||
| from authentik.policies.dummy.models import DummyPolicy | from authentik.policies.dummy.models import DummyPolicy | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
| from authentik.providers.rac.models import Endpoint, Protocols, RACProvider |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TestRACViews(APITestCase): | class TestRACViews(APITestCase): | ||||||
| @ -33,8 +39,21 @@ class TestRACViews(APITestCase): | |||||||
|             provider=self.provider, |             provider=self.provider, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |     @patch( | ||||||
|  |         "authentik.enterprise.license.LicenseKey.validate", | ||||||
|  |         MagicMock( | ||||||
|  |             return_value=LicenseKey( | ||||||
|  |                 aud="", | ||||||
|  |                 exp=int(mktime((now() + timedelta(days=3000)).timetuple())), | ||||||
|  |                 name=generate_id(), | ||||||
|  |                 internal_users=100, | ||||||
|  |                 external_users=100, | ||||||
|  |             ) | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|     def test_no_policy(self): |     def test_no_policy(self): | ||||||
|         """Test request""" |         """Test request""" | ||||||
|  |         License.objects.create(key=generate_id()) | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse( |             reverse( | ||||||
| @ -51,6 +70,18 @@ class TestRACViews(APITestCase): | |||||||
|         final_response = self.client.get(next_url) |         final_response = self.client.get(next_url) | ||||||
|         self.assertEqual(final_response.status_code, 200) |         self.assertEqual(final_response.status_code, 200) | ||||||
| 
 | 
 | ||||||
|  |     @patch( | ||||||
|  |         "authentik.enterprise.license.LicenseKey.validate", | ||||||
|  |         MagicMock( | ||||||
|  |             return_value=LicenseKey( | ||||||
|  |                 aud="", | ||||||
|  |                 exp=int(mktime((now() + timedelta(days=3000)).timetuple())), | ||||||
|  |                 name=generate_id(), | ||||||
|  |                 internal_users=100, | ||||||
|  |                 external_users=100, | ||||||
|  |             ) | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|     def test_app_deny(self): |     def test_app_deny(self): | ||||||
|         """Test request (deny on app level)""" |         """Test request (deny on app level)""" | ||||||
|         PolicyBinding.objects.create( |         PolicyBinding.objects.create( | ||||||
| @ -58,6 +89,7 @@ class TestRACViews(APITestCase): | |||||||
|             policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), |             policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), | ||||||
|             order=0, |             order=0, | ||||||
|         ) |         ) | ||||||
|  |         License.objects.create(key=generate_id()) | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse( |             reverse( | ||||||
| @ -67,6 +99,18 @@ class TestRACViews(APITestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertIsInstance(response, AccessDeniedResponse) |         self.assertIsInstance(response, AccessDeniedResponse) | ||||||
| 
 | 
 | ||||||
|  |     @patch( | ||||||
|  |         "authentik.enterprise.license.LicenseKey.validate", | ||||||
|  |         MagicMock( | ||||||
|  |             return_value=LicenseKey( | ||||||
|  |                 aud="", | ||||||
|  |                 exp=int(mktime((now() + timedelta(days=3000)).timetuple())), | ||||||
|  |                 name=generate_id(), | ||||||
|  |                 internal_users=100, | ||||||
|  |                 external_users=100, | ||||||
|  |             ) | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|     def test_endpoint_deny(self): |     def test_endpoint_deny(self): | ||||||
|         """Test request (deny on endpoint level)""" |         """Test request (deny on endpoint level)""" | ||||||
|         PolicyBinding.objects.create( |         PolicyBinding.objects.create( | ||||||
| @ -74,6 +118,7 @@ class TestRACViews(APITestCase): | |||||||
|             policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), |             policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), | ||||||
|             order=0, |             order=0, | ||||||
|         ) |         ) | ||||||
|  |         License.objects.create(key=generate_id()) | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse( |             reverse( | ||||||
| @ -4,14 +4,14 @@ from channels.auth import AuthMiddleware | |||||||
| from channels.sessions import CookieMiddleware | from channels.sessions import CookieMiddleware | ||||||
| from django.urls import path | from django.urls import path | ||||||
| 
 | 
 | ||||||
|  | from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet | ||||||
|  | from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet | ||||||
|  | from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet | ||||||
|  | from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet | ||||||
|  | from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer | ||||||
|  | from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer | ||||||
|  | from authentik.enterprise.providers.rac.views import RACInterface, RACStartView | ||||||
| from authentik.outposts.channels import TokenOutpostMiddleware | from authentik.outposts.channels import TokenOutpostMiddleware | ||||||
| from authentik.providers.rac.api.connection_tokens import ConnectionTokenViewSet |  | ||||||
| from authentik.providers.rac.api.endpoints import EndpointViewSet |  | ||||||
| from authentik.providers.rac.api.property_mappings import RACPropertyMappingViewSet |  | ||||||
| from authentik.providers.rac.api.providers import RACProviderViewSet |  | ||||||
| from authentik.providers.rac.consumer_client import RACClientConsumer |  | ||||||
| from authentik.providers.rac.consumer_outpost import RACOutpostConsumer |  | ||||||
| from authentik.providers.rac.views import RACInterface, RACStartView |  | ||||||
| from authentik.root.asgi_middleware import SessionMiddleware | from authentik.root.asgi_middleware import SessionMiddleware | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
| 
 | 
 | ||||||
| @ -10,6 +10,8 @@ from django.utils.translation import gettext as _ | |||||||
| 
 | 
 | ||||||
| from authentik.core.models import Application, AuthenticatedSession | from authentik.core.models import Application, AuthenticatedSession | ||||||
| from authentik.core.views.interface import InterfaceView | from authentik.core.views.interface import InterfaceView | ||||||
|  | from authentik.enterprise.policy import EnterprisePolicyAccessView | ||||||
|  | from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.flows.challenge import RedirectChallenge | from authentik.flows.challenge import RedirectChallenge | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| @ -18,11 +20,9 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | |||||||
| from authentik.flows.stage import RedirectStage | from authentik.flows.stage import RedirectStage | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.views import PolicyAccessView |  | ||||||
| from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class RACStartView(PolicyAccessView): | class RACStartView(EnterprisePolicyAccessView): | ||||||
|     """Start a RAC connection by checking access and creating a connection token""" |     """Start a RAC connection by checking access and creating a connection token""" | ||||||
| 
 | 
 | ||||||
|     endpoint: Endpoint |     endpoint: Endpoint | ||||||
| @ -46,7 +46,7 @@ class RACStartView(PolicyAccessView): | |||||||
|             ) |             ) | ||||||
|         except FlowNonApplicableException: |         except FlowNonApplicableException: | ||||||
|             raise Http404 from None |             raise Http404 from None | ||||||
|         plan.append_stage( |         plan.insert_stage( | ||||||
|             in_memory_stage( |             in_memory_stage( | ||||||
|                 RACFinalStage, |                 RACFinalStage, | ||||||
|                 application=self.application, |                 application=self.application, | ||||||
| @ -16,6 +16,7 @@ TENANT_APPS = [ | |||||||
|     "authentik.enterprise.audit", |     "authentik.enterprise.audit", | ||||||
|     "authentik.enterprise.providers.google_workspace", |     "authentik.enterprise.providers.google_workspace", | ||||||
|     "authentik.enterprise.providers.microsoft_entra", |     "authentik.enterprise.providers.microsoft_entra", | ||||||
|  |     "authentik.enterprise.providers.rac", | ||||||
|     "authentik.enterprise.providers.ssf", |     "authentik.enterprise.providers.ssf", | ||||||
|     "authentik.enterprise.stages.authenticator_endpoint_gdtc", |     "authentik.enterprise.stages.authenticator_endpoint_gdtc", | ||||||
|     "authentik.enterprise.stages.source", |     "authentik.enterprise.stages.source", | ||||||
|  | |||||||
| @ -9,16 +9,13 @@ from django.utils.timezone import now | |||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.models import Source, User | from authentik.core.models import Source, User | ||||||
| from authentik.core.sources.flow_manager import ( | from authentik.core.sources.flow_manager import SESSION_KEY_OVERRIDE_FLOW_TOKEN | ||||||
|     SESSION_KEY_OVERRIDE_FLOW_TOKEN, |  | ||||||
|     SESSION_KEY_SOURCE_FLOW_STAGES, |  | ||||||
| ) |  | ||||||
| from authentik.core.types import UILoginButton | from authentik.core.types import UILoginButton | ||||||
| from authentik.enterprise.stages.source.models import SourceStage | from authentik.enterprise.stages.source.models import SourceStage | ||||||
| from authentik.flows.challenge import Challenge, ChallengeResponse | from authentik.flows.challenge import Challenge, ChallengeResponse | ||||||
| from authentik.flows.models import FlowToken, in_memory_stage | from authentik.flows.models import FlowToken | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED | from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED | ||||||
| from authentik.flows.stage import ChallengeStageView, StageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
|  |  | ||||||
| PLAN_CONTEXT_RESUME_TOKEN = "resume_token"  # nosec | PLAN_CONTEXT_RESUME_TOKEN = "resume_token"  # nosec | ||||||
| @ -52,7 +49,6 @@ class SourceStageView(ChallengeStageView): | |||||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: |     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|         resume_token = self.create_flow_token() |         resume_token = self.create_flow_token() | ||||||
|         self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token |         self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token | ||||||
|         self.request.session[SESSION_KEY_SOURCE_FLOW_STAGES] = [in_memory_stage(SourceStageFinal)] |  | ||||||
|         return self.login_button.challenge |         return self.login_button.challenge | ||||||
|  |  | ||||||
|     def create_flow_token(self) -> FlowToken: |     def create_flow_token(self) -> FlowToken: | ||||||
| @ -81,19 +77,3 @@ class SourceStageView(ChallengeStageView): | |||||||
|  |  | ||||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||||
|         return self.executor.stage_ok() |         return self.executor.stage_ok() | ||||||
|  |  | ||||||
|  |  | ||||||
| class SourceStageFinal(StageView): |  | ||||||
|     """Dynamic stage injected in the source flow manager. This is injected in the |  | ||||||
|     flow the source flow manager picks (authentication or enrollment), and will run at the end. |  | ||||||
|     This stage uses the override flow token to resume execution of the initial flow the |  | ||||||
|     source stage is bound to.""" |  | ||||||
|  |  | ||||||
|     def dispatch(self, *args, **kwargs): |  | ||||||
|         token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) |  | ||||||
|         self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) |  | ||||||
|         plan = token.plan |  | ||||||
|         plan.context[PLAN_CONTEXT_IS_RESTORED] = token |  | ||||||
|         response = plan.to_redirect(self.request, token.flow) |  | ||||||
|         token.delete() |  | ||||||
|         return response |  | ||||||
|  | |||||||
| @ -4,8 +4,7 @@ from django.urls import reverse | |||||||
|  |  | ||||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | from authentik.core.tests.utils import create_test_flow, create_test_user | ||||||
| from authentik.enterprise.stages.source.models import SourceStage | from authentik.enterprise.stages.source.models import SourceStage | ||||||
| from authentik.enterprise.stages.source.stage import SourceStageFinal | from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken | ||||||
| from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken, in_memory_stage |  | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan | from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan | ||||||
| from authentik.flows.tests import FlowTestCase | from authentik.flows.tests import FlowTestCase | ||||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
| @ -88,7 +87,6 @@ class TestSourceStage(FlowTestCase): | |||||||
|         self.assertIsNotNone(flow_token) |         self.assertIsNotNone(flow_token) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         plan: FlowPlan = session[SESSION_KEY_PLAN] |         plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||||
|         plan.insert_stage(in_memory_stage(SourceStageFinal), index=0) |  | ||||||
|         plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token |         plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
| @ -98,6 +96,4 @@ class TestSourceStage(FlowTestCase): | |||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertStageRedirects( |         self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) | ||||||
|             response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) |  | ||||||
|         ) |  | ||||||
|  | |||||||
| @ -76,10 +76,10 @@ class FlowPlan: | |||||||
|         self.bindings.append(binding) |         self.bindings.append(binding) | ||||||
|         self.markers.append(marker or StageMarker()) |         self.markers.append(marker or StageMarker()) | ||||||
|  |  | ||||||
|     def insert_stage(self, stage: Stage, marker: StageMarker | None = None, index=1): |     def insert_stage(self, stage: Stage, marker: StageMarker | None = None): | ||||||
|         """Insert stage into plan, as immediate next stage""" |         """Insert stage into plan, as immediate next stage""" | ||||||
|         self.bindings.insert(index, FlowStageBinding(stage=stage, order=0)) |         self.bindings.insert(1, FlowStageBinding(stage=stage, order=0)) | ||||||
|         self.markers.insert(index, marker or StageMarker()) |         self.markers.insert(1, marker or StageMarker()) | ||||||
|  |  | ||||||
|     def redirect(self, destination: str): |     def redirect(self, destination: str): | ||||||
|         """Insert a redirect stage as next stage""" |         """Insert a redirect stage as next stage""" | ||||||
|  | |||||||
| @ -282,14 +282,16 @@ class ConfigLoader: | |||||||
|  |  | ||||||
|     def get_optional_int(self, path: str, default=None) -> int | None: |     def get_optional_int(self, path: str, default=None) -> int | None: | ||||||
|         """Wrapper for get that converts value into int or None if set""" |         """Wrapper for get that converts value into int or None if set""" | ||||||
|         value = self.get(path, UNSET) |         value = self.get(path, default) | ||||||
|         if value is UNSET: |         if value is UNSET: | ||||||
|             return default |             return default | ||||||
|         try: |         try: | ||||||
|             return int(value) |             return int(value) | ||||||
|         except (ValueError, TypeError) as exc: |         except (ValueError, TypeError) as exc: | ||||||
|             if value is None or (isinstance(value, str) and value.lower() == "null"): |             if value is None or (isinstance(value, str) and value.lower() == "null"): | ||||||
|                 return None |                 return default | ||||||
|  |             if value is UNSET: | ||||||
|  |                 return default | ||||||
|             self.log("warning", "Failed to parse config as int", path=path, exc=str(exc)) |             self.log("warning", "Failed to parse config as int", path=path, exc=str(exc)) | ||||||
|             return default |             return default | ||||||
|  |  | ||||||
| @ -370,9 +372,9 @@ def django_db_config(config: ConfigLoader | None = None) -> dict: | |||||||
|                 "sslcert": config.get("postgresql.sslcert"), |                 "sslcert": config.get("postgresql.sslcert"), | ||||||
|                 "sslkey": config.get("postgresql.sslkey"), |                 "sslkey": config.get("postgresql.sslkey"), | ||||||
|             }, |             }, | ||||||
|             "CONN_MAX_AGE": config.get_optional_int("postgresql.conn_max_age", 0), |             "CONN_MAX_AGE": CONFIG.get_optional_int("postgresql.conn_max_age", 0), | ||||||
|             "CONN_HEALTH_CHECKS": config.get_bool("postgresql.conn_health_checks", False), |             "CONN_HEALTH_CHECKS": CONFIG.get_bool("postgresql.conn_health_checks", False), | ||||||
|             "DISABLE_SERVER_SIDE_CURSORS": config.get_bool( |             "DISABLE_SERVER_SIDE_CURSORS": CONFIG.get_bool( | ||||||
|                 "postgresql.disable_server_side_cursors", False |                 "postgresql.disable_server_side_cursors", False | ||||||
|             ), |             ), | ||||||
|             "TEST": { |             "TEST": { | ||||||
| @ -381,8 +383,8 @@ def django_db_config(config: ConfigLoader | None = None) -> dict: | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     conn_max_age = config.get_optional_int("postgresql.conn_max_age", UNSET) |     conn_max_age = CONFIG.get_optional_int("postgresql.conn_max_age", UNSET) | ||||||
|     disable_server_side_cursors = config.get_bool("postgresql.disable_server_side_cursors", UNSET) |     disable_server_side_cursors = CONFIG.get_bool("postgresql.disable_server_side_cursors", UNSET) | ||||||
|     if config.get_bool("postgresql.use_pgpool", False): |     if config.get_bool("postgresql.use_pgpool", False): | ||||||
|         db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True |         db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True | ||||||
|         if disable_server_side_cursors is not UNSET: |         if disable_server_side_cursors is not UNSET: | ||||||
|  | |||||||
| @ -64,8 +64,6 @@ debugger: false | |||||||
| log_level: info | log_level: info | ||||||
|  |  | ||||||
| session_storage: cache | session_storage: cache | ||||||
| sessions: |  | ||||||
|   unauthenticated_age: days=1 |  | ||||||
|  |  | ||||||
| error_reporting: | error_reporting: | ||||||
|   enabled: false |   enabled: false | ||||||
|  | |||||||
| @ -33,7 +33,6 @@ class SyncObjectSerializer(PassiveSerializer): | |||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
|     sync_object_id = CharField() |     sync_object_id = CharField() | ||||||
|     override_dry_run = BooleanField(default=False) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SyncObjectResultSerializer(PassiveSerializer): | class SyncObjectResultSerializer(PassiveSerializer): | ||||||
| @ -99,7 +98,6 @@ class OutgoingSyncProviderStatusMixin: | |||||||
|             page=1, |             page=1, | ||||||
|             provider_pk=provider.pk, |             provider_pk=provider.pk, | ||||||
|             pk=params.validated_data["sync_object_id"], |             pk=params.validated_data["sync_object_id"], | ||||||
|             override_dry_run=params.validated_data["override_dry_run"], |  | ||||||
|         ).get() |         ).get() | ||||||
|         return Response(SyncObjectResultSerializer(instance={"messages": res}).data) |         return Response(SyncObjectResultSerializer(instance={"messages": res}).data) | ||||||
|  |  | ||||||
|  | |||||||
| @ -28,14 +28,6 @@ class Direction(StrEnum): | |||||||
|     remove = "remove" |     remove = "remove" | ||||||
|  |  | ||||||
|  |  | ||||||
| SAFE_METHODS = [ |  | ||||||
|     "GET", |  | ||||||
|     "HEAD", |  | ||||||
|     "OPTIONS", |  | ||||||
|     "TRACE", |  | ||||||
| ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseOutgoingSyncClient[ | class BaseOutgoingSyncClient[ | ||||||
|     TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider" |     TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider" | ||||||
| ]: | ]: | ||||||
|  | |||||||
| @ -21,22 +21,6 @@ class BadRequestSyncException(BaseSyncException): | |||||||
|     """Exception when invalid data was sent to the remote system""" |     """Exception when invalid data was sent to the remote system""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class DryRunRejected(BaseSyncException): |  | ||||||
|     """When dry_run is enabled and a provider dropped a mutating request""" |  | ||||||
|  |  | ||||||
|     def __init__(self, url: str, method: str, body: dict): |  | ||||||
|         super().__init__() |  | ||||||
|         self.url = url |  | ||||||
|         self.method = method |  | ||||||
|         self.body = body |  | ||||||
|  |  | ||||||
|     def __repr__(self): |  | ||||||
|         return self.__str__() |  | ||||||
|  |  | ||||||
|     def __str__(self): |  | ||||||
|         return f"Dry-run rejected request: {self.method} {self.url}" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class StopSync(BaseSyncException): | class StopSync(BaseSyncException): | ||||||
|     """Exception raised when a configuration error should stop the sync process""" |     """Exception raised when a configuration error should stop the sync process""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,9 +1,8 @@ | |||||||
| from typing import Any, Self | from typing import Any, Self | ||||||
|  |  | ||||||
| import pglock | import pglock | ||||||
| from django.db import connection, models | from django.db import connection | ||||||
| from django.db.models import Model, QuerySet, TextChoices | from django.db.models import Model, QuerySet, TextChoices | ||||||
| from django.utils.translation import gettext_lazy as _ |  | ||||||
|  |  | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | ||||||
| @ -19,14 +18,6 @@ class OutgoingSyncDeleteAction(TextChoices): | |||||||
|  |  | ||||||
|  |  | ||||||
| class OutgoingSyncProvider(Model): | class OutgoingSyncProvider(Model): | ||||||
|     """Base abstract models for providers implementing outgoing sync""" |  | ||||||
|  |  | ||||||
|     dry_run = models.BooleanField( |  | ||||||
|         default=False, |  | ||||||
|         help_text=_( |  | ||||||
|             "When enabled, provider will not modify or create objects in the remote system." |  | ||||||
|         ), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         abstract = True |         abstract = True | ||||||
| @ -41,7 +32,7 @@ class OutgoingSyncProvider(Model): | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def sync_lock(self) -> pglock.advisory: |     def sync_lock(self) -> pglock.advisory: | ||||||
|         """Postgres lock for syncing to prevent multiple parallel syncs happening""" |         """Postgres lock for syncing SCIM to prevent multiple parallel syncs happening""" | ||||||
|         return pglock.advisory( |         return pglock.advisory( | ||||||
|             lock_id=f"goauthentik.io/{connection.schema_name}/providers/outgoing-sync/{str(self.pk)}", |             lock_id=f"goauthentik.io/{connection.schema_name}/providers/outgoing-sync/{str(self.pk)}", | ||||||
|             timeout=0, |             timeout=0, | ||||||
|  | |||||||
| @ -20,7 +20,6 @@ from authentik.lib.sync.outgoing import PAGE_SIZE, PAGE_TIMEOUT | |||||||
| from authentik.lib.sync.outgoing.base import Direction | from authentik.lib.sync.outgoing.base import Direction | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( | from authentik.lib.sync.outgoing.exceptions import ( | ||||||
|     BadRequestSyncException, |     BadRequestSyncException, | ||||||
|     DryRunRejected, |  | ||||||
|     StopSync, |     StopSync, | ||||||
|     TransientSyncException, |     TransientSyncException, | ||||||
| ) | ) | ||||||
| @ -106,9 +105,7 @@ class SyncTasks: | |||||||
|                 return |                 return | ||||||
|         task.set_status(TaskStatus.SUCCESSFUL, *messages) |         task.set_status(TaskStatus.SUCCESSFUL, *messages) | ||||||
|  |  | ||||||
|     def sync_objects( |     def sync_objects(self, object_type: str, page: int, provider_pk: int, **filter): | ||||||
|         self, object_type: str, page: int, provider_pk: int, override_dry_run=False, **filter |  | ||||||
|     ): |  | ||||||
|         _object_type = path_to_class(object_type) |         _object_type = path_to_class(object_type) | ||||||
|         self.logger = get_logger().bind( |         self.logger = get_logger().bind( | ||||||
|             provider_type=class_to_path(self._provider_model), |             provider_type=class_to_path(self._provider_model), | ||||||
| @ -119,10 +116,6 @@ class SyncTasks: | |||||||
|         provider = self._provider_model.objects.filter(pk=provider_pk).first() |         provider = self._provider_model.objects.filter(pk=provider_pk).first() | ||||||
|         if not provider: |         if not provider: | ||||||
|             return messages |             return messages | ||||||
|         # Override dry run mode if requested, however don't save the provider |  | ||||||
|         # so that scheduled sync tasks still run in dry_run mode |  | ||||||
|         if override_dry_run: |  | ||||||
|             provider.dry_run = False |  | ||||||
|         try: |         try: | ||||||
|             client = provider.client_for_model(_object_type) |             client = provider.client_for_model(_object_type) | ||||||
|         except TransientSyncException: |         except TransientSyncException: | ||||||
| @ -139,22 +132,6 @@ class SyncTasks: | |||||||
|             except SkipObjectException: |             except SkipObjectException: | ||||||
|                 self.logger.debug("skipping object due to SkipObject", obj=obj) |                 self.logger.debug("skipping object due to SkipObject", obj=obj) | ||||||
|                 continue |                 continue | ||||||
|             except DryRunRejected as exc: |  | ||||||
|                 messages.append( |  | ||||||
|                     asdict( |  | ||||||
|                         LogEvent( |  | ||||||
|                             _("Dropping mutating request due to dry run"), |  | ||||||
|                             log_level="info", |  | ||||||
|                             logger=f"{provider._meta.verbose_name}@{object_type}", |  | ||||||
|                             attributes={ |  | ||||||
|                                 "obj": sanitize_item(obj), |  | ||||||
|                                 "method": exc.method, |  | ||||||
|                                 "url": exc.url, |  | ||||||
|                                 "body": exc.body, |  | ||||||
|                             }, |  | ||||||
|                         ) |  | ||||||
|                     ) |  | ||||||
|                 ) |  | ||||||
|             except BadRequestSyncException as exc: |             except BadRequestSyncException as exc: | ||||||
|                 self.logger.warning("failed to sync object", exc=exc, obj=obj) |                 self.logger.warning("failed to sync object", exc=exc, obj=obj) | ||||||
|                 messages.append( |                 messages.append( | ||||||
| @ -254,10 +231,8 @@ class SyncTasks: | |||||||
|                 raise Retry() from exc |                 raise Retry() from exc | ||||||
|             except SkipObjectException: |             except SkipObjectException: | ||||||
|                 continue |                 continue | ||||||
|             except DryRunRejected as exc: |  | ||||||
|                 self.logger.info("Rejected dry-run event", exc=exc) |  | ||||||
|             except StopSync as exc: |             except StopSync as exc: | ||||||
|                 self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk) |                 self.logger.warning(exc, provider_pk=provider.pk) | ||||||
|  |  | ||||||
|     def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]): |     def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]): | ||||||
|         self.logger = get_logger().bind( |         self.logger = get_logger().bind( | ||||||
| @ -288,7 +263,5 @@ class SyncTasks: | |||||||
|                 raise Retry() from exc |                 raise Retry() from exc | ||||||
|             except SkipObjectException: |             except SkipObjectException: | ||||||
|                 continue |                 continue | ||||||
|             except DryRunRejected as exc: |  | ||||||
|                 self.logger.info("Rejected dry-run event", exc=exc) |  | ||||||
|             except StopSync as exc: |             except StopSync as exc: | ||||||
|                 self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk) |                 self.logger.warning(exc, provider_pk=provider.pk) | ||||||
|  | |||||||
| @ -158,18 +158,6 @@ class TestConfig(TestCase): | |||||||
|             test_obj = Test() |             test_obj = Test() | ||||||
|             dumps(test_obj, indent=4, cls=AttrEncoder) |             dumps(test_obj, indent=4, cls=AttrEncoder) | ||||||
|  |  | ||||||
|     def test_get_optional_int(self): |  | ||||||
|         config = ConfigLoader() |  | ||||||
|         self.assertEqual(config.get_optional_int("foo", 21), 21) |  | ||||||
|         self.assertEqual(config.get_optional_int("foo"), None) |  | ||||||
|         config.set("foo", "21") |  | ||||||
|         self.assertEqual(config.get_optional_int("foo"), 21) |  | ||||||
|         self.assertEqual(config.get_optional_int("foo", 0), 21) |  | ||||||
|         self.assertEqual(config.get_optional_int("foo", "null"), 21) |  | ||||||
|         config.set("foo", "null") |  | ||||||
|         self.assertEqual(config.get_optional_int("foo"), None) |  | ||||||
|         self.assertEqual(config.get_optional_int("foo", 21), None) |  | ||||||
|  |  | ||||||
|     @mock.patch.dict(environ, check_deprecations_env_vars) |     @mock.patch.dict(environ, check_deprecations_env_vars) | ||||||
|     def test_check_deprecations(self): |     def test_check_deprecations(self): | ||||||
|         """Test config key re-write for deprecated env vars""" |         """Test config key re-write for deprecated env vars""" | ||||||
| @ -233,16 +221,6 @@ class TestConfig(TestCase): | |||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_db_conn_max_age(self): |  | ||||||
|         """Test DB conn_max_age Config""" |  | ||||||
|         config = ConfigLoader() |  | ||||||
|         config.set("postgresql.conn_max_age", "null") |  | ||||||
|         conf = django_db_config(config) |  | ||||||
|         self.assertEqual( |  | ||||||
|             conf["default"]["CONN_MAX_AGE"], |  | ||||||
|             None, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_db_read_replicas(self): |     def test_db_read_replicas(self): | ||||||
|         """Test read replicas""" |         """Test read replicas""" | ||||||
|         config = ConfigLoader() |         config = ConfigLoader() | ||||||
|  | |||||||
| @ -1,54 +0,0 @@ | |||||||
| """Email utility functions""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def mask_email(email: str | None) -> str | None: |  | ||||||
|     """Mask email address for privacy |  | ||||||
|  |  | ||||||
|     Args: |  | ||||||
|         email: Email address to mask |  | ||||||
|     Returns: |  | ||||||
|         Masked email address or None if input is None |  | ||||||
|     Example: |  | ||||||
|         mask_email("myname@company.org") |  | ||||||
|         'm*****@c******.org' |  | ||||||
|     """ |  | ||||||
|     if not email: |  | ||||||
|         return None |  | ||||||
|  |  | ||||||
|     # Basic email format validation |  | ||||||
|     if email.count("@") != 1: |  | ||||||
|         raise ValueError("Invalid email format: Must contain exactly one '@' symbol") |  | ||||||
|  |  | ||||||
|     local, domain = email.split("@") |  | ||||||
|     if not local or not domain: |  | ||||||
|         raise ValueError("Invalid email format: Local and domain parts cannot be empty") |  | ||||||
|  |  | ||||||
|     domain_parts = domain.split(".") |  | ||||||
|     if len(domain_parts) < 2:  # noqa: PLR2004 |  | ||||||
|         raise ValueError("Invalid email format: Domain must contain at least one dot") |  | ||||||
|  |  | ||||||
|     limit = 2 |  | ||||||
|  |  | ||||||
|     # Mask local part (keep first char) |  | ||||||
|     if len(local) <= limit: |  | ||||||
|         masked_local = "*" * len(local) |  | ||||||
|     else: |  | ||||||
|         masked_local = local[0] + "*" * (len(local) - 1) |  | ||||||
|  |  | ||||||
|     # Mask each domain part except the last one (TLD) |  | ||||||
|     masked_domain_parts = [] |  | ||||||
|     for _i, part in enumerate(domain_parts[:-1]):  # Process all parts except TLD |  | ||||||
|         if not part:  # Check for empty parts (consecutive dots) |  | ||||||
|             raise ValueError("Invalid email format: Domain parts cannot be empty") |  | ||||||
|         if len(part) <= limit: |  | ||||||
|             masked_part = "*" * len(part) |  | ||||||
|         else: |  | ||||||
|             masked_part = part[0] + "*" * (len(part) - 1) |  | ||||||
|         masked_domain_parts.append(masked_part) |  | ||||||
|  |  | ||||||
|     # Add TLD unchanged |  | ||||||
|     if not domain_parts[-1]:  # Check if TLD is empty |  | ||||||
|         raise ValueError("Invalid email format: TLD cannot be empty") |  | ||||||
|     masked_domain_parts.append(domain_parts[-1]) |  | ||||||
|  |  | ||||||
|     return f"{masked_local}@{'.'.join(masked_domain_parts)}" |  | ||||||
| @ -19,6 +19,7 @@ from authentik.core.api.used_by import UsedByMixin | |||||||
| from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer | from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
| from authentik.enterprise.license import LicenseKey | from authentik.enterprise.license import LicenseKey | ||||||
|  | from authentik.enterprise.providers.rac.models import RACProvider | ||||||
| from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator | from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator | ||||||
| from authentik.outposts.api.service_connections import ServiceConnectionSerializer | from authentik.outposts.api.service_connections import ServiceConnectionSerializer | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME | from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME | ||||||
| @ -30,7 +31,6 @@ from authentik.outposts.models import ( | |||||||
| ) | ) | ||||||
| from authentik.providers.ldap.models import LDAPProvider | from authentik.providers.ldap.models import LDAPProvider | ||||||
| from authentik.providers.proxy.models import ProxyProvider | from authentik.providers.proxy.models import ProxyProvider | ||||||
| from authentik.providers.rac.models import RACProvider |  | ||||||
| from authentik.providers.radius.models import RadiusProvider | from authentik.providers.radius.models import RadiusProvider | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -18,6 +18,8 @@ from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| from yaml import safe_load | from yaml import safe_load | ||||||
|  |  | ||||||
|  | from authentik.enterprise.providers.rac.controllers.docker import RACDockerController | ||||||
|  | from authentik.enterprise.providers.rac.controllers.kubernetes import RACKubernetesController | ||||||
| from authentik.events.models import TaskStatus | from authentik.events.models import TaskStatus | ||||||
| from authentik.events.system_tasks import SystemTask, prefill_task | from authentik.events.system_tasks import SystemTask, prefill_task | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| @ -39,8 +41,6 @@ from authentik.providers.ldap.controllers.docker import LDAPDockerController | |||||||
| from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesController | from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesController | ||||||
| from authentik.providers.proxy.controllers.docker import ProxyDockerController | from authentik.providers.proxy.controllers.docker import ProxyDockerController | ||||||
| from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController | from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController | ||||||
| from authentik.providers.rac.controllers.docker import RACDockerController |  | ||||||
| from authentik.providers.rac.controllers.kubernetes import RACKubernetesController |  | ||||||
| from authentik.providers.radius.controllers.docker import RadiusDockerController | from authentik.providers.radius.controllers.docker import RadiusDockerController | ||||||
| from authentik.providers.radius.controllers.kubernetes import RadiusKubernetesController | from authentik.providers.radius.controllers.kubernetes import RadiusKubernetesController | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  | |||||||
| @ -1,11 +1,26 @@ | |||||||
| """Expression Policy API""" | """Expression Policy API""" | ||||||
|  |  | ||||||
|  | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
|  | from guardian.shortcuts import get_objects_for_user | ||||||
|  | from rest_framework.decorators import action | ||||||
|  | from rest_framework.fields import CharField | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.events.logs import LogEventSerializer, capture_logs | ||||||
|  | from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer | ||||||
| from authentik.policies.api.policies import PolicySerializer | from authentik.policies.api.policies import PolicySerializer | ||||||
| from authentik.policies.expression.evaluator import PolicyEvaluator | from authentik.policies.expression.evaluator import PolicyEvaluator | ||||||
| from authentik.policies.expression.models import ExpressionPolicy | from authentik.policies.expression.models import ExpressionPolicy | ||||||
|  | from authentik.policies.models import PolicyBinding | ||||||
|  | from authentik.policies.process import PolicyProcess | ||||||
|  | from authentik.policies.types import PolicyRequest | ||||||
|  | from authentik.rbac.decorators import permission_required | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class ExpressionPolicySerializer(PolicySerializer): | class ExpressionPolicySerializer(PolicySerializer): | ||||||
| @ -30,3 +45,50 @@ class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet): | |||||||
|     filterset_fields = "__all__" |     filterset_fields = "__all__" | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|     search_fields = ["name"] |     search_fields = ["name"] | ||||||
|  |  | ||||||
|  |     class ExpressionPolicyTestSerializer(PolicyTestSerializer): | ||||||
|  |         """Expression policy test serializer""" | ||||||
|  |  | ||||||
|  |         expression = CharField() | ||||||
|  |  | ||||||
|  |     @permission_required("authentik_policies.view_policy") | ||||||
|  |     @extend_schema( | ||||||
|  |         request=ExpressionPolicyTestSerializer(), | ||||||
|  |         responses={ | ||||||
|  |             200: PolicyTestResultSerializer(), | ||||||
|  |             400: OpenApiResponse(description="Invalid parameters"), | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) | ||||||
|  |     def test(self, request: Request, pk: str) -> Response: | ||||||
|  |         """Test policy""" | ||||||
|  |         policy = self.get_object() | ||||||
|  |         test_params = self.ExpressionPolicyTestSerializer(data=request.data) | ||||||
|  |         if not test_params.is_valid(): | ||||||
|  |             return Response(test_params.errors, status=400) | ||||||
|  |  | ||||||
|  |         # User permission check, only allow policy testing for users that are readable | ||||||
|  |         users = get_objects_for_user(request.user, "authentik_core.view_user").filter( | ||||||
|  |             pk=test_params.validated_data["user"].pk | ||||||
|  |         ) | ||||||
|  |         if not users.exists(): | ||||||
|  |             return Response(status=400) | ||||||
|  |  | ||||||
|  |         policy.expression = test_params.validated_data["expression"] | ||||||
|  |  | ||||||
|  |         p_request = PolicyRequest(users.first()) | ||||||
|  |         p_request.debug = True | ||||||
|  |         p_request.set_http_request(self.request) | ||||||
|  |         p_request.context = test_params.validated_data.get("context", {}) | ||||||
|  |  | ||||||
|  |         proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None) | ||||||
|  |         with capture_logs() as logs: | ||||||
|  |             result = proc.execute() | ||||||
|  |         log_messages = [] | ||||||
|  |         for log in logs: | ||||||
|  |             if log.attributes.get("process", "") == "PolicyProcess": | ||||||
|  |                 continue | ||||||
|  |             log_messages.append(LogEventSerializer(log).data) | ||||||
|  |         result.log_messages = log_messages | ||||||
|  |         response = PolicyTestResultSerializer(result) | ||||||
|  |         return Response(response.data) | ||||||
|  | |||||||
| @ -42,12 +42,6 @@ class GeoIPPolicySerializer(CountryFieldMixin, PolicySerializer): | |||||||
|             "asns", |             "asns", | ||||||
|             "countries", |             "countries", | ||||||
|             "countries_obj", |             "countries_obj", | ||||||
|             "check_history_distance", |  | ||||||
|             "history_max_distance_km", |  | ||||||
|             "distance_tolerance_km", |  | ||||||
|             "history_login_count", |  | ||||||
|             "check_impossible_travel", |  | ||||||
|             "impossible_tolerance_km", |  | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,43 +0,0 @@ | |||||||
| # Generated by Django 5.0.10 on 2025-01-02 20:40 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_policies_geoip", "0001_initial"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="geoippolicy", |  | ||||||
|             name="check_history_distance", |  | ||||||
|             field=models.BooleanField(default=False), |  | ||||||
|         ), |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="geoippolicy", |  | ||||||
|             name="check_impossible_travel", |  | ||||||
|             field=models.BooleanField(default=False), |  | ||||||
|         ), |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="geoippolicy", |  | ||||||
|             name="distance_tolerance_km", |  | ||||||
|             field=models.PositiveIntegerField(default=50), |  | ||||||
|         ), |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="geoippolicy", |  | ||||||
|             name="history_login_count", |  | ||||||
|             field=models.PositiveIntegerField(default=5), |  | ||||||
|         ), |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="geoippolicy", |  | ||||||
|             name="history_max_distance_km", |  | ||||||
|             field=models.PositiveBigIntegerField(default=100), |  | ||||||
|         ), |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="geoippolicy", |  | ||||||
|             name="impossible_tolerance_km", |  | ||||||
|             field=models.PositiveIntegerField(default=100), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -4,21 +4,15 @@ from itertools import chain | |||||||
|  |  | ||||||
| from django.contrib.postgres.fields import ArrayField | from django.contrib.postgres.fields import ArrayField | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.utils.timezone import now |  | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django_countries.fields import CountryField | from django_countries.fields import CountryField | ||||||
| from geopy import distance |  | ||||||
| from rest_framework.serializers import BaseSerializer | from rest_framework.serializers import BaseSerializer | ||||||
|  |  | ||||||
| from authentik.events.context_processors.geoip import GeoIPDict |  | ||||||
| from authentik.events.models import Event, EventAction |  | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| from authentik.policies.geoip.exceptions import GeoIPNotFoundException | from authentik.policies.geoip.exceptions import GeoIPNotFoundException | ||||||
| from authentik.policies.models import Policy | from authentik.policies.models import Policy | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
|  |  | ||||||
| MAX_DISTANCE_HOUR_KM = 1000 |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GeoIPPolicy(Policy): | class GeoIPPolicy(Policy): | ||||||
|     """Ensure the user satisfies requirements of geography or network topology, based on IP |     """Ensure the user satisfies requirements of geography or network topology, based on IP | ||||||
| @ -27,15 +21,6 @@ class GeoIPPolicy(Policy): | |||||||
|     asns = ArrayField(models.IntegerField(), blank=True, default=list) |     asns = ArrayField(models.IntegerField(), blank=True, default=list) | ||||||
|     countries = CountryField(multiple=True, blank=True) |     countries = CountryField(multiple=True, blank=True) | ||||||
|  |  | ||||||
|     distance_tolerance_km = models.PositiveIntegerField(default=50) |  | ||||||
|  |  | ||||||
|     check_history_distance = models.BooleanField(default=False) |  | ||||||
|     history_max_distance_km = models.PositiveBigIntegerField(default=100) |  | ||||||
|     history_login_count = models.PositiveIntegerField(default=5) |  | ||||||
|  |  | ||||||
|     check_impossible_travel = models.BooleanField(default=False) |  | ||||||
|     impossible_tolerance_km = models.PositiveIntegerField(default=100) |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> type[BaseSerializer]: |     def serializer(self) -> type[BaseSerializer]: | ||||||
|         from authentik.policies.geoip.api import GeoIPPolicySerializer |         from authentik.policies.geoip.api import GeoIPPolicySerializer | ||||||
| @ -52,27 +37,21 @@ class GeoIPPolicy(Policy): | |||||||
|         - the client IP is advertised by an autonomous system with ASN in the `asns` |         - the client IP is advertised by an autonomous system with ASN in the `asns` | ||||||
|         - the client IP is geolocated in a country of `countries` |         - the client IP is geolocated in a country of `countries` | ||||||
|         """ |         """ | ||||||
|         static_results: list[PolicyResult] = [] |         results: list[PolicyResult] = [] | ||||||
|         dynamic_results: list[PolicyResult] = [] |  | ||||||
|  |  | ||||||
|         if self.asns: |         if self.asns: | ||||||
|             static_results.append(self.passes_asn(request)) |             results.append(self.passes_asn(request)) | ||||||
|         if self.countries: |         if self.countries: | ||||||
|             static_results.append(self.passes_country(request)) |             results.append(self.passes_country(request)) | ||||||
|  |  | ||||||
|         if self.check_history_distance or self.check_impossible_travel: |         if not results: | ||||||
|             dynamic_results.append(self.passes_distance(request)) |  | ||||||
|  |  | ||||||
|         if not static_results and not dynamic_results: |  | ||||||
|             return PolicyResult(True) |             return PolicyResult(True) | ||||||
|  |  | ||||||
|         passing = any(r.passing for r in static_results) and all(r.passing for r in dynamic_results) |         passing = any(r.passing for r in results) | ||||||
|         messages = chain( |         messages = chain(*[r.messages for r in results]) | ||||||
|             *[r.messages for r in static_results], *[r.messages for r in dynamic_results] |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         result = PolicyResult(passing, *messages) |         result = PolicyResult(passing, *messages) | ||||||
|         result.source_results = list(chain(static_results, dynamic_results)) |         result.source_results = results | ||||||
|  |  | ||||||
|         return result |         return result | ||||||
|  |  | ||||||
| @ -94,7 +73,7 @@ class GeoIPPolicy(Policy): | |||||||
|  |  | ||||||
|     def passes_country(self, request: PolicyRequest) -> PolicyResult: |     def passes_country(self, request: PolicyRequest) -> PolicyResult: | ||||||
|         # This is not a single get chain because `request.context` can contain `{ "geoip": None }`. |         # This is not a single get chain because `request.context` can contain `{ "geoip": None }`. | ||||||
|         geoip_data: GeoIPDict | None = request.context.get("geoip") |         geoip_data = request.context.get("geoip") | ||||||
|         country = geoip_data.get("country") if geoip_data else None |         country = geoip_data.get("country") if geoip_data else None | ||||||
|  |  | ||||||
|         if not country: |         if not country: | ||||||
| @ -108,42 +87,6 @@ class GeoIPPolicy(Policy): | |||||||
|  |  | ||||||
|         return PolicyResult(True) |         return PolicyResult(True) | ||||||
|  |  | ||||||
|     def passes_distance(self, request: PolicyRequest) -> PolicyResult: |  | ||||||
|         """Check if current policy execution is out of distance range compared |  | ||||||
|         to previous authentication requests""" |  | ||||||
|         # Get previous login event and GeoIP data |  | ||||||
|         previous_logins = Event.objects.filter( |  | ||||||
|             action=EventAction.LOGIN, user__pk=request.user.pk, context__geo__isnull=False |  | ||||||
|         ).order_by("-created")[: self.history_login_count] |  | ||||||
|         _now = now() |  | ||||||
|         geoip_data: GeoIPDict | None = request.context.get("geoip") |  | ||||||
|         if not geoip_data: |  | ||||||
|             return PolicyResult(False) |  | ||||||
|         for previous_login in previous_logins: |  | ||||||
|             previous_login_geoip: GeoIPDict = previous_login.context["geo"] |  | ||||||
|  |  | ||||||
|             # Figure out distance |  | ||||||
|             dist = distance.geodesic( |  | ||||||
|                 (previous_login_geoip["lat"], previous_login_geoip["long"]), |  | ||||||
|                 (geoip_data["lat"], geoip_data["long"]), |  | ||||||
|             ) |  | ||||||
|             if self.check_history_distance and dist.km >= ( |  | ||||||
|                 self.history_max_distance_km + self.distance_tolerance_km |  | ||||||
|             ): |  | ||||||
|                 return PolicyResult( |  | ||||||
|                     False, _("Distance from previous authentication is larger than threshold.") |  | ||||||
|                 ) |  | ||||||
|             # Check if distance between `previous_login` and now is more |  | ||||||
|             # than max distance per hour times the amount of hours since the previous login |  | ||||||
|             # (round down to the lowest closest time of hours) |  | ||||||
|             # clamped to be at least 1 hour |  | ||||||
|             rel_time_hours = max(int((_now - previous_login.created).total_seconds() / 3600), 1) |  | ||||||
|             if self.check_impossible_travel and dist.km >= ( |  | ||||||
|                 (MAX_DISTANCE_HOUR_KM * rel_time_hours) + self.distance_tolerance_km |  | ||||||
|             ): |  | ||||||
|                 return PolicyResult(False, _("Distance is further than possible.")) |  | ||||||
|         return PolicyResult(True) |  | ||||||
|  |  | ||||||
|     class Meta(Policy.PolicyMeta): |     class Meta(Policy.PolicyMeta): | ||||||
|         verbose_name = _("GeoIP Policy") |         verbose_name = _("GeoIP Policy") | ||||||
|         verbose_name_plural = _("GeoIP Policies") |         verbose_name_plural = _("GeoIP Policies") | ||||||
|  | |||||||
| @ -1,10 +1,8 @@ | |||||||
| """geoip policy tests""" | """geoip policy tests""" | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.tests.utils import create_test_user |  | ||||||
| from authentik.events.models import Event, EventAction |  | ||||||
| from authentik.events.utils import get_user |  | ||||||
| from authentik.policies.engine import PolicyRequest, PolicyResult | from authentik.policies.engine import PolicyRequest, PolicyResult | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| from authentik.policies.geoip.exceptions import GeoIPNotFoundException | from authentik.policies.geoip.exceptions import GeoIPNotFoundException | ||||||
| @ -16,8 +14,8 @@ class TestGeoIPPolicy(TestCase): | |||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         self.user = create_test_user() |  | ||||||
|         self.request = PolicyRequest(self.user) |         self.request = PolicyRequest(get_anonymous_user()) | ||||||
|  |  | ||||||
|         self.context_disabled_geoip = {} |         self.context_disabled_geoip = {} | ||||||
|         self.context_unknown_ip = {"asn": None, "geoip": None} |         self.context_unknown_ip = {"asn": None, "geoip": None} | ||||||
| @ -128,70 +126,3 @@ class TestGeoIPPolicy(TestCase): | |||||||
|         result: PolicyResult = policy.passes(self.request) |         result: PolicyResult = policy.passes(self.request) | ||||||
|  |  | ||||||
|         self.assertTrue(result.passing) |         self.assertTrue(result.passing) | ||||||
|  |  | ||||||
|     def test_history(self): |  | ||||||
|         """Test history checks""" |  | ||||||
|         Event.objects.create( |  | ||||||
|             action=EventAction.LOGIN, |  | ||||||
|             user=get_user(self.user), |  | ||||||
|             context={ |  | ||||||
|                 # Random location in Canada |  | ||||||
|                 "geo": {"lat": 55.868351, "long": -104.441011}, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         # Random location in Poland |  | ||||||
|         self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679} |  | ||||||
|  |  | ||||||
|         policy = GeoIPPolicy.objects.create(check_history_distance=True) |  | ||||||
|  |  | ||||||
|         result: PolicyResult = policy.passes(self.request) |  | ||||||
|         self.assertFalse(result.passing) |  | ||||||
|  |  | ||||||
|     def test_history_no_data(self): |  | ||||||
|         """Test history checks (with no geoip data in context)""" |  | ||||||
|         Event.objects.create( |  | ||||||
|             action=EventAction.LOGIN, |  | ||||||
|             user=get_user(self.user), |  | ||||||
|             context={ |  | ||||||
|                 # Random location in Canada |  | ||||||
|                 "geo": {"lat": 55.868351, "long": -104.441011}, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         policy = GeoIPPolicy.objects.create(check_history_distance=True) |  | ||||||
|  |  | ||||||
|         result: PolicyResult = policy.passes(self.request) |  | ||||||
|         self.assertFalse(result.passing) |  | ||||||
|  |  | ||||||
|     def test_history_impossible_travel(self): |  | ||||||
|         """Test history checks""" |  | ||||||
|         Event.objects.create( |  | ||||||
|             action=EventAction.LOGIN, |  | ||||||
|             user=get_user(self.user), |  | ||||||
|             context={ |  | ||||||
|                 # Random location in Canada |  | ||||||
|                 "geo": {"lat": 55.868351, "long": -104.441011}, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         # Random location in Poland |  | ||||||
|         self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679} |  | ||||||
|  |  | ||||||
|         policy = GeoIPPolicy.objects.create(check_impossible_travel=True) |  | ||||||
|  |  | ||||||
|         result: PolicyResult = policy.passes(self.request) |  | ||||||
|         self.assertFalse(result.passing) |  | ||||||
|  |  | ||||||
|     def test_history_no_geoip(self): |  | ||||||
|         """Test history checks (previous login with no geoip data)""" |  | ||||||
|         Event.objects.create( |  | ||||||
|             action=EventAction.LOGIN, |  | ||||||
|             user=get_user(self.user), |  | ||||||
|             context={}, |  | ||||||
|         ) |  | ||||||
|         # Random location in Poland |  | ||||||
|         self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679} |  | ||||||
|  |  | ||||||
|         policy = GeoIPPolicy.objects.create(check_history_distance=True) |  | ||||||
|  |  | ||||||
|         result: PolicyResult = policy.passes(self.request) |  | ||||||
|         self.assertFalse(result.passing) |  | ||||||
|  | |||||||
| @ -148,10 +148,10 @@ class PasswordPolicy(Policy): | |||||||
|             user_inputs.append(request.user.email) |             user_inputs.append(request.user.email) | ||||||
|         if request.http_request: |         if request.http_request: | ||||||
|             user_inputs.append(request.http_request.brand.branding_title) |             user_inputs.append(request.http_request.brand.branding_title) | ||||||
|         # Only calculate result for the first 72 characters, as with over 100 char |         # Only calculate result for the first 100 characters, as with over 100 char | ||||||
|         # long passwords we can be reasonably sure that they'll surpass the score anyways |         # long passwords we can be reasonably sure that they'll surpass the score anyways | ||||||
|         # See https://github.com/dropbox/zxcvbn#runtime-latency |         # See https://github.com/dropbox/zxcvbn#runtime-latency | ||||||
|         results = zxcvbn(password[:72], user_inputs) |         results = zxcvbn(password[:100], user_inputs) | ||||||
|         LOGGER.debug("password failed", check="zxcvbn", score=results["score"]) |         LOGGER.debug("password failed", check="zxcvbn", score=results["score"]) | ||||||
|         result = PolicyResult(results["score"] > self.zxcvbn_score_threshold) |         result = PolicyResult(results["score"] > self.zxcvbn_score_threshold) | ||||||
|         if not result.passing: |         if not result.passing: | ||||||
|  | |||||||
| @ -9,12 +9,7 @@ from hashlib import sha256 | |||||||
| from typing import Any | from typing import Any | ||||||
| from urllib.parse import urlparse, urlunparse | from urllib.parse import urlparse, urlunparse | ||||||
|  |  | ||||||
| from cryptography.hazmat.primitives.asymmetric.ec import ( | from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey | ||||||
|     SECP256R1, |  | ||||||
|     SECP384R1, |  | ||||||
|     SECP521R1, |  | ||||||
|     EllipticCurvePrivateKey, |  | ||||||
| ) |  | ||||||
| from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | ||||||
| from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes | from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes | ||||||
| from dacite import Config | from dacite import Config | ||||||
| @ -119,22 +114,6 @@ class JWTAlgorithms(models.TextChoices): | |||||||
|     HS256 = "HS256", _("HS256 (Symmetric Encryption)") |     HS256 = "HS256", _("HS256 (Symmetric Encryption)") | ||||||
|     RS256 = "RS256", _("RS256 (Asymmetric Encryption)") |     RS256 = "RS256", _("RS256 (Asymmetric Encryption)") | ||||||
|     ES256 = "ES256", _("ES256 (Asymmetric Encryption)") |     ES256 = "ES256", _("ES256 (Asymmetric Encryption)") | ||||||
|     ES384 = "ES384", _("ES384 (Asymmetric Encryption)") |  | ||||||
|     ES512 = "ES512", _("ES512 (Asymmetric Encryption)") |  | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def from_private_key(cls, private_key: PrivateKeyTypes | None) -> str: |  | ||||||
|         if isinstance(private_key, RSAPrivateKey): |  | ||||||
|             return cls.RS256 |  | ||||||
|         if isinstance(private_key, EllipticCurvePrivateKey): |  | ||||||
|             curve = private_key.curve |  | ||||||
|             if isinstance(curve, SECP256R1): |  | ||||||
|                 return cls.ES256 |  | ||||||
|             if isinstance(curve, SECP384R1): |  | ||||||
|                 return cls.ES384 |  | ||||||
|             if isinstance(curve, SECP521R1): |  | ||||||
|                 return cls.ES512 |  | ||||||
|         raise ValueError(f"Invalid private key type: {type(private_key)}") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ScopeMapping(PropertyMapping): | class ScopeMapping(PropertyMapping): | ||||||
| @ -284,7 +263,11 @@ class OAuth2Provider(WebfingerProvider, Provider): | |||||||
|             return self.client_secret, JWTAlgorithms.HS256 |             return self.client_secret, JWTAlgorithms.HS256 | ||||||
|         key: CertificateKeyPair = self.signing_key |         key: CertificateKeyPair = self.signing_key | ||||||
|         private_key = key.private_key |         private_key = key.private_key | ||||||
|         return private_key, JWTAlgorithms.from_private_key(private_key) |         if isinstance(private_key, RSAPrivateKey): | ||||||
|  |             return private_key, JWTAlgorithms.RS256 | ||||||
|  |         if isinstance(private_key, EllipticCurvePrivateKey): | ||||||
|  |             return private_key, JWTAlgorithms.ES256 | ||||||
|  |         raise ValueError(f"Invalid private key type: {type(private_key)}") | ||||||
|  |  | ||||||
|     def get_issuer(self, request: HttpRequest) -> str | None: |     def get_issuer(self, request: HttpRequest) -> str | None: | ||||||
|         """Get issuer, based on request""" |         """Get issuer, based on request""" | ||||||
|  | |||||||
| @ -71,7 +71,7 @@ class CodeValidatorView(PolicyAccessView): | |||||||
|         except FlowNonApplicableException: |         except FlowNonApplicableException: | ||||||
|             LOGGER.warning("Flow not applicable to user") |             LOGGER.warning("Flow not applicable to user") | ||||||
|             return None |             return None | ||||||
|         plan.append_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) |         plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) | ||||||
|         return plan.to_redirect(self.request, self.token.provider.authorization_flow) |         return plan.to_redirect(self.request, self.token.provider.authorization_flow) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -34,5 +34,5 @@ class EndSessionView(PolicyAccessView): | |||||||
|                 PLAN_CONTEXT_APPLICATION: self.application, |                 PLAN_CONTEXT_APPLICATION: self.application, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         plan.append_stage(in_memory_stage(SessionEndStage)) |         plan.insert_stage(in_memory_stage(SessionEndStage)) | ||||||
|         return plan.to_redirect(self.request, self.flow) |         return plan.to_redirect(self.request, self.flow) | ||||||
|  | |||||||
| @ -75,7 +75,10 @@ class JWKSView(View): | |||||||
|         key_data = {} |         key_data = {} | ||||||
|  |  | ||||||
|         if use == "sig": |         if use == "sig": | ||||||
|             key_data["alg"] = JWTAlgorithms.from_private_key(private_key) |             if isinstance(private_key, RSAPrivateKey): | ||||||
|  |                 key_data["alg"] = JWTAlgorithms.RS256 | ||||||
|  |             elif isinstance(private_key, EllipticCurvePrivateKey): | ||||||
|  |                 key_data["alg"] = JWTAlgorithms.ES256 | ||||||
|         elif use == "enc": |         elif use == "enc": | ||||||
|             key_data["alg"] = "RSA-OAEP-256" |             key_data["alg"] = "RSA-OAEP-256" | ||||||
|             key_data["enc"] = "A256CBC-HS512" |             key_data["enc"] = "A256CBC-HS512" | ||||||
|  | |||||||
| @ -36,17 +36,17 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]): | |||||||
|     def reconciler_name() -> str: |     def reconciler_name() -> str: | ||||||
|         return "ingress" |         return "ingress" | ||||||
|  |  | ||||||
|     def _check_annotations(self, current: V1Ingress, reference: V1Ingress): |     def _check_annotations(self, reference: V1Ingress): | ||||||
|         """Check that all annotations *we* set are correct""" |         """Check that all annotations *we* set are correct""" | ||||||
|         for key, value in reference.metadata.annotations.items(): |         for key, value in self.get_ingress_annotations().items(): | ||||||
|             if key not in current.metadata.annotations: |             if key not in reference.metadata.annotations: | ||||||
|                 raise NeedsUpdate() |                 raise NeedsUpdate() | ||||||
|             if current.metadata.annotations[key] != value: |             if reference.metadata.annotations[key] != value: | ||||||
|                 raise NeedsUpdate() |                 raise NeedsUpdate() | ||||||
|  |  | ||||||
|     def reconcile(self, current: V1Ingress, reference: V1Ingress): |     def reconcile(self, current: V1Ingress, reference: V1Ingress): | ||||||
|         super().reconcile(current, reference) |         super().reconcile(current, reference) | ||||||
|         self._check_annotations(current, reference) |         self._check_annotations(reference) | ||||||
|         # Create a list of all expected host and tls hosts |         # Create a list of all expected host and tls hosts | ||||||
|         expected_hosts = [] |         expected_hosts = [] | ||||||
|         expected_hosts_tls = [] |         expected_hosts_tls = [] | ||||||
|  | |||||||
| @ -1,14 +0,0 @@ | |||||||
| """RAC app config""" |  | ||||||
|  |  | ||||||
| from django.apps import AppConfig |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikProviderRAC(AppConfig): |  | ||||||
|     """authentik rac app config""" |  | ||||||
|  |  | ||||||
|     name = "authentik.providers.rac" |  | ||||||
|     label = "authentik_providers_rac" |  | ||||||
|     verbose_name = "authentik Providers.RAC" |  | ||||||
|     default = True |  | ||||||
|     mountpoint = "" |  | ||||||
|     ws_mountpoint = "authentik.providers.rac.urls" |  | ||||||
| @ -61,7 +61,7 @@ class SAMLSLOView(PolicyAccessView): | |||||||
|                 PLAN_CONTEXT_APPLICATION: self.application, |                 PLAN_CONTEXT_APPLICATION: self.application, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         plan.append_stage(in_memory_stage(SessionEndStage)) |         plan.insert_stage(in_memory_stage(SessionEndStage)) | ||||||
|         return plan.to_redirect(self.request, self.flow) |         return plan.to_redirect(self.request, self.flow) | ||||||
|  |  | ||||||
|     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: |     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: | ||||||
|  | |||||||
| @ -30,7 +30,6 @@ class SCIMProviderSerializer(ProviderSerializer): | |||||||
|             "token", |             "token", | ||||||
|             "exclude_users_service_account", |             "exclude_users_service_account", | ||||||
|             "filter_group", |             "filter_group", | ||||||
|             "dry_run", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = {} |         extra_kwargs = {} | ||||||
|  |  | ||||||
|  | |||||||
| @ -12,9 +12,8 @@ from authentik.lib.sync.outgoing import ( | |||||||
|     HTTP_SERVICE_UNAVAILABLE, |     HTTP_SERVICE_UNAVAILABLE, | ||||||
|     HTTP_TOO_MANY_REQUESTS, |     HTTP_TOO_MANY_REQUESTS, | ||||||
| ) | ) | ||||||
| from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient | from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( | from authentik.lib.sync.outgoing.exceptions import ( | ||||||
|     DryRunRejected, |  | ||||||
|     NotFoundSyncException, |     NotFoundSyncException, | ||||||
|     ObjectExistsSyncException, |     ObjectExistsSyncException, | ||||||
|     TransientSyncException, |     TransientSyncException, | ||||||
| @ -55,8 +54,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( | |||||||
|  |  | ||||||
|     def _request(self, method: str, path: str, **kwargs) -> dict: |     def _request(self, method: str, path: str, **kwargs) -> dict: | ||||||
|         """Wrapper to send a request to the full URL""" |         """Wrapper to send a request to the full URL""" | ||||||
|         if self.provider.dry_run and method.upper() not in SAFE_METHODS: |  | ||||||
|             raise DryRunRejected(f"{self.base_url}{path}", method, body=kwargs.get("json")) |  | ||||||
|         try: |         try: | ||||||
|             response = self._session.request( |             response = self._session.request( | ||||||
|                 method, |                 method, | ||||||
|  | |||||||
| @ -1,21 +0,0 @@ | |||||||
| # Generated by Django 5.0.12 on 2025-02-24 19:43 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_providers_scim", "0010_scimprovider_verify_certificates"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="scimprovider", |  | ||||||
|             name="dry_run", |  | ||||||
|             field=models.BooleanField( |  | ||||||
|                 default=False, |  | ||||||
|                 help_text="When enabled, provider will not modify or create objects in the remote system.", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -3,15 +3,12 @@ | |||||||
| from json import loads | from json import loads | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from django.utils.text import slugify |  | ||||||
| from jsonschema import validate | from jsonschema import validate | ||||||
| from requests_mock import Mocker | from requests_mock import Mocker | ||||||
|  |  | ||||||
| from authentik.blueprints.tests import apply_blueprint | from authentik.blueprints.tests import apply_blueprint | ||||||
| from authentik.core.models import Application, Group, User | from authentik.core.models import Application, Group, User | ||||||
| from authentik.events.models import SystemTask |  | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.lib.sync.outgoing.base import SAFE_METHODS |  | ||||||
| from authentik.providers.scim.models import SCIMMapping, SCIMProvider | from authentik.providers.scim.models import SCIMMapping, SCIMProvider | ||||||
| from authentik.providers.scim.tasks import scim_sync, sync_tasks | from authentik.providers.scim.tasks import scim_sync, sync_tasks | ||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
| @ -333,59 +330,3 @@ class SCIMUserTests(TestCase): | |||||||
|                 "userName": uid, |                 "userName": uid, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_user_create_dry_run(self): |  | ||||||
|         """Test user creation (dry_run)""" |  | ||||||
|         # Update the provider before we start mocking as saving the provider triggers a full sync |  | ||||||
|         self.provider.dry_run = True |  | ||||||
|         self.provider.save() |  | ||||||
|         with Mocker() as mock: |  | ||||||
|             scim_id = generate_id() |  | ||||||
|             mock.get( |  | ||||||
|                 "https://localhost/ServiceProviderConfig", |  | ||||||
|                 json={}, |  | ||||||
|             ) |  | ||||||
|             mock.post( |  | ||||||
|                 "https://localhost/Users", |  | ||||||
|                 json={ |  | ||||||
|                     "id": scim_id, |  | ||||||
|                 }, |  | ||||||
|             ) |  | ||||||
|             uid = generate_id() |  | ||||||
|             User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|             self.assertEqual(mock.call_count, 1, mock.request_history) |  | ||||||
|             self.assertEqual(mock.request_history[0].method, "GET") |  | ||||||
|  |  | ||||||
|     def test_sync_task_dry_run(self): |  | ||||||
|         """Test sync tasks""" |  | ||||||
|         # Update the provider before we start mocking as saving the provider triggers a full sync |  | ||||||
|         self.provider.dry_run = True |  | ||||||
|         self.provider.save() |  | ||||||
|         with Mocker() as mock: |  | ||||||
|             uid = generate_id() |  | ||||||
|             mock.get( |  | ||||||
|                 "https://localhost/ServiceProviderConfig", |  | ||||||
|                 json={}, |  | ||||||
|             ) |  | ||||||
|             User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             sync_tasks.trigger_single_task(self.provider, scim_sync).get() |  | ||||||
|  |  | ||||||
|             self.assertEqual(mock.call_count, 3) |  | ||||||
|             for request in mock.request_history: |  | ||||||
|                 self.assertIn(request.method, SAFE_METHODS) |  | ||||||
|         task = SystemTask.objects.filter(uid=slugify(self.provider.name)).first() |  | ||||||
|         self.assertIsNotNone(task) |  | ||||||
|         drop_msg = task.messages[2] |  | ||||||
|         self.assertEqual(drop_msg["event"], "Dropping mutating request due to dry run") |  | ||||||
|         self.assertIsNotNone(drop_msg["attributes"]["url"]) |  | ||||||
|         self.assertIsNotNone(drop_msg["attributes"]["body"]) |  | ||||||
|         self.assertIsNotNone(drop_msg["attributes"]["method"]) |  | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.contrib.auth.models import Permission | from django.contrib.auth.models import Permission | ||||||
| from django.db.models import QuerySet | from django.db.models import Q, QuerySet | ||||||
| from django_filters.filters import ModelChoiceFilter | from django_filters.filters import ModelChoiceFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| @ -18,6 +18,7 @@ from rest_framework.filters import OrderingFilter, SearchFilter | |||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAuthenticated | ||||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | from rest_framework.viewsets import ReadOnlyModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.blueprints.v1.importer import excluded_models | ||||||
| from authentik.core.api.utils import ModelSerializer, PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.lib.validators import RequiredTogetherValidator | from authentik.lib.validators import RequiredTogetherValidator | ||||||
| @ -105,13 +106,13 @@ class RBACPermissionViewSet(ReadOnlyModelViewSet): | |||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     def get_queryset(self) -> QuerySet: |     def get_queryset(self) -> QuerySet: | ||||||
|         return ( |         query = Q() | ||||||
|             Permission.objects.all() |         for model in excluded_models(): | ||||||
|             .select_related("content_type") |             query |= Q( | ||||||
|             .filter( |                 content_type__app_label=model._meta.app_label, | ||||||
|                 content_type__app_label__startswith="authentik", |                 content_type__model=model._meta.model_name, | ||||||
|             ) |             ) | ||||||
|         ) |         return Permission.objects.all().select_related("content_type").exclude(query) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PermissionAssignSerializer(PassiveSerializer): | class PermissionAssignSerializer(PassiveSerializer): | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	