Compare commits
	
		
			4 Commits
		
	
	
		
			version/20
			...
			sources/ld
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2a6479062f | |||
| 52463b8f96 | |||
| 330f639a7e | |||
| 85ea4651e4 | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2023.10.2 | ||||
| current_version = 2023.8.3 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/codecov.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/codecov.yml
									
									
									
									
										vendored
									
									
								
							| @ -6,5 +6,5 @@ coverage: | ||||
|         # adjust accordingly based on how flaky your tests are | ||||
|         # this allows a 1% drop from the previous base commit coverage | ||||
|         threshold: 1% | ||||
| comment: | ||||
|   notify: | ||||
|     after_n_builds: 3 | ||||
|  | ||||
							
								
								
									
										37
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										37
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -30,7 +30,6 @@ updates: | ||||
|     open-pull-requests-limit: 10 | ||||
|     commit-message: | ||||
|       prefix: "web:" | ||||
|     # TODO: deduplicate these groups | ||||
|     groups: | ||||
|       sentry: | ||||
|         patterns: | ||||
| @ -41,7 +40,7 @@ updates: | ||||
|           - "babel-*" | ||||
|       eslint: | ||||
|         patterns: | ||||
|           - "@typescript-eslint/*" | ||||
|           - "@typescript-eslint/eslint-*" | ||||
|           - "eslint" | ||||
|           - "eslint-*" | ||||
|       storybook: | ||||
| @ -51,40 +50,6 @@ updates: | ||||
|       esbuild: | ||||
|         patterns: | ||||
|           - "@esbuild/*" | ||||
|   - package-ecosystem: npm | ||||
|     directory: "/tests/wdio" | ||||
|     schedule: | ||||
|       interval: daily | ||||
|       time: "04:00" | ||||
|     labels: | ||||
|       - dependencies | ||||
|     open-pull-requests-limit: 10 | ||||
|     commit-message: | ||||
|       prefix: "web:" | ||||
|     # TODO: deduplicate these groups | ||||
|     groups: | ||||
|       sentry: | ||||
|         patterns: | ||||
|           - "@sentry/*" | ||||
|       babel: | ||||
|         patterns: | ||||
|           - "@babel/*" | ||||
|           - "babel-*" | ||||
|       eslint: | ||||
|         patterns: | ||||
|           - "@typescript-eslint/*" | ||||
|           - "eslint" | ||||
|           - "eslint-*" | ||||
|       storybook: | ||||
|         patterns: | ||||
|           - "@storybook/*" | ||||
|           - "*storybook*" | ||||
|       esbuild: | ||||
|         patterns: | ||||
|           - "@esbuild/*" | ||||
|       wdio: | ||||
|         patterns: | ||||
|           - "@wdio/*" | ||||
|   - package-ecosystem: npm | ||||
|     directory: "/website" | ||||
|     schedule: | ||||
|  | ||||
							
								
								
									
										1
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -90,7 +90,6 @@ jobs: | ||||
|         psql: | ||||
|           - 12-alpine | ||||
|           - 15-alpine | ||||
|           - 16-alpine | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Setup authentik env | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -29,7 +29,7 @@ jobs: | ||||
|       - name: golangci-lint | ||||
|         uses: golangci/golangci-lint-action@v3 | ||||
|         with: | ||||
|           version: v1.54.2 | ||||
|           version: v1.52.2 | ||||
|           args: --timeout 5000s --verbose | ||||
|           skip-cache: true | ||||
|   test-unittest: | ||||
| @ -124,7 +124,7 @@ jobs: | ||||
|       - uses: actions/setup-go@v4 | ||||
|         with: | ||||
|           go-version-file: "go.mod" | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
|  | ||||
							
								
								
									
										34
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										34
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -13,31 +13,25 @@ on: | ||||
| jobs: | ||||
|   lint-eslint: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         project: | ||||
|           - web | ||||
|           - tests/wdio | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: ${{ matrix.project }}/package-lock.json | ||||
|       - working-directory: ${{ matrix.project }}/ | ||||
|           cache-dependency-path: web/package-lock.json | ||||
|       - working-directory: web/ | ||||
|         run: npm ci | ||||
|       - name: Generate API | ||||
|         run: make gen-client-ts | ||||
|       - name: Eslint | ||||
|         working-directory: ${{ matrix.project }}/ | ||||
|         working-directory: web/ | ||||
|         run: npm run lint | ||||
|   lint-build: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
| @ -51,31 +45,25 @@ jobs: | ||||
|         run: npm run tsc | ||||
|   lint-prettier: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         project: | ||||
|           - web | ||||
|           - tests/wdio | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: ${{ matrix.project }}/package-lock.json | ||||
|       - working-directory: ${{ matrix.project }}/ | ||||
|           cache-dependency-path: web/package-lock.json | ||||
|       - working-directory: web/ | ||||
|         run: npm ci | ||||
|       - name: Generate API | ||||
|         run: make gen-client-ts | ||||
|       - name: prettier | ||||
|         working-directory: ${{ matrix.project }}/ | ||||
|         working-directory: web/ | ||||
|         run: npm run prettier-check | ||||
|   lint-lit-analyse: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
| @ -107,7 +95,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
|  | ||||
							
								
								
									
										6
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -15,7 +15,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
| @ -29,7 +29,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
| @ -50,7 +50,7 @@ jobs: | ||||
|           - build-docs-only | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,8 +1,8 @@ | ||||
| name: ghcr-retention | ||||
|  | ||||
| on: | ||||
|   # schedule: | ||||
|   #   - cron: "0 0 * * *" # every day at midnight | ||||
|   schedule: | ||||
|     - cron: "0 0 * * *" # every day at midnight | ||||
|   workflow_dispatch: | ||||
|  | ||||
| jobs: | ||||
|  | ||||
							
								
								
									
										13
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -27,10 +27,8 @@ jobs: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.repository_owner }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: make empty clients | ||||
|         run: | | ||||
|           mkdir -p ./gen-ts-api | ||||
|           mkdir -p ./gen-go-api | ||||
|       - name: make empty ts client | ||||
|         run: mkdir -p ./gen-ts-client | ||||
|       - name: Build Docker Image | ||||
|         uses: docker/build-push-action@v5 | ||||
|         with: | ||||
| @ -71,10 +69,6 @@ jobs: | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|       - name: make empty clients | ||||
|         run: | | ||||
|           mkdir -p ./gen-ts-api | ||||
|           mkdir -p ./gen-go-api | ||||
|       - name: Docker Login Registry | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
| @ -99,7 +93,6 @@ jobs: | ||||
|             ghcr.io/goauthentik/${{ matrix.type }}:latest | ||||
|           file: ${{ matrix.type }}.Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           context: . | ||||
|           build-args: | | ||||
|             VERSION=${{ steps.ev.outputs.version }} | ||||
|             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||
| @ -120,7 +113,7 @@ jobs: | ||||
|       - uses: actions/setup-go@v4 | ||||
|         with: | ||||
|           go-version-file: "go.mod" | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           cache: "npm" | ||||
|  | ||||
							
								
								
									
										1
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -16,7 +16,6 @@ jobs: | ||||
|           echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||
|           echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env | ||||
|           docker buildx install | ||||
|           mkdir -p ./gen-ts-api | ||||
|           docker build -t testing:latest . | ||||
|           echo "AUTHENTIK_IMAGE=testing" >> .env | ||||
|           echo "AUTHENTIK_TAG=latest" >> .env | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -17,7 +17,7 @@ jobs: | ||||
|       - uses: actions/checkout@v4 | ||||
|         with: | ||||
|           token: ${{ steps.generate_token.outputs.token }} | ||||
|       - uses: actions/setup-node@v4 | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: "20" | ||||
|           registry-url: "https://registry.npmjs.org" | ||||
|  | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -206,6 +206,3 @@ data/ | ||||
| .netlify | ||||
| .ruff_cache | ||||
| source_docs/ | ||||
|  | ||||
| ### Golang ### | ||||
| /vendor/ | ||||
|  | ||||
| @ -9,8 +9,6 @@ lifecycle/                      @goauthentik/backend | ||||
| schemas/                        @goauthentik/backend | ||||
| scripts/                        @goauthentik/backend | ||||
| tests/                          @goauthentik/backend | ||||
| pyproject.toml                  @goauthentik/backend | ||||
| poetry.lock                     @goauthentik/backend | ||||
| # Infrastructure | ||||
| .github/                        @goauthentik/infrastructure | ||||
| Dockerfile                      @goauthentik/infrastructure | ||||
| @ -19,7 +17,6 @@ Dockerfile                      @goauthentik/infrastructure | ||||
| docker-compose.yml              @goauthentik/infrastructure | ||||
| # Web | ||||
| web/                            @goauthentik/frontend | ||||
| tests/wdio/                     @goauthentik/frontend | ||||
| # Docs & Website | ||||
| website/                        @goauthentik/docs | ||||
| # Security | ||||
|  | ||||
							
								
								
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| # Stage 1: Build website | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:21 as website-builder | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:20 as website-builder | ||||
|  | ||||
| ENV NODE_ENV=production | ||||
|  | ||||
| @ -17,7 +17,7 @@ COPY ./SECURITY.md /work/ | ||||
| RUN npm run build-docs-only | ||||
|  | ||||
| # Stage 2: Build webui | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:21 as web-builder | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:20 as web-builder | ||||
|  | ||||
| ENV NODE_ENV=production | ||||
|  | ||||
| @ -35,7 +35,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | ||||
| RUN npm run build | ||||
|  | ||||
| # Stage 3: Build go proxy | ||||
| FROM docker.io/golang:1.21.3-bookworm AS go-builder | ||||
| FROM docker.io/golang:1.21.1-bookworm AS go-builder | ||||
|  | ||||
| WORKDIR /go/src/goauthentik.io | ||||
|  | ||||
| @ -146,10 +146,10 @@ USER 1000 | ||||
| ENV TMPDIR=/dev/shm/ \ | ||||
|     PYTHONDONTWRITEBYTECODE=1 \ | ||||
|     PYTHONUNBUFFERED=1 \ | ||||
|     PATH="/ak-root/venv/bin:/lifecycle:$PATH" \ | ||||
|     PATH="/ak-root/venv/bin:$PATH" \ | ||||
|     VENV_PATH="/ak-root/venv" \ | ||||
|     POETRY_VIRTUALENVS_CREATE=false | ||||
|  | ||||
| HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ] | ||||
| HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ] | ||||
|  | ||||
| ENTRYPOINT [ "dumb-init", "--", "ak" ] | ||||
| ENTRYPOINT [ "dumb-init", "--", "/lifecycle/ak" ] | ||||
|  | ||||
							
								
								
									
										20
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								Makefile
									
									
									
									
									
								
							| @ -28,13 +28,10 @@ CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ | ||||
|  | ||||
| all: lint-fix lint test gen web  ## Lint, build, and test everything | ||||
|  | ||||
| HELP_WIDTH := $(shell grep -h '^[a-z][^ ]*:.*\#\#' $(MAKEFILE_LIST) 2>/dev/null | \ | ||||
| 	cut -d':' -f1 | awk '{printf "%d\n", length}' | sort -rn | head -1) | ||||
|  | ||||
| help:  ## Show this help | ||||
| 	@echo "\nSpecify a command. The choices are:\n" | ||||
| 	@grep -Eh '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ | ||||
| 		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[0;36m%-$(HELP_WIDTH)s  \033[m %s\n", $$1, $$2}' | \ | ||||
| 	@grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ | ||||
| 		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[0;36m%-24s\033[m %s\n", $$1, $$2}' | \ | ||||
| 		sort | ||||
| 	@echo "" | ||||
|  | ||||
| @ -56,15 +53,14 @@ test: ## Run the server tests and produce a coverage report (locally) | ||||
| 	coverage report | ||||
|  | ||||
| lint-fix:  ## Lint and automatically fix errors in the python source code. Reports spelling errors. | ||||
| 	isort $(PY_SOURCES) | ||||
| 	black $(PY_SOURCES) | ||||
| 	ruff $(PY_SOURCES) | ||||
| 	isort authentik $(PY_SOURCES) | ||||
| 	black authentik $(PY_SOURCES) | ||||
| 	ruff authentik $(PY_SOURCES) | ||||
| 	codespell -w $(CODESPELL_ARGS) | ||||
|  | ||||
| lint: ## Lint the python and golang sources | ||||
| 	bandit -r $(PY_SOURCES) -x node_modules | ||||
| 	./web/node_modules/.bin/pyright $(PY_SOURCES) | ||||
| 	pylint $(PY_SOURCES) | ||||
| 	bandit -r $(PY_SOURCES) -x node_modules | ||||
| 	golangci-lint run -v | ||||
|  | ||||
| migrate: ## Run the Authentik Django server's migrations | ||||
| @ -79,10 +75,10 @@ install: web-install website-install  ## Install all requires dependencies for ` | ||||
| 	poetry install | ||||
|  | ||||
| dev-drop-db: | ||||
| 	dropdb -U ${pg_user} -h ${pg_host} ${pg_name} | ||||
| 	echo dropdb -U ${pg_user} -h ${pg_host} ${pg_name} | ||||
| 	# Also remove the test-db if it exists | ||||
| 	dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true | ||||
| 	redis-cli -n 0 flushall | ||||
| 	echo redis-cli -n 0 flushall | ||||
|  | ||||
| dev-create-db: | ||||
| 	createdb -U ${pg_user} -h ${pg_host} ${pg_name} | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from os import environ | ||||
| from typing import Optional | ||||
|  | ||||
| __version__ = "2023.10.2" | ||||
| __version__ = "2023.8.3" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """Meta API""" | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from rest_framework.fields import CharField | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.viewsets import ViewSet | ||||
| @ -21,7 +21,7 @@ class AppSerializer(PassiveSerializer): | ||||
| class AppsViewSet(ViewSet): | ||||
|     """Read-only view list all installed apps""" | ||||
|  | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     @extend_schema(responses={200: AppSerializer(many=True)}) | ||||
|     def list(self, request: Request) -> Response: | ||||
| @ -35,7 +35,7 @@ class AppsViewSet(ViewSet): | ||||
| class ModelViewSet(ViewSet): | ||||
|     """Read-only view list all installed models""" | ||||
|  | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     @extend_schema(responses={200: AppSerializer(many=True)}) | ||||
|     def list(self, request: Request) -> Response: | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.db.models.functions import ExtractHour | ||||
| from drf_spectacular.utils import extend_schema, extend_schema_field | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from rest_framework.fields import IntegerField, SerializerMethodField | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
| @ -68,7 +68,7 @@ class LoginMetricsSerializer(PassiveSerializer): | ||||
| class AdministrationMetricsViewSet(APIView): | ||||
|     """Login Metrics per 1h""" | ||||
|  | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     @extend_schema(responses={200: LoginMetricsSerializer(many=False)}) | ||||
|     def get(self, request: Request) -> Response: | ||||
|  | ||||
| @ -8,6 +8,7 @@ from django.utils.timezone import now | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from gunicorn import version_info as gunicorn_version | ||||
| from rest_framework.fields import SerializerMethodField | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
| @ -16,7 +17,6 @@ from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.lib.utils.reflection import get_env | ||||
| from authentik.outposts.apps import MANAGED_OUTPOST | ||||
| from authentik.outposts.models import Outpost | ||||
| from authentik.rbac.permissions import HasPermission | ||||
|  | ||||
|  | ||||
| class RuntimeDict(TypedDict): | ||||
| @ -88,7 +88,7 @@ class SystemSerializer(PassiveSerializer): | ||||
| class SystemView(APIView): | ||||
|     """Get system information.""" | ||||
|  | ||||
|     permission_classes = [HasPermission("authentik_rbac.view_system_info")] | ||||
|     permission_classes = [IsAdminUser] | ||||
|     pagination_class = None | ||||
|     filter_backends = [] | ||||
|     serializer_class = SystemSerializer | ||||
|  | ||||
| @ -14,15 +14,14 @@ from rest_framework.fields import ( | ||||
|     ListField, | ||||
|     SerializerMethodField, | ||||
| ) | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.viewsets import ViewSet | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus | ||||
| from authentik.rbac.permissions import HasPermission | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -64,7 +63,7 @@ class TaskSerializer(PassiveSerializer): | ||||
| class TaskViewSet(ViewSet): | ||||
|     """Read-only view set that returns all background tasks""" | ||||
|  | ||||
|     permission_classes = [HasPermission("authentik_rbac.view_system_tasks")] | ||||
|     permission_classes = [IsAdminUser] | ||||
|     serializer_class = TaskSerializer | ||||
|  | ||||
|     @extend_schema( | ||||
| @ -94,7 +93,6 @@ class TaskViewSet(ViewSet): | ||||
|         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) | ||||
|         return Response(TaskSerializer(tasks, many=True).data) | ||||
|  | ||||
|     @permission_required(None, ["authentik_rbac.run_system_tasks"]) | ||||
|     @extend_schema( | ||||
|         request=OpenApiTypes.NONE, | ||||
|         responses={ | ||||
|  | ||||
| @ -2,18 +2,18 @@ | ||||
| from django.conf import settings | ||||
| from drf_spectacular.utils import extend_schema, inline_serializer | ||||
| from rest_framework.fields import IntegerField | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from authentik.rbac.permissions import HasPermission | ||||
| from authentik.root.celery import CELERY_APP | ||||
|  | ||||
|  | ||||
| class WorkerView(APIView): | ||||
|     """Get currently connected worker count.""" | ||||
|  | ||||
|     permission_classes = [HasPermission("authentik_rbac.view_system_info")] | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     @extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()})) | ||||
|     def get(self, request: Request) -> Response: | ||||
|  | ||||
| @ -7,9 +7,9 @@ from rest_framework.authentication import get_authorization_header | ||||
| from rest_framework.filters import BaseFilterBackend | ||||
| from rest_framework.permissions import BasePermission | ||||
| from rest_framework.request import Request | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
|  | ||||
| from authentik.api.authentication import validate_auth | ||||
| from authentik.rbac.filters import ObjectFilter | ||||
|  | ||||
|  | ||||
| class OwnerFilter(BaseFilterBackend): | ||||
| @ -26,14 +26,14 @@ class OwnerFilter(BaseFilterBackend): | ||||
| class SecretKeyFilter(DjangoFilterBackend): | ||||
|     """Allow access to all objects when authenticated with secret key as token. | ||||
|  | ||||
|     Replaces both DjangoFilterBackend and ObjectFilter""" | ||||
|     Replaces both DjangoFilterBackend and ObjectPermissionsFilter""" | ||||
|  | ||||
|     def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: | ||||
|         auth_header = get_authorization_header(request) | ||||
|         token = validate_auth(auth_header) | ||||
|         if token and token == settings.SECRET_KEY: | ||||
|             return queryset | ||||
|         queryset = ObjectFilter().filter_queryset(request, queryset, view) | ||||
|         queryset = ObjectPermissionsFilter().filter_queryset(request, queryset, view) | ||||
|         return super().filter_queryset(request, queryset, view) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -10,7 +10,7 @@ from structlog.stdlib import get_logger | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| def permission_required(obj_perm: Optional[str] = None, global_perms: Optional[list[str]] = None): | ||||
| def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None): | ||||
|     """Check permissions for a single custom action""" | ||||
|  | ||||
|     def wrapper_outter(func: Callable): | ||||
| @ -18,17 +18,15 @@ def permission_required(obj_perm: Optional[str] = None, global_perms: Optional[l | ||||
|  | ||||
|         @wraps(func) | ||||
|         def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response: | ||||
|             if obj_perm: | ||||
|             if perm: | ||||
|                 obj = self.get_object() | ||||
|                 if not request.user.has_perm(obj_perm, obj): | ||||
|                     LOGGER.debug( | ||||
|                         "denying access for object", user=request.user, perm=obj_perm, obj=obj | ||||
|                     ) | ||||
|                 if not request.user.has_perm(perm, obj): | ||||
|                     LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj) | ||||
|                     return self.permission_denied(request) | ||||
|             if global_perms: | ||||
|                 for other_perm in global_perms: | ||||
|             if other_perms: | ||||
|                 for other_perm in other_perms: | ||||
|                     if not request.user.has_perm(other_perm): | ||||
|                         LOGGER.debug("denying access for other", user=request.user, perm=other_perm) | ||||
|                         LOGGER.debug("denying access for other", user=request.user, perm=perm) | ||||
|                         return self.permission_denied(request) | ||||
|             return func(self, request, *args, **kwargs) | ||||
|  | ||||
|  | ||||
| @ -77,10 +77,3 @@ class Pagination(pagination.PageNumberPagination): | ||||
|             }, | ||||
|             "required": ["pagination", "results"], | ||||
|         } | ||||
|  | ||||
|  | ||||
| class SmallerPagination(Pagination): | ||||
|     """Smaller pagination for objects which might require a lot of queries | ||||
|     to retrieve all data for.""" | ||||
|  | ||||
|     max_page_size = 10 | ||||
|  | ||||
| @ -16,7 +16,6 @@ def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable: | ||||
|  | ||||
|     def tester(self: TestModelViewSets): | ||||
|         self.assertIsNotNone(getattr(test_viewset, "search_fields", None)) | ||||
|         self.assertIsNotNone(getattr(test_viewset, "ordering", None)) | ||||
|         filterset_class = getattr(test_viewset, "filterset_class", None) | ||||
|         if not filterset_class: | ||||
|             self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None)) | ||||
|  | ||||
| @ -4,6 +4,7 @@ from drf_spectacular.utils import extend_schema, inline_serializer | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.fields import CharField, DateTimeField, JSONField | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ListSerializer, ModelSerializer | ||||
| @ -86,11 +87,11 @@ class BlueprintInstanceSerializer(ModelSerializer): | ||||
| class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Blueprint instances""" | ||||
|  | ||||
|     permission_classes = [IsAdminUser] | ||||
|     serializer_class = BlueprintInstanceSerializer | ||||
|     queryset = BlueprintInstance.objects.all() | ||||
|     search_fields = ["name", "path"] | ||||
|     filterset_fields = ["name", "path"] | ||||
|     ordering = ["name"] | ||||
|  | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|  | ||||
| @ -6,7 +6,6 @@ from django.test import TestCase | ||||
|  | ||||
| from authentik.blueprints.v1.importer import is_model_allowed | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.providers.oauth2.models import RefreshToken | ||||
|  | ||||
|  | ||||
| class TestModels(TestCase): | ||||
| @ -22,9 +21,6 @@ def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable: | ||||
|         model_class = test_model() | ||||
|         self.assertTrue(isinstance(model_class, SerializerModel)) | ||||
|         self.assertIsNotNone(model_class.serializer) | ||||
|         if model_class.serializer.Meta().model == RefreshToken: | ||||
|             return | ||||
|         self.assertEqual(model_class.serializer.Meta().model, test_model) | ||||
|  | ||||
|     return tester | ||||
|  | ||||
|  | ||||
| @ -584,17 +584,12 @@ class EntryInvalidError(SentryIgnoredException): | ||||
|     entry_model: Optional[str] | ||||
|     entry_id: Optional[str] | ||||
|     validation_error: Optional[ValidationError] | ||||
|     serializer: Optional[Serializer] = None | ||||
|  | ||||
|     def __init__( | ||||
|         self, *args: object, validation_error: Optional[ValidationError] = None, **kwargs | ||||
|     ) -> None: | ||||
|     def __init__(self, *args: object, validation_error: Optional[ValidationError] = None) -> None: | ||||
|         super().__init__(*args) | ||||
|         self.entry_model = None | ||||
|         self.entry_id = None | ||||
|         self.validation_error = validation_error | ||||
|         for key, value in kwargs.items(): | ||||
|             setattr(self, key, value) | ||||
|  | ||||
|     @staticmethod | ||||
|     def from_entry( | ||||
|  | ||||
| @ -35,28 +35,25 @@ from authentik.core.models import ( | ||||
|     Source, | ||||
|     UserSourceConnection, | ||||
| ) | ||||
| from authentik.enterprise.models import LicenseUsage | ||||
| from authentik.events.utils import cleanse_dict | ||||
| from authentik.flows.models import FlowToken, Stage | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.outposts.models import OutpostServiceConnection | ||||
| from authentik.policies.models import Policy, PolicyBindingModel | ||||
| from authentik.providers.scim.models import SCIMGroup, SCIMUser | ||||
|  | ||||
| # Context set when the serializer is created in a blueprint context | ||||
| # Update website/developer-docs/blueprints/v1/models.md when used | ||||
| SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry" | ||||
|  | ||||
|  | ||||
| def excluded_models() -> list[type[Model]]: | ||||
|     """Return a list of all excluded models that shouldn't be exposed via API | ||||
|     or other means (internal only, base classes, non-used objects, etc)""" | ||||
| def is_model_allowed(model: type[Model]) -> bool: | ||||
|     """Check if model is allowed""" | ||||
|     # pylint: disable=imported-auth-user | ||||
|     from django.contrib.auth.models import Group as DjangoGroup | ||||
|     from django.contrib.auth.models import User as DjangoUser | ||||
|  | ||||
|     return ( | ||||
|     excluded_models = ( | ||||
|         DjangoUser, | ||||
|         DjangoGroup, | ||||
|         # Base classes | ||||
| @ -72,15 +69,8 @@ def excluded_models() -> list[type[Model]]: | ||||
|         AuthenticatedSession, | ||||
|         # Classes which are only internally managed | ||||
|         FlowToken, | ||||
|         LicenseUsage, | ||||
|         SCIMGroup, | ||||
|         SCIMUser, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def is_model_allowed(model: type[Model]) -> bool: | ||||
|     """Check if model is allowed""" | ||||
|     return model not in excluded_models() and issubclass(model, (SerializerModel, BaseMetaModel)) | ||||
|     return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel)) | ||||
|  | ||||
|  | ||||
| class DoRollback(SentryIgnoredException): | ||||
| @ -255,10 +245,7 @@ class Importer: | ||||
|         try: | ||||
|             full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import)) | ||||
|         except ValueError as exc: | ||||
|             raise EntryInvalidError.from_entry( | ||||
|                 exc, | ||||
|                 entry, | ||||
|             ) from exc | ||||
|             raise EntryInvalidError.from_entry(exc, entry) from exc | ||||
|         always_merger.merge(full_data, updated_identifiers) | ||||
|         serializer_kwargs["data"] = full_data | ||||
|  | ||||
| @ -275,7 +262,6 @@ class Importer: | ||||
|                 f"Serializer errors {serializer.errors}", | ||||
|                 validation_error=exc, | ||||
|                 entry=entry, | ||||
|                 serializer=serializer, | ||||
|             ) from exc | ||||
|         return serializer | ||||
|  | ||||
| @ -304,14 +290,12 @@ class Importer: | ||||
|                 ) | ||||
|                 return False | ||||
|             # Validate each single entry | ||||
|             serializer = None | ||||
|             try: | ||||
|                 serializer = self._validate_single(entry) | ||||
|             except EntryInvalidError as exc: | ||||
|                 # For deleting objects we don't need the serializer to be valid | ||||
|                 if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT: | ||||
|                     serializer = exc.serializer | ||||
|                 else: | ||||
|                     continue | ||||
|                 self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) | ||||
|                 if raise_errors: | ||||
|                     raise exc | ||||
|  | ||||
| @ -82,7 +82,7 @@ class BlueprintEventHandler(FileSystemEventHandler): | ||||
|             path = Path(event.src_path) | ||||
|             root = Path(CONFIG.get("blueprints_dir")).absolute() | ||||
|             rel_path = str(path.relative_to(root)) | ||||
|             for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True): | ||||
|             for instance in BlueprintInstance.objects.filter(path=rel_path): | ||||
|                 LOGGER.debug("modified blueprint file, starting apply", instance=instance) | ||||
|                 apply_blueprint.delay(instance.pk.hex) | ||||
|  | ||||
|  | ||||
| @ -17,6 +17,7 @@ from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
| from structlog.stdlib import get_logger | ||||
| from structlog.testing import capture_logs | ||||
|  | ||||
| @ -37,7 +38,6 @@ from authentik.lib.utils.file import ( | ||||
| from authentik.policies.api.exec import PolicyTestResultSerializer | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.types import PolicyResult | ||||
| from authentik.rbac.filters import ObjectFilter | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -98,7 +98,6 @@ class ApplicationSerializer(ModelSerializer): | ||||
| class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Application Viewset""" | ||||
|  | ||||
|     # pylint: disable=no-member | ||||
|     queryset = Application.objects.all().prefetch_related("provider") | ||||
|     serializer_class = ApplicationSerializer | ||||
|     search_fields = [ | ||||
| @ -123,7 +122,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||
|         for backend in list(self.filter_backends): | ||||
|             if backend == ObjectFilter: | ||||
|             if backend == ObjectPermissionsFilter: | ||||
|                 continue | ||||
|             queryset = backend().filter_queryset(self.request, queryset, self) | ||||
|         return queryset | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
| from json import loads | ||||
| from typing import Optional | ||||
|  | ||||
| from django.db.models.query import QuerySet | ||||
| from django.http import Http404 | ||||
| from django_filters.filters import CharFilter, ModelMultipleChoiceFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| @ -13,12 +14,12 @@ from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
|  | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import PassiveSerializer, is_dict | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.rbac.api.roles import RoleSerializer | ||||
|  | ||||
|  | ||||
| class GroupMemberSerializer(ModelSerializer): | ||||
| @ -48,12 +49,6 @@ class GroupSerializer(ModelSerializer): | ||||
|     users_obj = ListSerializer( | ||||
|         child=GroupMemberSerializer(), read_only=True, source="users", required=False | ||||
|     ) | ||||
|     roles_obj = ListSerializer( | ||||
|         child=RoleSerializer(), | ||||
|         read_only=True, | ||||
|         source="roles", | ||||
|         required=False, | ||||
|     ) | ||||
|     parent_name = CharField(source="parent.name", read_only=True, allow_null=True) | ||||
|  | ||||
|     num_pk = IntegerField(read_only=True) | ||||
| @ -76,10 +71,8 @@ class GroupSerializer(ModelSerializer): | ||||
|             "parent", | ||||
|             "parent_name", | ||||
|             "users", | ||||
|             "users_obj", | ||||
|             "attributes", | ||||
|             "roles", | ||||
|             "roles_obj", | ||||
|             "users_obj", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "users": { | ||||
| @ -139,13 +132,25 @@ class UserAccountSerializer(PassiveSerializer): | ||||
| class GroupViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Group Viewset""" | ||||
|  | ||||
|     # pylint: disable=no-member | ||||
|     queryset = Group.objects.all().select_related("parent").prefetch_related("users") | ||||
|     serializer_class = GroupSerializer | ||||
|     search_fields = ["name", "is_superuser"] | ||||
|     filterset_class = GroupFilter | ||||
|     ordering = ["name"] | ||||
|  | ||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||
|         for backend in list(self.filter_backends): | ||||
|             if backend == ObjectPermissionsFilter: | ||||
|                 continue | ||||
|             queryset = backend().filter_queryset(self.request, queryset, self) | ||||
|         return queryset | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|         if self.request.user.has_perm("authentik_core.view_group"): | ||||
|             return self._filter_queryset_for_list(queryset) | ||||
|         return super().filter_queryset(queryset) | ||||
|  | ||||
|     @permission_required(None, ["authentik_core.add_user"]) | ||||
|     @extend_schema( | ||||
|         request=UserAccountSerializer, | ||||
|  | ||||
| @ -119,7 +119,6 @@ class TransactionApplicationResponseSerializer(PassiveSerializer): | ||||
| class TransactionalApplicationView(APIView): | ||||
|     """Create provider and application and attach them in a single transaction""" | ||||
|  | ||||
|     # TODO: Migrate to a more specific permission | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     @extend_schema( | ||||
|  | ||||
| @ -73,11 +73,6 @@ class UsedByMixin: | ||||
|             # but so we only apply them once, have a simple flag for the first object | ||||
|             first_object = True | ||||
|  | ||||
|             # TODO: This will only return the used-by references that the user can see | ||||
|             # Either we have to leak model information here to not make the list | ||||
|             # useless if the user doesn't have all permissions, or we need to double | ||||
|             # query and check if there is a difference between modes the user can see | ||||
|             # and can't see and add a warning | ||||
|             for obj in get_objects_for_user( | ||||
|                 request.user, f"{app}.view_{model_name}", manager | ||||
|             ).all(): | ||||
|  | ||||
| @ -7,6 +7,7 @@ from django.contrib.auth import update_session_auth_hash | ||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||
| from django.core.cache import cache | ||||
| from django.db.models.functions import ExtractHour | ||||
| from django.db.models.query import QuerySet | ||||
| from django.db.transaction import atomic | ||||
| from django.db.utils import IntegrityError | ||||
| from django.urls import reverse_lazy | ||||
| @ -51,6 +52,7 @@ from rest_framework.serializers import ( | ||||
| ) | ||||
| from rest_framework.validators import UniqueValidator | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.admin.api.metrics import CoordinateSerializer | ||||
| @ -188,7 +190,6 @@ class UserSerializer(ModelSerializer): | ||||
|             "uid", | ||||
|             "path", | ||||
|             "type", | ||||
|             "uuid", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "name": {"allow_blank": True}, | ||||
| @ -203,7 +204,6 @@ class UserSelfSerializer(ModelSerializer): | ||||
|     groups = SerializerMethodField() | ||||
|     uid = CharField(read_only=True) | ||||
|     settings = SerializerMethodField() | ||||
|     system_permissions = SerializerMethodField() | ||||
|  | ||||
|     @extend_schema_field( | ||||
|         ListSerializer( | ||||
| @ -225,14 +225,6 @@ class UserSelfSerializer(ModelSerializer): | ||||
|         """Get user settings with tenant and group settings applied""" | ||||
|         return user.group_attributes(self._context["request"]).get("settings", {}) | ||||
|  | ||||
|     def get_system_permissions(self, user: User) -> list[str]: | ||||
|         """Get all system permissions assigned to the user""" | ||||
|         return list( | ||||
|             user.user_permissions.filter( | ||||
|                 content_type__app_label="authentik_rbac", content_type__model="systempermission" | ||||
|             ).values_list("codename", flat=True) | ||||
|         ) | ||||
|  | ||||
|     class Meta: | ||||
|         model = User | ||||
|         fields = [ | ||||
| @ -247,7 +239,6 @@ class UserSelfSerializer(ModelSerializer): | ||||
|             "uid", | ||||
|             "settings", | ||||
|             "type", | ||||
|             "system_permissions", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "is_active": {"read_only": True}, | ||||
| @ -662,6 +653,19 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|  | ||||
|         return Response(status=204) | ||||
|  | ||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||
|         for backend in list(self.filter_backends): | ||||
|             if backend == ObjectPermissionsFilter: | ||||
|                 continue | ||||
|             queryset = backend().filter_queryset(self.request, queryset, self) | ||||
|         return queryset | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|         if self.request.user.has_perm("authentik_core.view_user"): | ||||
|             return self._filter_queryset_for_list(queryset) | ||||
|         return super().filter_queryset(queryset) | ||||
|  | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             200: inline_serializer( | ||||
|  | ||||
| @ -1,45 +0,0 @@ | ||||
| # Generated by Django 4.2.6 on 2023-10-11 13:37 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0031_alter_user_type"), | ||||
|         ("authentik_rbac", "0001_initial"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterModelOptions( | ||||
|             name="group", | ||||
|             options={"verbose_name": "Group", "verbose_name_plural": "Groups"}, | ||||
|         ), | ||||
|         migrations.AlterModelOptions( | ||||
|             name="token", | ||||
|             options={ | ||||
|                 "permissions": [("view_token_key", "View token's key")], | ||||
|                 "verbose_name": "Token", | ||||
|                 "verbose_name_plural": "Tokens", | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AlterModelOptions( | ||||
|             name="user", | ||||
|             options={ | ||||
|                 "permissions": [ | ||||
|                     ("reset_user_password", "Reset Password"), | ||||
|                     ("impersonate", "Can impersonate other users"), | ||||
|                     ("assign_user_permissions", "Can assign permissions to users"), | ||||
|                     ("unassign_user_permissions", "Can unassign permissions from users"), | ||||
|                 ], | ||||
|                 "verbose_name": "User", | ||||
|                 "verbose_name_plural": "Users", | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="group", | ||||
|             name="roles", | ||||
|             field=models.ManyToManyField( | ||||
|                 blank=True, related_name="ak_groups", to="authentik_rbac.role" | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										41
									
								
								authentik/core/migrations/0032_groupsourceconnection.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								authentik/core/migrations/0032_groupsourceconnection.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,41 @@ | ||||
| # Generated by Django 4.2.5 on 2023-09-27 10:44 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0031_alter_user_type"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="GroupSourceConnection", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.AutoField( | ||||
|                         auto_created=True, primary_key=True, serialize=False, verbose_name="ID" | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("created", models.DateTimeField(auto_now_add=True)), | ||||
|                 ("last_updated", models.DateTimeField(auto_now=True)), | ||||
|                 ( | ||||
|                     "group", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group" | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "source", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, to="authentik_core.source" | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "unique_together": {("group", "source")}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -1,7 +1,7 @@ | ||||
| """authentik core models""" | ||||
| from datetime import timedelta | ||||
| from hashlib import sha256 | ||||
| from typing import Any, Optional, Self | ||||
| from typing import Any, Optional | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from deepmerge import always_merger | ||||
| @ -88,8 +88,6 @@ class Group(SerializerModel): | ||||
|         default=False, help_text=_("Users added to this group will be superusers.") | ||||
|     ) | ||||
|  | ||||
|     roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True) | ||||
|  | ||||
|     parent = models.ForeignKey( | ||||
|         "Group", | ||||
|         blank=True, | ||||
| @ -117,38 +115,6 @@ class Group(SerializerModel): | ||||
|         """Recursively check if `user` is member of us, or any parent.""" | ||||
|         return user.all_groups().filter(group_uuid=self.group_uuid).exists() | ||||
|  | ||||
|     def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]: | ||||
|         """Recursively get all groups that have this as parent or are indirectly related""" | ||||
|         direct_groups = [] | ||||
|         if isinstance(self, QuerySet): | ||||
|             direct_groups = list(x for x in self.all().values_list("pk", flat=True).iterator()) | ||||
|         else: | ||||
|             direct_groups = [self.pk] | ||||
|         if len(direct_groups) < 1: | ||||
|             return Group.objects.none() | ||||
|         query = """ | ||||
|         WITH RECURSIVE parents AS ( | ||||
|             SELECT authentik_core_group.*, 0 AS relative_depth | ||||
|             FROM authentik_core_group | ||||
|             WHERE authentik_core_group.group_uuid = ANY(%s) | ||||
|  | ||||
|             UNION ALL | ||||
|  | ||||
|             SELECT authentik_core_group.*, parents.relative_depth + 1 | ||||
|             FROM authentik_core_group, parents | ||||
|             WHERE ( | ||||
|                 authentik_core_group.group_uuid = parents.parent_id and | ||||
|                 parents.relative_depth < 20 | ||||
|             ) | ||||
|         ) | ||||
|         SELECT group_uuid | ||||
|         FROM parents | ||||
|         GROUP BY group_uuid, name | ||||
|         ORDER BY name; | ||||
|         """ | ||||
|         group_pks = [group.pk for group in Group.objects.raw(query, [direct_groups]).iterator()] | ||||
|         return Group.objects.filter(pk__in=group_pks) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"Group {self.name}" | ||||
|  | ||||
| @ -159,8 +125,6 @@ class Group(SerializerModel): | ||||
|                 "parent", | ||||
|             ), | ||||
|         ) | ||||
|         verbose_name = _("Group") | ||||
|         verbose_name_plural = _("Groups") | ||||
|  | ||||
|  | ||||
| class UserManager(DjangoUserManager): | ||||
| @ -196,7 +160,33 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): | ||||
|         """Recursively get all groups this user is a member of. | ||||
|         At least one query is done to get the direct groups of the user, with groups | ||||
|         there are at most 3 queries done""" | ||||
|         return Group.children_recursive(self.ak_groups.all()) | ||||
|         direct_groups = list( | ||||
|             x for x in self.ak_groups.all().values_list("pk", flat=True).iterator() | ||||
|         ) | ||||
|         if len(direct_groups) < 1: | ||||
|             return Group.objects.none() | ||||
|         query = """ | ||||
|         WITH RECURSIVE parents AS ( | ||||
|             SELECT authentik_core_group.*, 0 AS relative_depth | ||||
|             FROM authentik_core_group | ||||
|             WHERE authentik_core_group.group_uuid = ANY(%s) | ||||
|  | ||||
|             UNION ALL | ||||
|  | ||||
|             SELECT authentik_core_group.*, parents.relative_depth + 1 | ||||
|             FROM authentik_core_group, parents | ||||
|             WHERE ( | ||||
|                 authentik_core_group.group_uuid = parents.parent_id and | ||||
|                 parents.relative_depth < 20 | ||||
|             ) | ||||
|         ) | ||||
|         SELECT group_uuid | ||||
|         FROM parents | ||||
|         GROUP BY group_uuid, name | ||||
|         ORDER BY name; | ||||
|         """ | ||||
|         group_pks = [group.pk for group in Group.objects.raw(query, [direct_groups]).iterator()] | ||||
|         return Group.objects.filter(pk__in=group_pks) | ||||
|  | ||||
|     def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]: | ||||
|         """Get a dictionary containing the attributes from all groups the user belongs to, | ||||
| @ -271,14 +261,12 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): | ||||
|         return get_avatar(self) | ||||
|  | ||||
|     class Meta: | ||||
|         permissions = ( | ||||
|             ("reset_user_password", "Reset Password"), | ||||
|             ("impersonate", "Can impersonate other users"), | ||||
|         ) | ||||
|         verbose_name = _("User") | ||||
|         verbose_name_plural = _("Users") | ||||
|         permissions = [ | ||||
|             ("reset_user_password", _("Reset Password")), | ||||
|             ("impersonate", _("Can impersonate other users")), | ||||
|             ("assign_user_permissions", _("Can assign permissions to users")), | ||||
|             ("unassign_user_permissions", _("Can unassign permissions from users")), | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class Provider(SerializerModel): | ||||
| @ -587,6 +575,23 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel): | ||||
|         unique_together = (("user", "source"),) | ||||
|  | ||||
|  | ||||
| class GroupSourceConnection(SerializerModel, CreatedUpdatedModel): | ||||
|     """Connection between Group and Source.""" | ||||
|  | ||||
|     group = models.ForeignKey(Group, on_delete=models.CASCADE) | ||||
|     source = models.ForeignKey(Source, on_delete=models.CASCADE) | ||||
|  | ||||
|     objects = InheritanceManager() | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|         """Get serializer for this model""" | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = (("group", "source"),) | ||||
|  | ||||
|  | ||||
| class ExpiringModel(models.Model): | ||||
|     """Base Model which can expire, and is automatically cleaned up.""" | ||||
|  | ||||
| @ -687,7 +692,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel): | ||||
|             models.Index(fields=["identifier"]), | ||||
|             models.Index(fields=["key"]), | ||||
|         ] | ||||
|         permissions = [("view_token_key", _("View token's key"))] | ||||
|         permissions = (("view_token_key", "View token's key"),) | ||||
|  | ||||
|  | ||||
| class PropertyMapping(SerializerModel, ManagedModel): | ||||
|  | ||||
| @ -7,7 +7,6 @@ from django.db.models import Model | ||||
| from django.db.models.signals import post_save, pre_delete, pre_save | ||||
| from django.dispatch import receiver | ||||
| from django.http.request import HttpRequest | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider, User | ||||
|  | ||||
| @ -16,8 +15,6 @@ password_changed = Signal() | ||||
| # Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage | ||||
| login_failed = Signal() | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @receiver(post_save, sender=Application) | ||||
| def post_save_application(sender: type[Model], instance, created: bool, **_): | ||||
|  | ||||
| @ -97,7 +97,6 @@ class SourceFlowManager: | ||||
|         if self.request.user.is_authenticated: | ||||
|             new_connection.user = self.request.user | ||||
|             new_connection = self.update_connection(new_connection, **kwargs) | ||||
|             # pylint: disable=no-member | ||||
|             new_connection.save() | ||||
|             return Action.LINK, new_connection | ||||
|  | ||||
|  | ||||
| @ -16,8 +16,8 @@ You've logged out of {{ application }}. | ||||
| {% block card %} | ||||
| <form method="POST" class="pf-c-form"> | ||||
|     <p> | ||||
|         {% blocktrans with application=application.name branding_title=tenant.branding_title %} | ||||
|             You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account. | ||||
|         {% blocktrans with application=application.name %} | ||||
|             You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your authentik account. | ||||
|         {% endblocktrans %} | ||||
|     </p> | ||||
|  | ||||
|  | ||||
| @ -21,9 +21,10 @@ def create_test_flow( | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def create_test_user(name: Optional[str] = None, **kwargs) -> User: | ||||
|     """Generate a test user""" | ||||
| def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User: | ||||
|     """Generate a test-admin user""" | ||||
|     uid = generate_id(20) if not name else name | ||||
|     group = Group.objects.create(name=uid, is_superuser=True) | ||||
|     kwargs.setdefault("email", f"{uid}@goauthentik.io") | ||||
|     kwargs.setdefault("username", uid) | ||||
|     user: User = User.objects.create( | ||||
| @ -32,13 +33,6 @@ def create_test_user(name: Optional[str] = None, **kwargs) -> User: | ||||
|     ) | ||||
|     user.set_password(uid) | ||||
|     user.save() | ||||
|     return user | ||||
|  | ||||
|  | ||||
| def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User: | ||||
|     """Generate a test-admin user""" | ||||
|     user = create_test_user(name, **kwargs) | ||||
|     group = Group.objects.create(name=user.name or name, is_superuser=True) | ||||
|     group.users.add(user) | ||||
|     return user | ||||
|  | ||||
|  | ||||
| @ -1,10 +1,13 @@ | ||||
| """authentik crypto app config""" | ||||
| from datetime import datetime | ||||
| from typing import Optional | ||||
| from typing import TYPE_CHECKING, Optional | ||||
|  | ||||
| from authentik.blueprints.apps import ManagedAppConfig | ||||
| from authentik.lib.generators import generate_id | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.crypto.models import CertificateKeyPair | ||||
|  | ||||
| MANAGED_KEY = "goauthentik.io/crypto/jwt-managed" | ||||
|  | ||||
|  | ||||
| @ -20,37 +23,33 @@ class AuthentikCryptoConfig(ManagedAppConfig): | ||||
|         """Load crypto tasks""" | ||||
|         self.import_module("authentik.crypto.tasks") | ||||
|  | ||||
|     def _create_update_cert(self): | ||||
|     def _create_update_cert(self, cert: Optional["CertificateKeyPair"] = None): | ||||
|         from authentik.crypto.builder import CertificateBuilder | ||||
|         from authentik.crypto.models import CertificateKeyPair | ||||
|  | ||||
|         common_name = "authentik Internal JWT Certificate" | ||||
|         builder = CertificateBuilder(common_name) | ||||
|         builder = CertificateBuilder("authentik Internal JWT Certificate") | ||||
|         builder.build( | ||||
|             subject_alt_names=["goauthentik.io"], | ||||
|             validity_days=360, | ||||
|         ) | ||||
|         CertificateKeyPair.objects.update_or_create( | ||||
|             managed=MANAGED_KEY, | ||||
|             defaults={ | ||||
|                 "name": common_name, | ||||
|                 "certificate_data": builder.certificate, | ||||
|                 "key_data": builder.private_key, | ||||
|             }, | ||||
|         ) | ||||
|         if not cert: | ||||
|             cert = CertificateKeyPair() | ||||
|         builder.cert = cert | ||||
|         builder.cert.managed = MANAGED_KEY | ||||
|         builder.save() | ||||
|  | ||||
|     def reconcile_managed_jwt_cert(self): | ||||
|         """Ensure managed JWT certificate""" | ||||
|         from authentik.crypto.models import CertificateKeyPair | ||||
|  | ||||
|         cert: Optional[CertificateKeyPair] = CertificateKeyPair.objects.filter( | ||||
|             managed=MANAGED_KEY | ||||
|         ).first() | ||||
|         now = datetime.now() | ||||
|         if not cert or ( | ||||
|             now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after | ||||
|         ): | ||||
|         certs = CertificateKeyPair.objects.filter(managed=MANAGED_KEY) | ||||
|         if not certs.exists(): | ||||
|             self._create_update_cert() | ||||
|             return | ||||
|         cert: CertificateKeyPair = certs.first() | ||||
|         now = datetime.now() | ||||
|         if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after: | ||||
|             self._create_update_cert(cert) | ||||
|  | ||||
|     def reconcile_self_signed(self): | ||||
|         """Create self-signed keypair""" | ||||
| @ -62,10 +61,4 @@ class AuthentikCryptoConfig(ManagedAppConfig): | ||||
|             return | ||||
|         builder = CertificateBuilder(name) | ||||
|         builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"]) | ||||
|         CertificateKeyPair.objects.get_or_create( | ||||
|             name=name, | ||||
|             defaults={ | ||||
|                 "certificate_data": builder.certificate, | ||||
|                 "key_data": builder.private_key, | ||||
|             }, | ||||
|         ) | ||||
|         builder.save() | ||||
|  | ||||
| @ -6,7 +6,7 @@ from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import extend_schema, inline_serializer | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.permissions import IsAdminUser, IsAuthenticated | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| @ -84,7 +84,7 @@ class LicenseViewSet(UsedByMixin, ModelViewSet): | ||||
|             200: inline_serializer("InstallIDSerializer", {"install_id": CharField(required=True)}), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=False, methods=["GET"]) | ||||
|     @action(detail=False, methods=["GET"], permission_classes=[IsAdminUser]) | ||||
|     def get_install_id(self, request: Request) -> Response: | ||||
|         """Get install_id""" | ||||
|         return Response( | ||||
|  | ||||
| @ -33,8 +33,4 @@ class Migration(migrations.Migration): | ||||
|                 "verbose_name_plural": "License Usage Records", | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AlterModelOptions( | ||||
|             name="license", | ||||
|             options={"verbose_name": "License", "verbose_name_plural": "Licenses"}, | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
| @ -19,10 +19,8 @@ from django.utils.translation import gettext as _ | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
| from jwt import PyJWTError, decode, get_unverified_header | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| from authentik.core.models import ExpiringModel, User, UserTypes | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.root.install_id import get_install_id | ||||
|  | ||||
|  | ||||
| @ -136,9 +134,6 @@ class LicenseKey: | ||||
|  | ||||
|     def record_usage(self): | ||||
|         """Capture the current validity status and metrics and save them""" | ||||
|         threshold = now() - timedelta(hours=8) | ||||
|         if LicenseUsage.objects.filter(record_date__gte=threshold).exists(): | ||||
|             return | ||||
|         LicenseUsage.objects.create( | ||||
|             user_count=self.get_default_user_count(), | ||||
|             external_user_count=self.get_external_user_count(), | ||||
| @ -156,7 +151,7 @@ class LicenseKey: | ||||
|         return usage.record_date | ||||
|  | ||||
|  | ||||
| class License(SerializerModel): | ||||
| class License(models.Model): | ||||
|     """An authentik enterprise license""" | ||||
|  | ||||
|     license_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||
| @ -167,12 +162,6 @@ class License(SerializerModel): | ||||
|     internal_users = models.BigIntegerField() | ||||
|     external_users = models.BigIntegerField() | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.enterprise.api import LicenseSerializer | ||||
|  | ||||
|         return LicenseSerializer | ||||
|  | ||||
|     @property | ||||
|     def status(self) -> LicenseKey: | ||||
|         """Get parsed license status""" | ||||
| @ -180,8 +169,6 @@ class License(SerializerModel): | ||||
|  | ||||
|     class Meta: | ||||
|         indexes = (HashIndex(fields=("key",)),) | ||||
|         verbose_name = _("License") | ||||
|         verbose_name_plural = _("Licenses") | ||||
|  | ||||
|  | ||||
| def usage_expiry(): | ||||
|  | ||||
| @ -6,7 +6,7 @@ from authentik.lib.utils.time import fqdn_rand | ||||
| CELERY_BEAT_SCHEDULE = { | ||||
|     "enterprise_calculate_license": { | ||||
|         "task": "authentik.enterprise.tasks.calculate_license", | ||||
|         "schedule": crontab(minute=fqdn_rand("calculate_license"), hour="*/2"), | ||||
|         "schedule": crontab(minute=fqdn_rand("calculate_license"), hour="*/8"), | ||||
|         "options": {"queue": "authentik_scheduled"}, | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -6,4 +6,5 @@ from authentik.root.celery import CELERY_APP | ||||
| @CELERY_APP.task() | ||||
| def calculate_license(): | ||||
|     """Calculate licensing status""" | ||||
|     LicenseKey.get_total().record_usage() | ||||
|     total = LicenseKey.get_total() | ||||
|     total.record_usage() | ||||
|  | ||||
| @ -436,39 +436,32 @@ class NotificationTransport(SerializerModel): | ||||
|  | ||||
|     def send_email(self, notification: "Notification") -> list[str]: | ||||
|         """Send notification via global email configuration""" | ||||
|         subject_prefix = "authentik Notification: " | ||||
|         context = { | ||||
|             "key_value": { | ||||
|         subject = "authentik Notification: " | ||||
|         key_value = { | ||||
|             "user_email": notification.user.email, | ||||
|             "user_username": notification.user.username, | ||||
|             }, | ||||
|             "body": notification.body, | ||||
|             "title": "", | ||||
|         } | ||||
|         if notification.event and notification.event.user: | ||||
|             context["key_value"]["event_user_email"] = notification.event.user.get("email", None) | ||||
|             context["key_value"]["event_user_username"] = notification.event.user.get( | ||||
|                 "username", None | ||||
|             ) | ||||
|             key_value["event_user_email"] = notification.event.user.get("email", None) | ||||
|             key_value["event_user_username"] = notification.event.user.get("username", None) | ||||
|         if notification.event: | ||||
|             context["title"] += notification.event.action | ||||
|             subject += notification.event.action | ||||
|             for key, value in notification.event.context.items(): | ||||
|                 if not isinstance(value, str): | ||||
|                     continue | ||||
|                 context["key_value"][key] = value | ||||
|                 key_value[key] = value | ||||
|         else: | ||||
|             context["title"] += notification.body[:75] | ||||
|         # TODO: improve permission check | ||||
|         if notification.user.is_superuser: | ||||
|             context["source"] = { | ||||
|                 "from": self.name, | ||||
|             } | ||||
|             subject += notification.body[:75] | ||||
|         mail = TemplateEmailMessage( | ||||
|             subject=subject_prefix + context["title"], | ||||
|             subject=subject, | ||||
|             to=[notification.user.email], | ||||
|             language=notification.user.locale(), | ||||
|             template_name="email/event_notification.html", | ||||
|             template_context=context, | ||||
|             template_name="email/generic.html", | ||||
|             template_context={ | ||||
|                 "title": subject, | ||||
|                 "body": notification.body, | ||||
|                 "key_value": key_value, | ||||
|             }, | ||||
|         ) | ||||
|         # Email is sent directly here, as the call to send() should have been from a task. | ||||
|         try: | ||||
|  | ||||
| @ -206,8 +206,8 @@ def prefill_task(func): | ||||
|         task_call_module=func.__module__, | ||||
|         task_call_func=func.__name__, | ||||
|         # We don't have real values for these attributes but they cannot be null | ||||
|         start_timestamp=0, | ||||
|         finish_timestamp=0, | ||||
|         start_timestamp=default_timer(), | ||||
|         finish_timestamp=default_timer(), | ||||
|         finish_time=datetime.now(), | ||||
|     ).save(86400) | ||||
|     LOGGER.debug("prefilled task", task_name=func.__name__) | ||||
|  | ||||
| @ -2,7 +2,6 @@ | ||||
| import re | ||||
| from copy import copy | ||||
| from dataclasses import asdict, is_dataclass | ||||
| from datetime import date, datetime, time, timedelta | ||||
| from enum import Enum | ||||
| from pathlib import Path | ||||
| from types import GeneratorType | ||||
| @ -14,7 +13,6 @@ from django.core.handlers.wsgi import WSGIRequest | ||||
| from django.db import models | ||||
| from django.db.models.base import Model | ||||
| from django.http.request import HttpRequest | ||||
| from django.utils import timezone | ||||
| from django.views.debug import SafeExceptionReporterFilter | ||||
| from geoip2.models import City | ||||
| from guardian.utils import get_anonymous_user | ||||
| @ -86,7 +84,7 @@ def get_user(user: User, original_user: Optional[User] = None) -> dict[str, Any] | ||||
|     return user_data | ||||
|  | ||||
|  | ||||
| # pylint: disable=too-many-return-statements,too-many-branches | ||||
| # pylint: disable=too-many-return-statements | ||||
| def sanitize_item(value: Any) -> Any: | ||||
|     """Sanitize a single item, ensure it is JSON parsable""" | ||||
|     if is_dataclass(value): | ||||
| @ -136,23 +134,6 @@ def sanitize_item(value: Any) -> Any: | ||||
|             "type": value.__name__, | ||||
|             "module": value.__module__, | ||||
|         } | ||||
|     # See | ||||
|     # https://github.com/encode/django-rest-framework/blob/master/rest_framework/utils/encoders.py | ||||
|     # For Date Time string spec, see ECMA 262 | ||||
|     # https://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 | ||||
|     if isinstance(value, datetime): | ||||
|         representation = value.isoformat() | ||||
|         if representation.endswith("+00:00"): | ||||
|             representation = representation[:-6] + "Z" | ||||
|         return representation | ||||
|     if isinstance(value, date): | ||||
|         return value.isoformat() | ||||
|     if isinstance(value, time): | ||||
|         if timezone and timezone.is_aware(value): | ||||
|             raise ValueError("JSON can't represent timezone-aware times.") | ||||
|         return value.isoformat() | ||||
|     if isinstance(value, timedelta): | ||||
|         return str(value.total_seconds()) | ||||
|     return value | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -45,4 +45,3 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet): | ||||
|     serializer_class = FlowStageBindingSerializer | ||||
|     filterset_fields = "__all__" | ||||
|     search_fields = ["stage__name"] | ||||
|     ordering = ["order"] | ||||
|  | ||||
| @ -8,11 +8,6 @@ GAUGE_FLOWS_CACHED = Gauge( | ||||
|     "authentik_flows_cached", | ||||
|     "Cached flows", | ||||
| ) | ||||
| HIST_FLOW_EXECUTION_STAGE_TIME = Histogram( | ||||
|     "authentik_flows_execution_stage_time", | ||||
|     "Duration each stage took to execute.", | ||||
|     ["stage_type", "method"], | ||||
| ) | ||||
| HIST_FLOWS_PLAN_TIME = Histogram( | ||||
|     "authentik_flows_plan_time", | ||||
|     "Duration to build a plan for a flow", | ||||
|  | ||||
| @ -132,6 +132,13 @@ class PermissionDict(TypedDict): | ||||
|     name: str | ||||
|  | ||||
|  | ||||
| class PermissionSerializer(PassiveSerializer): | ||||
|     """Permission used for consent""" | ||||
|  | ||||
|     name = CharField(allow_blank=True) | ||||
|     id = CharField() | ||||
|  | ||||
|  | ||||
| class ChallengeResponse(PassiveSerializer): | ||||
|     """Base class for all challenge responses""" | ||||
|  | ||||
|  | ||||
| @ -1,25 +0,0 @@ | ||||
| # Generated by Django 4.2.6 on 2023-10-10 17:18 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterModelOptions( | ||||
|             name="flow", | ||||
|             options={ | ||||
|                 "permissions": [ | ||||
|                     ("export_flow", "Can export a Flow"), | ||||
|                     ("inspect_flow", "Can inspect a Flow's execution"), | ||||
|                     ("view_flow_cache", "View Flow's cache metrics"), | ||||
|                     ("clear_flow_cache", "Clear Flow's cache metrics"), | ||||
|                 ], | ||||
|                 "verbose_name": "Flow", | ||||
|                 "verbose_name_plural": "Flows", | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -1,34 +0,0 @@ | ||||
| # Generated by Django 4.2.6 on 2023-10-28 14:24 | ||||
|  | ||||
| from django.apps.registry import Apps | ||||
| from django.db import migrations | ||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||
|  | ||||
|  | ||||
| def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
|     Flow = apps.get_model("authentik_flows", "Flow") | ||||
|     User = apps.get_model("authentik_core", "User") | ||||
|  | ||||
|     db_alias = schema_editor.connection.alias | ||||
|  | ||||
|     users = User.objects.using(db_alias).exclude(username="akadmin") | ||||
|     try: | ||||
|         users = users.exclude(pk=get_anonymous_user().pk) | ||||
|     # pylint: disable=broad-except | ||||
|     except Exception:  # nosec | ||||
|         pass | ||||
|  | ||||
|     if users.exists(): | ||||
|         Flow.objects.filter(slug="initial-setup").update(authentication="require_superuser") | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_flows", "0026_alter_flow_options"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(set_oobe_flow_authentication), | ||||
|     ] | ||||
| @ -194,10 +194,9 @@ class Flow(SerializerModel, PolicyBindingModel): | ||||
|         verbose_name_plural = _("Flows") | ||||
|  | ||||
|         permissions = [ | ||||
|             ("export_flow", _("Can export a Flow")), | ||||
|             ("inspect_flow", _("Can inspect a Flow's execution")), | ||||
|             ("view_flow_cache", _("View Flow's cache metrics")), | ||||
|             ("clear_flow_cache", _("Clear Flow's cache metrics")), | ||||
|             ("export_flow", "Can export a Flow"), | ||||
|             ("view_flow_cache", "View Flow's cache metrics"), | ||||
|             ("clear_flow_cache", "Clear Flow's cache metrics"), | ||||
|         ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -24,7 +24,6 @@ from structlog.stdlib import BoundLogger, get_logger | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.events.models import Event, EventAction, cleanse_dict | ||||
| from authentik.flows.apps import HIST_FLOW_EXECUTION_STAGE_TIME | ||||
| from authentik.flows.challenge import ( | ||||
|     Challenge, | ||||
|     ChallengeResponse, | ||||
| @ -267,21 +266,17 @@ class FlowExecutorView(APIView): | ||||
|     ) | ||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
|         """Get the next pending challenge from the currently active flow.""" | ||||
|         class_path = class_to_path(self.current_stage_view.__class__) | ||||
|         self._logger.debug( | ||||
|             "f(exec): Passing GET", | ||||
|             view_class=class_path, | ||||
|             view_class=class_to_path(self.current_stage_view.__class__), | ||||
|             stage=self.current_stage, | ||||
|         ) | ||||
|         try: | ||||
|             with Hub.current.start_span( | ||||
|                 op="authentik.flow.executor.stage", | ||||
|                 description=class_path, | ||||
|             ) as span, HIST_FLOW_EXECUTION_STAGE_TIME.labels( | ||||
|                 method=request.method.upper(), | ||||
|                 stage_type=class_path, | ||||
|             ).time(): | ||||
|                 span.set_data("Method", request.method.upper()) | ||||
|                 description=class_to_path(self.current_stage_view.__class__), | ||||
|             ) as span: | ||||
|                 span.set_data("Method", "GET") | ||||
|                 span.set_data("authentik Stage", self.current_stage_view) | ||||
|                 span.set_data("authentik Flow", self.flow.slug) | ||||
|                 stage_response = self.current_stage_view.dispatch(request) | ||||
| @ -315,21 +310,17 @@ class FlowExecutorView(APIView): | ||||
|     ) | ||||
|     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
|         """Solve the previously retrieved challenge and advanced to the next stage.""" | ||||
|         class_path = class_to_path(self.current_stage_view.__class__) | ||||
|         self._logger.debug( | ||||
|             "f(exec): Passing POST", | ||||
|             view_class=class_path, | ||||
|             view_class=class_to_path(self.current_stage_view.__class__), | ||||
|             stage=self.current_stage, | ||||
|         ) | ||||
|         try: | ||||
|             with Hub.current.start_span( | ||||
|                 op="authentik.flow.executor.stage", | ||||
|                 description=class_path, | ||||
|             ) as span, HIST_FLOW_EXECUTION_STAGE_TIME.labels( | ||||
|                 method=request.method.upper(), | ||||
|                 stage_type=class_path, | ||||
|             ).time(): | ||||
|                 span.set_data("Method", request.method.upper()) | ||||
|                 description=class_to_path(self.current_stage_view.__class__), | ||||
|             ) as span: | ||||
|                 span.set_data("Method", "POST") | ||||
|                 span.set_data("authentik Stage", self.current_stage_view) | ||||
|                 span.set_data("authentik Flow", self.flow.slug) | ||||
|                 stage_response = self.current_stage_view.dispatch(request) | ||||
|  | ||||
| @ -3,7 +3,6 @@ from hashlib import sha256 | ||||
| from typing import Any | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.http import Http404 | ||||
| from django.http.request import HttpRequest | ||||
| from django.http.response import HttpResponse | ||||
| from django.shortcuts import get_object_or_404 | ||||
| @ -12,6 +11,7 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||
| from rest_framework.fields import BooleanField, ListField, SerializerMethodField | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
| @ -68,19 +68,21 @@ class FlowInspectionSerializer(PassiveSerializer): | ||||
| class FlowInspectorView(APIView): | ||||
|     """Flow inspector API""" | ||||
|  | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     flow: Flow | ||||
|     _logger: BoundLogger | ||||
|     permission_classes = [] | ||||
|  | ||||
|     def check_permissions(self, request): | ||||
|         """Always allow access when in debug mode""" | ||||
|         if settings.DEBUG: | ||||
|             return None | ||||
|         return super().check_permissions(request) | ||||
|  | ||||
|     def setup(self, request: HttpRequest, flow_slug: str): | ||||
|         super().setup(request, flow_slug=flow_slug) | ||||
|         self._logger = get_logger().bind(flow_slug=flow_slug) | ||||
|         self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) | ||||
|         if settings.DEBUG: | ||||
|             return | ||||
|         if request.user.has_perm("authentik_flow.inspect_flow", self.flow): | ||||
|             return | ||||
|         raise Http404 | ||||
|         self._logger = get_logger().bind(flow_slug=flow_slug) | ||||
|  | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|  | ||||
| @ -24,7 +24,7 @@ ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local") | ||||
|  | ||||
|  | ||||
| def get_path_from_dict(root: dict, path: str, sep=".", default=None) -> Any: | ||||
|     """Recursively walk through `root`, checking each part of `path` separated by `sep`. | ||||
|     """Recursively walk through `root`, checking each part of `path` split by `sep`. | ||||
|     If at any point a dict does not exist, return default""" | ||||
|     for comp in path.split(sep): | ||||
|         if root and comp in root: | ||||
| @ -34,19 +34,7 @@ def get_path_from_dict(root: dict, path: str, sep=".", default=None) -> Any: | ||||
|     return root | ||||
|  | ||||
|  | ||||
| def set_path_in_dict(root: dict, path: str, value: Any, sep="."): | ||||
|     """Recursively walk through `root`, checking each part of `path` separated by `sep` | ||||
|     and setting the last value to `value`""" | ||||
|     # Walk each component of the path | ||||
|     path_parts = path.split(sep) | ||||
|     for comp in path_parts[:-1]: | ||||
|         if comp not in root: | ||||
|             root[comp] = {} | ||||
|         root = root.get(comp, {}) | ||||
|     root[path_parts[-1]] = value | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| @dataclass | ||||
| class Attr: | ||||
|     """Single configuration attribute""" | ||||
|  | ||||
| @ -67,10 +55,6 @@ class Attr: | ||||
|     # to the config file containing this change or the file containing this value | ||||
|     source: Optional[str] = field(default=None) | ||||
|  | ||||
|     def __post_init__(self): | ||||
|         if isinstance(self.value, Attr): | ||||
|             raise RuntimeError(f"config Attr with nested Attr for source {self.source}") | ||||
|  | ||||
|  | ||||
| class AttrEncoder(JSONEncoder): | ||||
|     """JSON encoder that can deal with `Attr` classes""" | ||||
| @ -243,7 +227,15 @@ class ConfigLoader: | ||||
|  | ||||
|     def set(self, path: str, value: Any, sep="."): | ||||
|         """Set value using same syntax as get()""" | ||||
|         set_path_in_dict(self.raw, path, Attr(value), sep=sep) | ||||
|         # Walk sub_dicts before parsing path | ||||
|         root = self.raw | ||||
|         # Walk each component of the path | ||||
|         path_parts = path.split(sep) | ||||
|         for comp in path_parts[:-1]: | ||||
|             if comp not in root: | ||||
|                 root[comp] = {} | ||||
|             root = root.get(comp, {}) | ||||
|         root[path_parts[-1]] = Attr(value) | ||||
|  | ||||
|  | ||||
| CONFIG = ConfigLoader() | ||||
|  | ||||
| @ -141,7 +141,7 @@ class BaseEvaluator: | ||||
|         """Create event with supplied data and try to extract as much relevant data | ||||
|         from the context""" | ||||
|         context = self._context.copy() | ||||
|         # If the result was a complex variable, we don't want to reuse it | ||||
|         # If the result was a complex variable, we don't want to re-use it | ||||
|         context.pop("result", None) | ||||
|         context.pop("handler", None) | ||||
|         event_kwargs = context | ||||
|  | ||||
| @ -1,32 +0,0 @@ | ||||
| """Serializer validators""" | ||||
| from typing import Optional | ||||
|  | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.serializers import Serializer | ||||
| from rest_framework.utils.representation import smart_repr | ||||
|  | ||||
|  | ||||
| class RequiredTogetherValidator: | ||||
|     """Serializer-level validator that ensures all fields in `fields` are only | ||||
|     used together""" | ||||
|  | ||||
|     fields: list[str] | ||||
|     requires_context = True | ||||
|     message = _("The fields {field_names} must be used together.") | ||||
|  | ||||
|     def __init__(self, fields: list[str], message: Optional[str] = None) -> None: | ||||
|         self.fields = fields | ||||
|         self.message = message or self.message | ||||
|  | ||||
|     def __call__(self, attrs: dict, serializer: Serializer): | ||||
|         """Check that if any of the fields in `self.fields` are set, all of them must be set""" | ||||
|         if any(field in attrs for field in self.fields) and not all( | ||||
|             field in attrs for field in self.fields | ||||
|         ): | ||||
|             field_names = ", ".join(self.fields) | ||||
|             message = self.message.format(field_names=field_names) | ||||
|             raise ValidationError(message, code="required") | ||||
|  | ||||
|     def __repr__(self): | ||||
|         return "<%s(fields=%s)>" % (self.__class__.__name__, smart_repr(self.fields)) | ||||
| @ -4,7 +4,6 @@ from datetime import datetime | ||||
| from enum import IntEnum | ||||
| from typing import Any, Optional | ||||
| 
 | ||||
| from asgiref.sync import async_to_sync | ||||
| from channels.exceptions import DenyConnection | ||||
| from dacite.core import from_dict | ||||
| from dacite.data import Data | ||||
| @ -15,8 +14,6 @@ from authentik.core.channels import AuthJsonConsumer | ||||
| from authentik.outposts.apps import GAUGE_OUTPOSTS_CONNECTED, GAUGE_OUTPOSTS_LAST_UPDATE | ||||
| from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState | ||||
| 
 | ||||
| OUTPOST_GROUP = "group_outpost_%(outpost_pk)s" | ||||
| 
 | ||||
| 
 | ||||
| class WebsocketMessageInstruction(IntEnum): | ||||
|     """Commands which can be triggered over Websocket""" | ||||
| @ -30,9 +27,6 @@ class WebsocketMessageInstruction(IntEnum): | ||||
|     # Message sent by us to trigger an Update | ||||
|     TRIGGER_UPDATE = 2 | ||||
| 
 | ||||
|     # Provider specific message | ||||
|     PROVIDER_SPECIFIC = 3 | ||||
| 
 | ||||
| 
 | ||||
| @dataclass(slots=True) | ||||
| class WebsocketMessage: | ||||
| @ -50,6 +44,8 @@ class OutpostConsumer(AuthJsonConsumer): | ||||
| 
 | ||||
|     last_uid: Optional[str] = None | ||||
| 
 | ||||
|     first_msg = False | ||||
| 
 | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.logger = get_logger() | ||||
| @ -72,26 +68,22 @@ class OutpostConsumer(AuthJsonConsumer): | ||||
|             raise DenyConnection() | ||||
|         self.outpost = outpost | ||||
|         self.last_uid = self.channel_name | ||||
|         async_to_sync(self.channel_layer.group_add)( | ||||
|             OUTPOST_GROUP % {"outpost_pk": str(self.outpost.pk)}, self.channel_name | ||||
|         ) | ||||
|         GAUGE_OUTPOSTS_CONNECTED.labels( | ||||
|             outpost=self.outpost.name, | ||||
|             uid=self.last_uid, | ||||
|             expected=self.outpost.config.kubernetes_replicas, | ||||
|         ).inc() | ||||
| 
 | ||||
|     def disconnect(self, code): | ||||
|         if self.outpost: | ||||
|             async_to_sync(self.channel_layer.group_discard)( | ||||
|                 OUTPOST_GROUP % {"outpost_pk": str(self.outpost.pk)}, self.channel_name | ||||
|             ) | ||||
|         if self.outpost and self.last_uid: | ||||
|             state = OutpostState.for_instance_uid(self.outpost, self.last_uid) | ||||
|             if self.channel_name in state.channel_ids: | ||||
|                 state.channel_ids.remove(self.channel_name) | ||||
|                 state.save() | ||||
|             GAUGE_OUTPOSTS_CONNECTED.labels( | ||||
|                 outpost=self.outpost.name, | ||||
|                 uid=self.last_uid, | ||||
|                 expected=self.outpost.config.kubernetes_replicas, | ||||
|             ).dec() | ||||
|         self.logger.debug( | ||||
|             "removed outpost instance from cache", | ||||
|             instance_uuid=self.last_uid, | ||||
|         ) | ||||
| 
 | ||||
|     def receive_json(self, content: Data): | ||||
|         msg = from_dict(WebsocketMessage, content) | ||||
| @ -102,13 +94,26 @@ class OutpostConsumer(AuthJsonConsumer): | ||||
|             raise DenyConnection() | ||||
| 
 | ||||
|         state = OutpostState.for_instance_uid(self.outpost, uid) | ||||
|         if self.channel_name not in state.channel_ids: | ||||
|             state.channel_ids.append(self.channel_name) | ||||
|         state.last_seen = datetime.now() | ||||
|         state.hostname = msg.args.pop("hostname", "") | ||||
|         state.hostname = msg.args.get("hostname", "") | ||||
| 
 | ||||
|         if not self.first_msg: | ||||
|             GAUGE_OUTPOSTS_CONNECTED.labels( | ||||
|                 outpost=self.outpost.name, | ||||
|                 uid=self.last_uid, | ||||
|                 expected=self.outpost.config.kubernetes_replicas, | ||||
|             ).inc() | ||||
|             self.logger.debug( | ||||
|                 "added outpost instance to cache", | ||||
|                 instance_uuid=self.last_uid, | ||||
|             ) | ||||
|             self.first_msg = True | ||||
| 
 | ||||
|         if msg.instruction == WebsocketMessageInstruction.HELLO: | ||||
|             state.version = msg.args.pop("version", None) | ||||
|             state.build_hash = msg.args.pop("buildHash", "") | ||||
|             state.args = msg.args | ||||
|             state.version = msg.args.get("version", None) | ||||
|             state.build_hash = msg.args.get("buildHash", "") | ||||
|         elif msg.instruction == WebsocketMessageInstruction.ACK: | ||||
|             return | ||||
|         GAUGE_OUTPOSTS_LAST_UPDATE.labels( | ||||
| @ -126,14 +131,3 @@ class OutpostConsumer(AuthJsonConsumer): | ||||
|         self.send_json( | ||||
|             asdict(WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE)) | ||||
|         ) | ||||
| 
 | ||||
|     def event_provider_specific(self, event): | ||||
|         """Event handler which can be called by provider-specific | ||||
|         implementations to send specific messages to the outpost""" | ||||
|         self.send_json( | ||||
|             asdict( | ||||
|                 WebsocketMessage( | ||||
|                     instruction=WebsocketMessageInstruction.PROVIDER_SPECIFIC, args=event | ||||
|                 ) | ||||
|             ) | ||||
|         ) | ||||
| @ -28,8 +28,4 @@ class Migration(migrations.Migration): | ||||
|                 verbose_name="Managed by authentik", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterModelOptions( | ||||
|             name="outpost", | ||||
|             options={"verbose_name": "Outpost", "verbose_name_plural": "Outposts"}, | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
| @ -380,7 +380,7 @@ class Outpost(SerializerModel, ManagedModel): | ||||
|                 managed=managed, | ||||
|             ) | ||||
|         except IntegrityError: | ||||
|             # Integrity error happens mostly when managed is reused | ||||
|             # Integrity error happens mostly when managed is re-used | ||||
|             Token.objects.filter(managed=managed).delete() | ||||
|             Token.objects.filter(identifier=self.token_identifier).delete() | ||||
|             return self.token | ||||
| @ -405,22 +405,18 @@ class Outpost(SerializerModel, ManagedModel): | ||||
|     def __str__(self) -> str: | ||||
|         return f"Outpost {self.name}" | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Outpost") | ||||
|         verbose_name_plural = _("Outposts") | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class OutpostState: | ||||
|     """Outpost instance state, last_seen and version""" | ||||
|  | ||||
|     uid: str | ||||
|     channel_ids: list[str] = field(default_factory=list) | ||||
|     last_seen: Optional[datetime] = field(default=None) | ||||
|     version: Optional[str] = field(default=None) | ||||
|     version_should: Version = field(default=OUR_VERSION) | ||||
|     build_hash: str = field(default="") | ||||
|     hostname: str = field(default="") | ||||
|     args: dict = field(default_factory=dict) | ||||
|  | ||||
|     _outpost: Optional[Outpost] = field(default=None) | ||||
|  | ||||
|  | ||||
| @ -5,6 +5,7 @@ from socket import gethostname | ||||
| from typing import Any, Optional | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| import yaml | ||||
| from asgiref.sync import async_to_sync | ||||
| from channels.layers import get_channel_layer | ||||
| from django.core.cache import cache | ||||
| @ -15,7 +16,6 @@ from docker.constants import DEFAULT_UNIX_SOCKET | ||||
| from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME | ||||
| from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION | ||||
| from structlog.stdlib import get_logger | ||||
| from yaml import safe_load | ||||
|  | ||||
| from authentik.events.monitored_tasks import ( | ||||
|     MonitoredTask, | ||||
| @ -25,7 +25,6 @@ from authentik.events.monitored_tasks import ( | ||||
| ) | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.reflection import path_to_class | ||||
| from authentik.outposts.consumer import OUTPOST_GROUP | ||||
| from authentik.outposts.controllers.base import BaseController, ControllerException | ||||
| from authentik.outposts.controllers.docker import DockerClient | ||||
| from authentik.outposts.controllers.kubernetes import KubernetesClient | ||||
| @ -35,6 +34,7 @@ from authentik.outposts.models import ( | ||||
|     Outpost, | ||||
|     OutpostModel, | ||||
|     OutpostServiceConnection, | ||||
|     OutpostState, | ||||
|     OutpostType, | ||||
|     ServiceConnectionInvalid, | ||||
| ) | ||||
| @ -243,9 +243,10 @@ def _outpost_single_update(outpost: Outpost, layer=None): | ||||
|     outpost.build_user_permissions(outpost.user) | ||||
|     if not layer:  # pragma: no cover | ||||
|         layer = get_channel_layer() | ||||
|     group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)} | ||||
|     LOGGER.debug("sending update", channel=group, outpost=outpost) | ||||
|     async_to_sync(layer.group_send)(group, {"type": "event.update"}) | ||||
|     for state in OutpostState.for_outpost(outpost): | ||||
|         for channel in state.channel_ids: | ||||
|             LOGGER.debug("sending update", channel=channel, instance=state.uid, outpost=outpost) | ||||
|             async_to_sync(layer.send)(channel, {"type": "event.update"}) | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task( | ||||
| @ -278,7 +279,7 @@ def outpost_connection_discovery(self: MonitoredTask): | ||||
|             with kubeconfig_path.open("r", encoding="utf8") as _kubeconfig: | ||||
|                 KubernetesServiceConnection.objects.create( | ||||
|                     name=kubeconfig_local_name, | ||||
|                     kubeconfig=safe_load(_kubeconfig), | ||||
|                     kubeconfig=yaml.safe_load(_kubeconfig), | ||||
|                 ) | ||||
|     unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path | ||||
|     socket = Path(unix_socket_path) | ||||
|  | ||||
| @ -7,7 +7,7 @@ from django.test import TransactionTestCase | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.core.tests.utils import create_test_flow | ||||
| from authentik.outposts.consumer import WebsocketMessage, WebsocketMessageInstruction | ||||
| from authentik.outposts.channels import WebsocketMessage, WebsocketMessageInstruction | ||||
| from authentik.outposts.models import Outpost, OutpostType | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
| from authentik.root import websocket | ||||
|  | ||||
| @ -7,7 +7,7 @@ from authentik.outposts.api.service_connections import ( | ||||
|     KubernetesServiceConnectionViewSet, | ||||
|     ServiceConnectionViewSet, | ||||
| ) | ||||
| from authentik.outposts.consumer import OutpostConsumer | ||||
| from authentik.outposts.channels import OutpostConsumer | ||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | ||||
|  | ||||
| websocket_urlpatterns = [ | ||||
|  | ||||
| @ -7,11 +7,7 @@ GAUGE_POLICIES_CACHED = Gauge( | ||||
|     "authentik_policies_cached", | ||||
|     "Cached Policies", | ||||
| ) | ||||
| HIST_POLICIES_ENGINE_TOTAL_TIME = Histogram( | ||||
|     "authentik_policies_engine_time_total_seconds", | ||||
|     "(Total) Duration the policy engine took to evaluate a result.", | ||||
|     ["obj_type", "obj_pk"], | ||||
| ) | ||||
|  | ||||
| HIST_POLICIES_EXECUTION_TIME = Histogram( | ||||
|     "authentik_policies_execution_time", | ||||
|     "Execution times for single policies", | ||||
| @ -21,7 +17,6 @@ HIST_POLICIES_EXECUTION_TIME = Histogram( | ||||
|         "binding_target_name", | ||||
|         "object_pk", | ||||
|         "object_type", | ||||
|         "mode", | ||||
|     ], | ||||
| ) | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """authentik policy engine""" | ||||
| from multiprocessing import Pipe, current_process | ||||
| from multiprocessing.connection import Connection | ||||
| from timeit import default_timer | ||||
| from typing import Iterator, Optional | ||||
|  | ||||
| from django.core.cache import cache | ||||
| @ -11,8 +10,6 @@ from sentry_sdk.tracing import Span | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.policies.apps import HIST_POLICIES_ENGINE_TOTAL_TIME, HIST_POLICIES_EXECUTION_TIME | ||||
| from authentik.policies.exceptions import PolicyEngineException | ||||
| from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode | ||||
| from authentik.policies.process import PolicyProcess, cache_key | ||||
| @ -80,33 +77,6 @@ class PolicyEngine: | ||||
|         if binding.policy is not None and binding.policy.__class__ == Policy: | ||||
|             raise PolicyEngineException(f"Policy '{binding.policy}' is root type") | ||||
|  | ||||
|     def _check_cache(self, binding: PolicyBinding): | ||||
|         if not self.use_cache: | ||||
|             return False | ||||
|         before = default_timer() | ||||
|         key = cache_key(binding, self.request) | ||||
|         cached_policy = cache.get(key, None) | ||||
|         duration = max(default_timer() - before, 0) | ||||
|         if not cached_policy: | ||||
|             return False | ||||
|         self.logger.debug( | ||||
|             "P_ENG: Taking result from cache", | ||||
|             binding=binding, | ||||
|             cache_key=key, | ||||
|             request=self.request, | ||||
|         ) | ||||
|         HIST_POLICIES_EXECUTION_TIME.labels( | ||||
|             binding_order=binding.order, | ||||
|             binding_target_type=binding.target_type, | ||||
|             binding_target_name=binding.target_name, | ||||
|             object_pk=str(self.request.obj.pk), | ||||
|             object_type=class_to_path(self.request.obj.__class__), | ||||
|             mode="cache_retrieve", | ||||
|         ).observe(duration) | ||||
|         # It's a bit silly to time this, but | ||||
|         self.__cached_policies.append(cached_policy) | ||||
|         return True | ||||
|  | ||||
|     def build(self) -> "PolicyEngine": | ||||
|         """Build wrapper which monitors performance""" | ||||
|         with ( | ||||
| @ -114,10 +84,6 @@ class PolicyEngine: | ||||
|                 op="authentik.policy.engine.build", | ||||
|                 description=self.__pbm, | ||||
|             ) as span, | ||||
|             HIST_POLICIES_ENGINE_TOTAL_TIME.labels( | ||||
|                 obj_type=class_to_path(self.__pbm.__class__), | ||||
|                 obj_pk=str(self.__pbm.pk), | ||||
|             ).time(), | ||||
|         ): | ||||
|             span: Span | ||||
|             span.set_data("pbm", self.__pbm) | ||||
| @ -126,7 +92,16 @@ class PolicyEngine: | ||||
|                 self.__expected_result_count += 1 | ||||
|  | ||||
|                 self._check_policy_type(binding) | ||||
|                 if self._check_cache(binding): | ||||
|                 key = cache_key(binding, self.request) | ||||
|                 cached_policy = cache.get(key, None) | ||||
|                 if cached_policy and self.use_cache: | ||||
|                     self.logger.debug( | ||||
|                         "P_ENG: Taking result from cache", | ||||
|                         binding=binding, | ||||
|                         cache_key=key, | ||||
|                         request=self.request, | ||||
|                     ) | ||||
|                     self.__cached_policies.append(cached_policy) | ||||
|                     continue | ||||
|                 self.logger.debug("P_ENG: Evaluating policy", binding=binding, request=self.request) | ||||
|                 our_end, task_end = Pipe(False) | ||||
|  | ||||
| @ -190,8 +190,8 @@ class Policy(SerializerModel, CreatedUpdatedModel): | ||||
|         verbose_name_plural = _("Policies") | ||||
|  | ||||
|         permissions = [ | ||||
|             ("view_policy_cache", _("View Policy's cache metrics")), | ||||
|             ("clear_policy_cache", _("Clear Policy's cache metrics")), | ||||
|             ("view_policy_cache", "View Policy's cache metrics"), | ||||
|             ("clear_policy_cache", "Clear Policy's cache metrics"), | ||||
|         ] | ||||
|  | ||||
|     class PolicyMeta: | ||||
|  | ||||
| @ -11,7 +11,6 @@ from structlog.stdlib import get_logger | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.policies.apps import HIST_POLICIES_EXECUTION_TIME | ||||
| from authentik.policies.exceptions import PolicyException | ||||
| from authentik.policies.models import PolicyBinding | ||||
| @ -129,8 +128,9 @@ class PolicyProcess(PROCESS_CLASS): | ||||
|                 binding_target_type=self.binding.target_type, | ||||
|                 binding_target_name=self.binding.target_name, | ||||
|                 object_pk=str(self.request.obj.pk), | ||||
|                 object_type=class_to_path(self.request.obj.__class__), | ||||
|                 mode="execute_process", | ||||
|                 object_type=( | ||||
|                     f"{self.request.obj._meta.app_label}.{self.request.obj._meta.model_name}" | ||||
|                 ), | ||||
|             ).time(), | ||||
|         ): | ||||
|             span: Span | ||||
|  | ||||
| @ -17,7 +17,7 @@ LOGGER = get_logger() | ||||
| @receiver(monitoring_set) | ||||
| def monitoring_set_policies(sender, **kwargs): | ||||
|     """set policy gauges""" | ||||
|     GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or [])) | ||||
|     GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or [])) | ||||
|  | ||||
|  | ||||
| @receiver(post_save, sender=Policy) | ||||
|  | ||||
| @ -9,7 +9,3 @@ class AuthentikProviderProxyConfig(ManagedAppConfig): | ||||
|     label = "authentik_providers_proxy" | ||||
|     verbose_name = "authentik Providers.Proxy" | ||||
|     default = True | ||||
|  | ||||
|     def reconcile_load_providers_proxy_signals(self): | ||||
|         """Load proxy signals""" | ||||
|         self.import_module("authentik.providers.proxy.signals") | ||||
|  | ||||
| @ -1,20 +0,0 @@ | ||||
| """Proxy provider signals""" | ||||
| from django.contrib.auth.signals import user_logged_out | ||||
| from django.db.models.signals import pre_delete | ||||
| from django.dispatch import receiver | ||||
| from django.http import HttpRequest | ||||
|  | ||||
| from authentik.core.models import AuthenticatedSession, User | ||||
| from authentik.providers.proxy.tasks import proxy_on_logout | ||||
|  | ||||
|  | ||||
| @receiver(user_logged_out) | ||||
| def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_): | ||||
|     """Catch logout by direct logout and forward to proxy providers""" | ||||
|     proxy_on_logout.delay(request.session.session_key) | ||||
|  | ||||
|  | ||||
| @receiver(pre_delete, sender=AuthenticatedSession) | ||||
| def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): | ||||
|     """Catch logout by expiring sessions being deleted""" | ||||
|     proxy_on_logout.delay(instance.session_key) | ||||
| @ -1,10 +1,6 @@ | ||||
| """proxy provider tasks""" | ||||
| from asgiref.sync import async_to_sync | ||||
| from channels.layers import get_channel_layer | ||||
| from django.db import DatabaseError, InternalError, ProgrammingError | ||||
|  | ||||
| from authentik.outposts.consumer import OUTPOST_GROUP | ||||
| from authentik.outposts.models import Outpost, OutpostType | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
| from authentik.root.celery import CELERY_APP | ||||
|  | ||||
| @ -17,19 +13,3 @@ def proxy_set_defaults(): | ||||
|     for provider in ProxyProvider.objects.all(): | ||||
|         provider.set_oauth_defaults() | ||||
|         provider.save() | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task() | ||||
| def proxy_on_logout(session_id: str): | ||||
|     """Update outpost instances connected to a single outpost""" | ||||
|     layer = get_channel_layer() | ||||
|     for outpost in Outpost.objects.filter(type=OutpostType.PROXY): | ||||
|         group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)} | ||||
|         async_to_sync(layer.group_send)( | ||||
|             group, | ||||
|             { | ||||
|                 "type": "event.provider.specific", | ||||
|                 "sub_type": "logout", | ||||
|                 "session_id": session_id, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @ -21,7 +21,6 @@ class RadiusProviderSerializer(ProviderSerializer): | ||||
|             # an admin might have to view it | ||||
|             "shared_secret", | ||||
|             "outpost_set", | ||||
|             "mfa_support", | ||||
|         ] | ||||
|         extra_kwargs = ProviderSerializer.Meta.extra_kwargs | ||||
|  | ||||
| @ -56,7 +55,6 @@ class RadiusOutpostConfigSerializer(ModelSerializer): | ||||
|             "auth_flow_slug", | ||||
|             "client_networks", | ||||
|             "shared_secret", | ||||
|             "mfa_support", | ||||
|         ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,21 +0,0 @@ | ||||
| # Generated by Django 4.2.6 on 2023-10-18 15:09 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_providers_radius", "0001_initial"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="radiusprovider", | ||||
|             name="mfa_support", | ||||
|             field=models.BooleanField( | ||||
|                 default=True, | ||||
|                 help_text="When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.", | ||||
|                 verbose_name="MFA Support", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -27,17 +27,6 @@ class RadiusProvider(OutpostModel, Provider): | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     mfa_support = models.BooleanField( | ||||
|         default=True, | ||||
|         verbose_name="MFA Support", | ||||
|         help_text=_( | ||||
|             "When enabled, code-based multi-factor authentication can be used by appending a " | ||||
|             "semicolon and the TOTP code to the password. This should only be enabled if all " | ||||
|             "users that will bind to this provider have a TOTP device configured, as otherwise " | ||||
|             "a password may incorrectly be rejected if it contains a semicolon." | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def launch_url(self) -> Optional[str]: | ||||
|         """Radius never has a launch URL""" | ||||
|  | ||||
| @ -146,7 +146,6 @@ class SAMLProviderSerializer(ProviderSerializer): | ||||
|             "signing_kp", | ||||
|             "verification_kp", | ||||
|             "sp_binding", | ||||
|             "default_relay_state", | ||||
|             "url_download_metadata", | ||||
|             "url_sso_post", | ||||
|             "url_sso_redirect", | ||||
|  | ||||
| @ -1,21 +0,0 @@ | ||||
| # Generated by Django 4.2.6 on 2023-10-08 20:29 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_providers_saml", "0012_managed"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="samlprovider", | ||||
|             name="default_relay_state", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 default="", | ||||
|                 help_text="Default relay_state value for IDP-initiated logins", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -138,10 +138,6 @@ class SAMLProvider(Provider): | ||||
|         verbose_name=_("Signing Keypair"), | ||||
|     ) | ||||
|  | ||||
|     default_relay_state = models.TextField( | ||||
|         default="", blank=True, help_text=_("Default relay_state value for IDP-initiated logins") | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def launch_url(self) -> Optional[str]: | ||||
|         """Use IDP-Initiated SAML flow as launch URL""" | ||||
|  | ||||
| @ -175,7 +175,4 @@ class AuthNRequestParser: | ||||
|  | ||||
|     def idp_initiated(self) -> AuthNRequest: | ||||
|         """Create IdP Initiated AuthNRequest""" | ||||
|         relay_state = None | ||||
|         if self.provider.default_relay_state != "": | ||||
|             relay_state = self.provider.default_relay_state | ||||
|         return AuthNRequest(relay_state=relay_state) | ||||
|         return AuthNRequest() | ||||
|  | ||||
| @ -8,7 +8,6 @@ from authentik.blueprints.tests import apply_blueprint | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.tests.utils import get_request | ||||
| from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider | ||||
| from authentik.providers.saml.processors.assertion import AssertionProcessor | ||||
| @ -265,10 +264,3 @@ class TestAuthNRequest(TestCase): | ||||
|             events.first().context["message"], | ||||
|             "Failed to evaluate property-mapping: 'test'", | ||||
|         ) | ||||
|  | ||||
|     def test_idp_initiated(self): | ||||
|         """Test IDP-initiated login""" | ||||
|         self.provider.default_relay_state = generate_id() | ||||
|         request = AuthNRequestParser(self.provider).idp_initiated() | ||||
|         self.assertEqual(request.id, None) | ||||
|         self.assertEqual(request.relay_state, self.provider.default_relay_state) | ||||
|  | ||||
| @ -1,136 +0,0 @@ | ||||
| """common RBAC serializers""" | ||||
| from django.apps import apps | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.db.models import QuerySet | ||||
| from django_filters.filters import ModelChoiceFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.fields import ( | ||||
|     CharField, | ||||
|     ChoiceField, | ||||
|     ListField, | ||||
|     ReadOnlyField, | ||||
|     SerializerMethodField, | ||||
| ) | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | ||||
|  | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.core.models import User | ||||
| from authentik.lib.validators import RequiredTogetherValidator | ||||
| from authentik.policies.event_matcher.models import model_choices | ||||
| from authentik.rbac.models import Role | ||||
|  | ||||
|  | ||||
| class PermissionSerializer(ModelSerializer): | ||||
|     """Global permission""" | ||||
|  | ||||
|     app_label = ReadOnlyField(source="content_type.app_label") | ||||
|     app_label_verbose = SerializerMethodField() | ||||
|     model = ReadOnlyField(source="content_type.model") | ||||
|     model_verbose = SerializerMethodField() | ||||
|  | ||||
|     def get_app_label_verbose(self, instance: Permission) -> str: | ||||
|         """Human-readable app label""" | ||||
|         try: | ||||
|             return apps.get_app_config(instance.content_type.app_label).verbose_name | ||||
|         except LookupError: | ||||
|             return f"{instance.content_type.app_label}.{instance.content_type.model}" | ||||
|  | ||||
|     def get_model_verbose(self, instance: Permission) -> str: | ||||
|         """Human-readable model name""" | ||||
|         try: | ||||
|             return apps.get_model( | ||||
|                 instance.content_type.app_label, instance.content_type.model | ||||
|             )._meta.verbose_name | ||||
|         except LookupError: | ||||
|             return f"{instance.content_type.app_label}.{instance.content_type.model}" | ||||
|  | ||||
|     class Meta: | ||||
|         model = Permission | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "name", | ||||
|             "codename", | ||||
|             "model", | ||||
|             "app_label", | ||||
|             "app_label_verbose", | ||||
|             "model_verbose", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class PermissionFilter(FilterSet): | ||||
|     """Filter permissions""" | ||||
|  | ||||
|     role = ModelChoiceFilter(queryset=Role.objects.all(), method="filter_role") | ||||
|     user = ModelChoiceFilter(queryset=User.objects.all()) | ||||
|  | ||||
|     def filter_role(self, queryset: QuerySet, name, value: Role) -> QuerySet: | ||||
|         """Filter permissions based on role""" | ||||
|         return queryset.filter(group__role=value) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Permission | ||||
|         fields = [ | ||||
|             "codename", | ||||
|             "content_type__model", | ||||
|             "content_type__app_label", | ||||
|             "role", | ||||
|             "user", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class RBACPermissionViewSet(ReadOnlyModelViewSet): | ||||
|     """Read-only list of all permissions, filterable by model and app""" | ||||
|  | ||||
|     queryset = Permission.objects.none() | ||||
|     serializer_class = PermissionSerializer | ||||
|     ordering = ["name"] | ||||
|     filterset_class = PermissionFilter | ||||
|     search_fields = [ | ||||
|         "codename", | ||||
|         "content_type__model", | ||||
|         "content_type__app_label", | ||||
|     ] | ||||
|  | ||||
|     def get_queryset(self) -> QuerySet: | ||||
|         return ( | ||||
|             Permission.objects.all() | ||||
|             .select_related("content_type") | ||||
|             .filter( | ||||
|                 content_type__app_label__startswith="authentik", | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class PermissionAssignSerializer(PassiveSerializer): | ||||
|     """Request to assign a new permission""" | ||||
|  | ||||
|     permissions = ListField(child=CharField()) | ||||
|     model = ChoiceField(choices=model_choices(), required=False) | ||||
|     object_pk = CharField(required=False) | ||||
|  | ||||
|     validators = [RequiredTogetherValidator(fields=["model", "object_pk"])] | ||||
|  | ||||
|     def validate(self, attrs: dict) -> dict: | ||||
|         model_instance = None | ||||
|         # Check if we're setting an object-level perm or global | ||||
|         model = attrs.get("model") | ||||
|         object_pk = attrs.get("object_pk") | ||||
|         if model and object_pk: | ||||
|             model = apps.get_model(attrs["model"]) | ||||
|             model_instance = model.objects.filter(pk=attrs["object_pk"]).first() | ||||
|         attrs["model_instance"] = model_instance | ||||
|         if attrs.get("model"): | ||||
|             return attrs | ||||
|         permissions = attrs.get("permissions", []) | ||||
|         if not all("." in perm for perm in permissions): | ||||
|             raise ValidationError( | ||||
|                 { | ||||
|                     "permissions": ( | ||||
|                         "When assigning global permissions, codename must be given as " | ||||
|                         "app_label.codename" | ||||
|                     ) | ||||
|                 } | ||||
|             ) | ||||
|         return attrs | ||||
| @ -1,123 +0,0 @@ | ||||
| """common RBAC serializers""" | ||||
| from django.db.models import Q, QuerySet | ||||
| from django.db.transaction import atomic | ||||
| from django_filters.filters import CharFilter, ChoiceFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||
| from guardian.models import GroupObjectPermission | ||||
| from guardian.shortcuts import assign_perm, remove_perm | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import CharField, ReadOnlyField | ||||
| from rest_framework.mixins import ListModelMixin | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.policies.event_matcher.models import model_choices | ||||
| from authentik.rbac.api.rbac import PermissionAssignSerializer | ||||
| from authentik.rbac.models import Role | ||||
|  | ||||
|  | ||||
| class RoleObjectPermissionSerializer(ModelSerializer): | ||||
|     """Role-bound object level permission""" | ||||
|  | ||||
|     app_label = ReadOnlyField(source="content_type.app_label") | ||||
|     model = ReadOnlyField(source="content_type.model") | ||||
|     codename = ReadOnlyField(source="permission.codename") | ||||
|     name = ReadOnlyField(source="permission.name") | ||||
|     object_pk = ReadOnlyField() | ||||
|  | ||||
|     class Meta: | ||||
|         model = GroupObjectPermission | ||||
|         fields = ["id", "codename", "model", "app_label", "object_pk", "name"] | ||||
|  | ||||
|  | ||||
| class RoleAssignedObjectPermissionSerializer(PassiveSerializer): | ||||
|     """Roles assigned object permission serializer""" | ||||
|  | ||||
|     role_pk = CharField(source="group.role.pk", read_only=True) | ||||
|     name = CharField(source="group.name", read_only=True) | ||||
|     permissions = RoleObjectPermissionSerializer( | ||||
|         many=True, source="group.groupobjectpermission_set" | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Role | ||||
|         fields = ["role_pk", "name", "permissions"] | ||||
|  | ||||
|  | ||||
| class RoleAssignedPermissionFilter(FilterSet): | ||||
|     """Role Assigned permission filter""" | ||||
|  | ||||
|     model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True) | ||||
|     object_pk = CharFilter(method="filter_object_pk") | ||||
|  | ||||
|     def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet: | ||||
|         """Filter by object type""" | ||||
|         app, _, model = value.partition(".") | ||||
|         return queryset.filter( | ||||
|             Q( | ||||
|                 group__permissions__content_type__app_label=app, | ||||
|                 group__permissions__content_type__model=model, | ||||
|             ) | ||||
|             | Q( | ||||
|                 group__groupobjectpermission__permission__content_type__app_label=app, | ||||
|                 group__groupobjectpermission__permission__content_type__model=model, | ||||
|             ) | ||||
|         ).distinct() | ||||
|  | ||||
|     def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet: | ||||
|         """Filter by object primary key""" | ||||
|         return queryset.filter(Q(group__groupobjectpermission__object_pk=value)).distinct() | ||||
|  | ||||
|  | ||||
| class RoleAssignedPermissionViewSet(ListModelMixin, GenericViewSet): | ||||
|     """Get assigned object permissions for a single object""" | ||||
|  | ||||
|     serializer_class = RoleAssignedObjectPermissionSerializer | ||||
|     ordering = ["name"] | ||||
|     # The filtering is done in the filterset, | ||||
|     # which has a required filter that does the heavy lifting | ||||
|     queryset = Role.objects.all() | ||||
|     filterset_class = RoleAssignedPermissionFilter | ||||
|  | ||||
|     @permission_required("authentik_rbac.assign_role_permissions") | ||||
|     @extend_schema( | ||||
|         request=PermissionAssignSerializer(), | ||||
|         responses={ | ||||
|             204: OpenApiResponse(description="Successfully assigned"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(methods=["POST"], detail=True, pagination_class=None, filter_backends=[]) | ||||
|     def assign(self, request: Request, *args, **kwargs) -> Response: | ||||
|         """Assign permission(s) to role. When `object_pk` is set, the permissions | ||||
|         are only assigned to the specific object, otherwise they are assigned globally.""" | ||||
|         role: Role = self.get_object() | ||||
|         data = PermissionAssignSerializer(data=request.data) | ||||
|         data.is_valid(raise_exception=True) | ||||
|         with atomic(): | ||||
|             for perm in data.validated_data["permissions"]: | ||||
|                 assign_perm(perm, role.group, data.validated_data["model_instance"]) | ||||
|         return Response(status=204) | ||||
|  | ||||
|     @permission_required("authentik_rbac.unassign_role_permissions") | ||||
|     @extend_schema( | ||||
|         request=PermissionAssignSerializer(), | ||||
|         responses={ | ||||
|             204: OpenApiResponse(description="Successfully unassigned"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(methods=["PATCH"], detail=True, pagination_class=None, filter_backends=[]) | ||||
|     def unassign(self, request: Request, *args, **kwargs) -> Response: | ||||
|         """Unassign permission(s) to role. When `object_pk` is set, the permissions | ||||
|         are only assigned to the specific object, otherwise they are assigned globally.""" | ||||
|         role: Role = self.get_object() | ||||
|         data = PermissionAssignSerializer(data=request.data) | ||||
|         data.is_valid(raise_exception=True) | ||||
|         with atomic(): | ||||
|             for perm in data.validated_data["permissions"]: | ||||
|                 remove_perm(perm, role.group, data.validated_data["model_instance"]) | ||||
|         return Response(status=204) | ||||
| @ -1,129 +0,0 @@ | ||||
| """common RBAC serializers""" | ||||
| from django.db.models import Q, QuerySet | ||||
| from django.db.transaction import atomic | ||||
| from django_filters.filters import CharFilter, ChoiceFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||
| from guardian.models import UserObjectPermission | ||||
| from guardian.shortcuts import assign_perm, remove_perm | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.fields import BooleanField, ReadOnlyField | ||||
| from rest_framework.mixins import ListModelMixin | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.groups import GroupMemberSerializer | ||||
| from authentik.core.models import User, UserTypes | ||||
| from authentik.policies.event_matcher.models import model_choices | ||||
| from authentik.rbac.api.rbac import PermissionAssignSerializer | ||||
|  | ||||
|  | ||||
| class UserObjectPermissionSerializer(ModelSerializer): | ||||
|     """User-bound object level permission""" | ||||
|  | ||||
|     app_label = ReadOnlyField(source="content_type.app_label") | ||||
|     model = ReadOnlyField(source="content_type.model") | ||||
|     codename = ReadOnlyField(source="permission.codename") | ||||
|     name = ReadOnlyField(source="permission.name") | ||||
|     object_pk = ReadOnlyField() | ||||
|  | ||||
|     class Meta: | ||||
|         model = UserObjectPermission | ||||
|         fields = ["id", "codename", "model", "app_label", "object_pk", "name"] | ||||
|  | ||||
|  | ||||
| class UserAssignedObjectPermissionSerializer(GroupMemberSerializer): | ||||
|     """Users assigned object permission serializer""" | ||||
|  | ||||
|     permissions = UserObjectPermissionSerializer(many=True, source="userobjectpermission_set") | ||||
|     is_superuser = BooleanField() | ||||
|  | ||||
|     class Meta: | ||||
|         model = GroupMemberSerializer.Meta.model | ||||
|         fields = GroupMemberSerializer.Meta.fields + ["permissions", "is_superuser"] | ||||
|  | ||||
|  | ||||
| class UserAssignedPermissionFilter(FilterSet): | ||||
|     """Assigned permission filter""" | ||||
|  | ||||
|     model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True) | ||||
|     object_pk = CharFilter(method="filter_object_pk") | ||||
|  | ||||
|     def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet: | ||||
|         """Filter by object type""" | ||||
|         app, _, model = value.partition(".") | ||||
|         return queryset.filter( | ||||
|             Q( | ||||
|                 user_permissions__content_type__app_label=app, | ||||
|                 user_permissions__content_type__model=model, | ||||
|             ) | ||||
|             | Q( | ||||
|                 userobjectpermission__permission__content_type__app_label=app, | ||||
|                 userobjectpermission__permission__content_type__model=model, | ||||
|             ) | ||||
|             | Q(ak_groups__is_superuser=True) | ||||
|         ).distinct() | ||||
|  | ||||
|     def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet: | ||||
|         """Filter by object primary key""" | ||||
|         return queryset.filter( | ||||
|             Q(userobjectpermission__object_pk=value) | Q(ak_groups__is_superuser=True), | ||||
|         ).distinct() | ||||
|  | ||||
|  | ||||
| class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet): | ||||
|     """Get assigned object permissions for a single object""" | ||||
|  | ||||
|     serializer_class = UserAssignedObjectPermissionSerializer | ||||
|     ordering = ["username"] | ||||
|     # The filtering is done in the filterset, | ||||
|     # which has a required filter that does the heavy lifting | ||||
|     queryset = User.objects.all() | ||||
|     filterset_class = UserAssignedPermissionFilter | ||||
|  | ||||
|     @permission_required("authentik_core.assign_user_permissions") | ||||
|     @extend_schema( | ||||
|         request=PermissionAssignSerializer(), | ||||
|         responses={ | ||||
|             204: OpenApiResponse(description="Successfully assigned"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(methods=["POST"], detail=True, pagination_class=None, filter_backends=[]) | ||||
|     def assign(self, request: Request, *args, **kwargs) -> Response: | ||||
|         """Assign permission(s) to user""" | ||||
|         user: User = self.get_object() | ||||
|         if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT: | ||||
|             raise ValidationError("Permissions cannot be assigned to an internal service account.") | ||||
|         data = PermissionAssignSerializer(data=request.data) | ||||
|         data.is_valid(raise_exception=True) | ||||
|         with atomic(): | ||||
|             for perm in data.validated_data["permissions"]: | ||||
|                 assign_perm(perm, user, data.validated_data["model_instance"]) | ||||
|         return Response(status=204) | ||||
|  | ||||
|     @permission_required("authentik_core.unassign_user_permissions") | ||||
|     @extend_schema( | ||||
|         request=PermissionAssignSerializer(), | ||||
|         responses={ | ||||
|             204: OpenApiResponse(description="Successfully unassigned"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(methods=["PATCH"], detail=True, pagination_class=None, filter_backends=[]) | ||||
|     def unassign(self, request: Request, *args, **kwargs) -> Response: | ||||
|         """Unassign permission(s) to user. When `object_pk` is set, the permissions | ||||
|         are only assigned to the specific object, otherwise they are assigned globally.""" | ||||
|         user: User = self.get_object() | ||||
|         if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT: | ||||
|             raise ValidationError( | ||||
|                 "Permissions cannot be unassigned from an internal service account." | ||||
|             ) | ||||
|         data = PermissionAssignSerializer(data=request.data) | ||||
|         data.is_valid(raise_exception=True) | ||||
|         with atomic(): | ||||
|             for perm in data.validated_data["permissions"]: | ||||
|                 remove_perm(perm, user, data.validated_data["model_instance"]) | ||||
|         return Response(status=204) | ||||
| @ -1,77 +0,0 @@ | ||||
| """common RBAC serializers""" | ||||
| from typing import Optional | ||||
|  | ||||
| from django.apps import apps | ||||
| from django_filters.filters import UUIDFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from guardian.models import GroupObjectPermission | ||||
| from guardian.shortcuts import get_objects_for_group | ||||
| from rest_framework.fields import SerializerMethodField | ||||
| from rest_framework.mixins import ListModelMixin | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| from authentik.api.pagination import SmallerPagination | ||||
| from authentik.rbac.api.rbac_assigned_by_roles import RoleObjectPermissionSerializer | ||||
|  | ||||
|  | ||||
| class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer): | ||||
|     """User permission with additional object-related data""" | ||||
|  | ||||
|     app_label_verbose = SerializerMethodField() | ||||
|     model_verbose = SerializerMethodField() | ||||
|  | ||||
|     object_description = SerializerMethodField() | ||||
|  | ||||
|     def get_app_label_verbose(self, instance: GroupObjectPermission) -> str: | ||||
|         """Get app label from permission's model""" | ||||
|         return apps.get_app_config(instance.content_type.app_label).verbose_name | ||||
|  | ||||
|     def get_model_verbose(self, instance: GroupObjectPermission) -> str: | ||||
|         """Get model label from permission's model""" | ||||
|         try: | ||||
|             return apps.get_model( | ||||
|                 instance.content_type.app_label, instance.content_type.model | ||||
|             )._meta.verbose_name | ||||
|         except LookupError: | ||||
|             return f"{instance.content_type.app_label}.{instance.content_type.model}" | ||||
|  | ||||
|     def get_object_description(self, instance: GroupObjectPermission) -> Optional[str]: | ||||
|         """Get model description from attached model. This operation takes at least | ||||
|         one additional query, and the description is only shown if the user/role has the | ||||
|         view_ permission on the object""" | ||||
|         app_label = instance.content_type.app_label | ||||
|         model = instance.content_type.model | ||||
|         try: | ||||
|             model_class = apps.get_model(app_label, model) | ||||
|         except LookupError: | ||||
|             return None | ||||
|         objects = get_objects_for_group(instance.group, f"{app_label}.view_{model}", model_class) | ||||
|         obj = objects.first() | ||||
|         if not obj: | ||||
|             return None | ||||
|         return str(obj) | ||||
|  | ||||
|     class Meta(RoleObjectPermissionSerializer.Meta): | ||||
|         fields = RoleObjectPermissionSerializer.Meta.fields + [ | ||||
|             "app_label_verbose", | ||||
|             "model_verbose", | ||||
|             "object_description", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class RolePermissionFilter(FilterSet): | ||||
|     """Role permission filter""" | ||||
|  | ||||
|     uuid = UUIDFilter("group__role__uuid", required=True) | ||||
|  | ||||
|  | ||||
| class RolePermissionViewSet(ListModelMixin, GenericViewSet): | ||||
|     """Get a role's assigned object permissions""" | ||||
|  | ||||
|     serializer_class = ExtraRoleObjectPermissionSerializer | ||||
|     ordering = ["group__role__name"] | ||||
|     pagination_class = SmallerPagination | ||||
|     # The filtering is done in the filterset, | ||||
|     # which has a required filter that does the heavy lifting | ||||
|     queryset = GroupObjectPermission.objects.select_related("content_type", "group__role").all() | ||||
|     filterset_class = RolePermissionFilter | ||||
| @ -1,77 +0,0 @@ | ||||
| """common RBAC serializers""" | ||||
| from typing import Optional | ||||
|  | ||||
| from django.apps import apps | ||||
| from django_filters.filters import NumberFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from guardian.models import UserObjectPermission | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from rest_framework.fields import SerializerMethodField | ||||
| from rest_framework.mixins import ListModelMixin | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| from authentik.api.pagination import SmallerPagination | ||||
| from authentik.rbac.api.rbac_assigned_by_users import UserObjectPermissionSerializer | ||||
|  | ||||
|  | ||||
| class ExtraUserObjectPermissionSerializer(UserObjectPermissionSerializer): | ||||
|     """User permission with additional object-related data""" | ||||
|  | ||||
|     app_label_verbose = SerializerMethodField() | ||||
|     model_verbose = SerializerMethodField() | ||||
|  | ||||
|     object_description = SerializerMethodField() | ||||
|  | ||||
|     def get_app_label_verbose(self, instance: UserObjectPermission) -> str: | ||||
|         """Get app label from permission's model""" | ||||
|         return apps.get_app_config(instance.content_type.app_label).verbose_name | ||||
|  | ||||
|     def get_model_verbose(self, instance: UserObjectPermission) -> str: | ||||
|         """Get model label from permission's model""" | ||||
|         try: | ||||
|             return apps.get_model( | ||||
|                 instance.content_type.app_label, instance.content_type.model | ||||
|             )._meta.verbose_name | ||||
|         except LookupError: | ||||
|             return f"{instance.content_type.app_label}.{instance.content_type.model}" | ||||
|  | ||||
|     def get_object_description(self, instance: UserObjectPermission) -> Optional[str]: | ||||
|         """Get model description from attached model. This operation takes at least | ||||
|         one additional query, and the description is only shown if the user/role has the | ||||
|         view_ permission on the object""" | ||||
|         app_label = instance.content_type.app_label | ||||
|         model = instance.content_type.model | ||||
|         try: | ||||
|             model_class = apps.get_model(app_label, model) | ||||
|         except LookupError: | ||||
|             return None | ||||
|         objects = get_objects_for_user(instance.user, f"{app_label}.view_{model}", model_class) | ||||
|         obj = objects.first() | ||||
|         if not obj: | ||||
|             return None | ||||
|         return str(obj) | ||||
|  | ||||
|     class Meta(UserObjectPermissionSerializer.Meta): | ||||
|         fields = UserObjectPermissionSerializer.Meta.fields + [ | ||||
|             "app_label_verbose", | ||||
|             "model_verbose", | ||||
|             "object_description", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class UserPermissionFilter(FilterSet): | ||||
|     """User-assigned permission filter""" | ||||
|  | ||||
|     user_id = NumberFilter("user__id", required=True) | ||||
|  | ||||
|  | ||||
| class UserPermissionViewSet(ListModelMixin, GenericViewSet): | ||||
|     """Get a users's assigned object permissions""" | ||||
|  | ||||
|     serializer_class = ExtraUserObjectPermissionSerializer | ||||
|     ordering = ["user__username"] | ||||
|     pagination_class = SmallerPagination | ||||
|     # The filtering is done in the filterset, | ||||
|     # which has a required filter that does the heavy lifting | ||||
|     queryset = UserObjectPermission.objects.select_related("content_type", "user").all() | ||||
|     filterset_class = UserPermissionFilter | ||||
| @ -1,24 +0,0 @@ | ||||
| """RBAC Roles""" | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.rbac.models import Role | ||||
|  | ||||
|  | ||||
| class RoleSerializer(ModelSerializer): | ||||
|     """Role serializer""" | ||||
|  | ||||
|     class Meta: | ||||
|         model = Role | ||||
|         fields = ["pk", "name"] | ||||
|  | ||||
|  | ||||
| class RoleViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Role viewset""" | ||||
|  | ||||
|     serializer_class = RoleSerializer | ||||
|     queryset = Role.objects.all() | ||||
|     search_fields = ["group__name"] | ||||
|     ordering = ["group__name"] | ||||
|     filterset_fields = ["group__name"] | ||||
| @ -1,15 +0,0 @@ | ||||
| """authentik rbac app config""" | ||||
| from authentik.blueprints.apps import ManagedAppConfig | ||||
|  | ||||
|  | ||||
| class AuthentikRBACConfig(ManagedAppConfig): | ||||
|     """authentik rbac app config""" | ||||
|  | ||||
|     name = "authentik.rbac" | ||||
|     label = "authentik_rbac" | ||||
|     verbose_name = "authentik RBAC" | ||||
|     default = True | ||||
|  | ||||
|     def reconcile_load_rbac_signals(self): | ||||
|         """Load rbac signals""" | ||||
|         self.import_module("authentik.rbac.signals") | ||||
| @ -1,33 +0,0 @@ | ||||
| """RBAC API Filter""" | ||||
| from django.db.models import QuerySet | ||||
| from rest_framework.exceptions import PermissionDenied | ||||
| from rest_framework.request import Request | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
|  | ||||
| from authentik.core.models import UserTypes | ||||
|  | ||||
|  | ||||
| class ObjectFilter(ObjectPermissionsFilter): | ||||
|     """Object permission filter that grants global permission higher priority than | ||||
|     per-object permissions""" | ||||
|  | ||||
|     def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: | ||||
|         permission = self.perm_format % { | ||||
|             "app_label": queryset.model._meta.app_label, | ||||
|             "model_name": queryset.model._meta.model_name, | ||||
|         } | ||||
|         # having the global permission set on a user has higher priority than | ||||
|         # per-object permissions | ||||
|         if request.user.has_perm(permission): | ||||
|             return queryset | ||||
|         queryset = super().filter_queryset(request, queryset, view) | ||||
|         # Outposts (which are the only objects using internal service accounts) | ||||
|         # except requests to return an empty list when they have no objects | ||||
|         # assigned | ||||
|         if request.user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT: | ||||
|             return queryset | ||||
|         if not queryset.exists(): | ||||
|             # User doesn't have direct permission to all objects | ||||
|             # and also no object permissions assigned (directly or via role) | ||||
|             raise PermissionDenied() | ||||
|         return queryset | ||||
| @ -1,47 +0,0 @@ | ||||
| # Generated by Django 4.2.6 on 2023-10-11 13:37 | ||||
|  | ||||
| import uuid | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("auth", "0012_alter_user_first_name_max_length"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="Role", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "uuid", | ||||
|                     models.UUIDField( | ||||
|                         default=uuid.uuid4, | ||||
|                         editable=False, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         unique=True, | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("name", models.TextField(max_length=150, unique=True)), | ||||
|                 ( | ||||
|                     "group", | ||||
|                     models.OneToOneField( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, to="auth.group" | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Role", | ||||
|                 "verbose_name_plural": "Roles", | ||||
|                 "permissions": [ | ||||
|                     ("assign_role_permissions", "Can assign permissions to users"), | ||||
|                     ("unassign_role_permissions", "Can unassign permissions from users"), | ||||
|                 ], | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -1,35 +0,0 @@ | ||||
| # Generated by Django 4.2.6 on 2023-10-12 15:26 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_rbac", "0001_initial"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="SystemPermission", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.AutoField( | ||||
|                         auto_created=True, primary_key=True, serialize=False, verbose_name="ID" | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "permissions": [ | ||||
|                     ("view_system_info", "Can view system info"), | ||||
|                     ("view_system_tasks", "Can view system tasks"), | ||||
|                     ("run_system_tasks", "Can run system tasks"), | ||||
|                     ("access_admin_interface", "Can access admin interface"), | ||||
|                 ], | ||||
|                 "verbose_name": "System permission", | ||||
|                 "verbose_name_plural": "System permissions", | ||||
|                 "managed": False, | ||||
|                 "default_permissions": (), | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -1,73 +0,0 @@ | ||||
| """RBAC models""" | ||||
| from typing import Optional | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from django.db import models | ||||
| from django.db.transaction import atomic | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| from authentik.lib.models import SerializerModel | ||||
|  | ||||
|  | ||||
| class Role(SerializerModel): | ||||
|     """RBAC role, which can have different permissions (both global and per-object) attached | ||||
|     to it.""" | ||||
|  | ||||
|     uuid = models.UUIDField(default=uuid4, editable=False, unique=True, primary_key=True) | ||||
|     # Due to the way django and django-guardian work, this is somewhat of a hack. | ||||
|     # Django and django-guardian allow for setting permissions on users and groups, but they | ||||
|     # only allow for a custom user object, not a custom group object, which is why | ||||
|     # we have both authentik and django groups. With this model, we use the inbuilt group system | ||||
|     # for RBAC. This means that every Role needs a single django group that its assigned to | ||||
|     # which will hold all of the actual permissions | ||||
|     # The main advantage of that is that all the permission checking just works out of the box, | ||||
|     # as these permissions are checked by default by django and most other libraries that build | ||||
|     # on top of django | ||||
|     group = models.OneToOneField("auth.Group", on_delete=models.CASCADE) | ||||
|  | ||||
|     # name field has the same constraints as the group model | ||||
|     name = models.TextField(max_length=150, unique=True) | ||||
|  | ||||
|     def assign_permission(self, *perms: str, obj: Optional[models.Model] = None): | ||||
|         """Assign permission to role, can handle multiple permissions, | ||||
|         but when assigning multiple permissions to an object the permissions | ||||
|         must all belong to the object given""" | ||||
|         with atomic(): | ||||
|             for perm in perms: | ||||
|                 assign_perm(perm, self.group, obj) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.rbac.api.roles import RoleSerializer | ||||
|  | ||||
|         return RoleSerializer | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"Role {self.name}" | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Role") | ||||
|         verbose_name_plural = _("Roles") | ||||
|         permissions = [ | ||||
|             ("assign_role_permissions", _("Can assign permissions to users")), | ||||
|             ("unassign_role_permissions", _("Can unassign permissions from users")), | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class SystemPermission(models.Model): | ||||
|     """System-wide permissions that are not related to any direct | ||||
|     database model""" | ||||
|  | ||||
|     class Meta: | ||||
|         managed = False | ||||
|         default_permissions = () | ||||
|         verbose_name = _("System permission") | ||||
|         verbose_name_plural = _("System permissions") | ||||
|         permissions = [ | ||||
|             ("view_system_info", _("Can view system info")), | ||||
|             ("view_system_tasks", _("Can view system tasks")), | ||||
|             ("run_system_tasks", _("Can run system tasks")), | ||||
|             ("access_admin_interface", _("Can access admin interface")), | ||||
|         ] | ||||
| @ -1,30 +0,0 @@ | ||||
| """RBAC Permissions""" | ||||
| from django.db.models import Model | ||||
| from rest_framework.permissions import BasePermission, DjangoObjectPermissions | ||||
| from rest_framework.request import Request | ||||
|  | ||||
|  | ||||
| class ObjectPermissions(DjangoObjectPermissions): | ||||
|     """RBAC Permissions""" | ||||
|  | ||||
|     def has_object_permission(self, request: Request, view, obj: Model): | ||||
|         queryset = self._queryset(view) | ||||
|         model_cls = queryset.model | ||||
|         perms = self.get_required_object_permissions(request.method, model_cls) | ||||
|         # Rank global permissions higher than per-object permissions | ||||
|         if request.user.has_perms(perms): | ||||
|             return True | ||||
|         return super().has_object_permission(request, view, obj) | ||||
|  | ||||
|  | ||||
| # pylint: disable=invalid-name | ||||
| def HasPermission(*perm: str) -> type[BasePermission]: | ||||
|     """Permission checker for any non-object permissions, returns | ||||
|     a BasePermission class that can be used with rest_framework""" | ||||
|  | ||||
|     # pylint: disable=missing-class-docstring, invalid-name | ||||
|     class checker(BasePermission): | ||||
|         def has_permission(self, request: Request, view): | ||||
|             return bool(request.user and request.user.has_perms(perm)) | ||||
|  | ||||
|     return checker | ||||
| @ -1,67 +0,0 @@ | ||||
| """rbac signals""" | ||||
| from django.contrib.auth.models import Group as DjangoGroup | ||||
| from django.db.models.signals import m2m_changed, pre_save | ||||
| from django.db.transaction import atomic | ||||
| from django.dispatch import receiver | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Group | ||||
| from authentik.rbac.models import Role | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @receiver(pre_save, sender=Role) | ||||
| def rbac_role_pre_save(sender: type[Role], instance: Role, **_): | ||||
|     """Ensure role has a group object created for it""" | ||||
|     if hasattr(instance, "group"): | ||||
|         return | ||||
|     group, _ = DjangoGroup.objects.get_or_create(name=instance.name) | ||||
|     instance.group = group | ||||
|  | ||||
|  | ||||
| @receiver(m2m_changed, sender=Group.roles.through) | ||||
| def rbac_group_role_m2m(sender: type[Group], action: str, instance: Group, reverse: bool, **_): | ||||
|     """RBAC: Sync group members into roles when roles are assigned""" | ||||
|     if action not in ["post_add", "post_remove", "post_clear"]: | ||||
|         return | ||||
|     with atomic(): | ||||
|         group_users = list( | ||||
|             instance.children_recursive() | ||||
|             .exclude(users__isnull=True) | ||||
|             .values_list("users", flat=True) | ||||
|         ) | ||||
|         if not group_users: | ||||
|             return | ||||
|         for role in instance.roles.all(): | ||||
|             role: Role | ||||
|             role.group.user_set.set(group_users) | ||||
|         LOGGER.debug("Updated users in group", group=instance) | ||||
|  | ||||
|  | ||||
| # pylint: disable=no-member | ||||
| @receiver(m2m_changed, sender=Group.users.through) | ||||
| def rbac_group_users_m2m( | ||||
|     sender: type[Group], action: str, instance: Group, pk_set: set, reverse: bool, **_ | ||||
| ): | ||||
|     """Handle Group/User m2m and mirror it to roles""" | ||||
|     if action not in ["post_add", "post_remove"]: | ||||
|         return | ||||
|     # reverse: instance is a Group, pk_set is a list of user pks | ||||
|     # non-reverse: instance is a User, pk_set is a list of groups | ||||
|     with atomic(): | ||||
|         if reverse: | ||||
|             for role in instance.roles.all(): | ||||
|                 role: Role | ||||
|                 if action == "post_add": | ||||
|                     role.group.user_set.add(*pk_set) | ||||
|                 elif action == "post_remove": | ||||
|                     role.group.user_set.remove(*pk_set) | ||||
|         else: | ||||
|             for group in Group.objects.filter(pk__in=pk_set): | ||||
|                 for role in group.roles.all(): | ||||
|                     role: Role | ||||
|                     if action == "post_add": | ||||
|                         role.group.user_set.add(instance) | ||||
|                     elif action == "post_remove": | ||||
|                         role.group.user_set.remove(instance) | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	