Compare commits
	
		
			4 Commits
		
	
	
		
			version/20
			...
			sources/ld
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2a6479062f | |||
| 52463b8f96 | |||
| 330f639a7e | |||
| 85ea4651e4 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2023.10.2 | current_version = 2023.8.3 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) | 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 |         # adjust accordingly based on how flaky your tests are | ||||||
|         # this allows a 1% drop from the previous base commit coverage |         # this allows a 1% drop from the previous base commit coverage | ||||||
|         threshold: 1% |         threshold: 1% | ||||||
| comment: |   notify: | ||||||
|     after_n_builds: 3 |     after_n_builds: 3 | ||||||
|  | |||||||
							
								
								
									
										37
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										37
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -30,7 +30,6 @@ updates: | |||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|     commit-message: |     commit-message: | ||||||
|       prefix: "web:" |       prefix: "web:" | ||||||
|     # TODO: deduplicate these groups |  | ||||||
|     groups: |     groups: | ||||||
|       sentry: |       sentry: | ||||||
|         patterns: |         patterns: | ||||||
| @ -41,7 +40,7 @@ updates: | |||||||
|           - "babel-*" |           - "babel-*" | ||||||
|       eslint: |       eslint: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@typescript-eslint/*" |           - "@typescript-eslint/eslint-*" | ||||||
|           - "eslint" |           - "eslint" | ||||||
|           - "eslint-*" |           - "eslint-*" | ||||||
|       storybook: |       storybook: | ||||||
| @ -51,40 +50,6 @@ updates: | |||||||
|       esbuild: |       esbuild: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@esbuild/*" |           - "@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 |   - package-ecosystem: npm | ||||||
|     directory: "/website" |     directory: "/website" | ||||||
|     schedule: |     schedule: | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -90,7 +90,6 @@ jobs: | |||||||
|         psql: |         psql: | ||||||
|           - 12-alpine |           - 12-alpine | ||||||
|           - 15-alpine |           - 15-alpine | ||||||
|           - 16-alpine |  | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - name: Setup authentik env |       - 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 |       - name: golangci-lint | ||||||
|         uses: golangci/golangci-lint-action@v3 |         uses: golangci/golangci-lint-action@v3 | ||||||
|         with: |         with: | ||||||
|           version: v1.54.2 |           version: v1.52.2 | ||||||
|           args: --timeout 5000s --verbose |           args: --timeout 5000s --verbose | ||||||
|           skip-cache: true |           skip-cache: true | ||||||
|   test-unittest: |   test-unittest: | ||||||
| @ -124,7 +124,7 @@ jobs: | |||||||
|       - uses: actions/setup-go@v4 |       - uses: actions/setup-go@v4 | ||||||
|         with: |         with: | ||||||
|           go-version-file: "go.mod" |           go-version-file: "go.mod" | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
|  | |||||||
							
								
								
									
										34
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										34
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -13,31 +13,25 @@ on: | |||||||
| jobs: | jobs: | ||||||
|   lint-eslint: |   lint-eslint: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     strategy: |  | ||||||
|       fail-fast: false |  | ||||||
|       matrix: |  | ||||||
|         project: |  | ||||||
|           - web |  | ||||||
|           - tests/wdio |  | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
|           cache-dependency-path: ${{ matrix.project }}/package-lock.json |           cache-dependency-path: web/package-lock.json | ||||||
|       - working-directory: ${{ matrix.project }}/ |       - working-directory: web/ | ||||||
|         run: npm ci |         run: npm ci | ||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-client-ts |         run: make gen-client-ts | ||||||
|       - name: Eslint |       - name: Eslint | ||||||
|         working-directory: ${{ matrix.project }}/ |         working-directory: web/ | ||||||
|         run: npm run lint |         run: npm run lint | ||||||
|   lint-build: |   lint-build: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
| @ -51,31 +45,25 @@ jobs: | |||||||
|         run: npm run tsc |         run: npm run tsc | ||||||
|   lint-prettier: |   lint-prettier: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     strategy: |  | ||||||
|       fail-fast: false |  | ||||||
|       matrix: |  | ||||||
|         project: |  | ||||||
|           - web |  | ||||||
|           - tests/wdio |  | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
|           cache-dependency-path: ${{ matrix.project }}/package-lock.json |           cache-dependency-path: web/package-lock.json | ||||||
|       - working-directory: ${{ matrix.project }}/ |       - working-directory: web/ | ||||||
|         run: npm ci |         run: npm ci | ||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-client-ts |         run: make gen-client-ts | ||||||
|       - name: prettier |       - name: prettier | ||||||
|         working-directory: ${{ matrix.project }}/ |         working-directory: web/ | ||||||
|         run: npm run prettier-check |         run: npm run prettier-check | ||||||
|   lint-lit-analyse: |   lint-lit-analyse: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
| @ -107,7 +95,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           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 |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
| @ -29,7 +29,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
| @ -50,7 +50,7 @@ jobs: | |||||||
|           - build-docs-only |           - build-docs-only | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,8 +1,8 @@ | |||||||
| name: ghcr-retention | name: ghcr-retention | ||||||
|  |  | ||||||
| on: | on: | ||||||
|   # schedule: |   schedule: | ||||||
|   #   - cron: "0 0 * * *" # every day at midnight |     - cron: "0 0 * * *" # every day at midnight | ||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -27,10 +27,8 @@ jobs: | |||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|       - name: make empty clients |       - name: make empty ts client | ||||||
|         run: | |         run: mkdir -p ./gen-ts-client | ||||||
|           mkdir -p ./gen-ts-api |  | ||||||
|           mkdir -p ./gen-go-api |  | ||||||
|       - name: Build Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v5 |         uses: docker/build-push-action@v5 | ||||||
|         with: |         with: | ||||||
| @ -71,10 +69,6 @@ jobs: | |||||||
|       - name: prepare variables |       - name: prepare variables | ||||||
|         uses: ./.github/actions/docker-push-variables |         uses: ./.github/actions/docker-push-variables | ||||||
|         id: ev |         id: ev | ||||||
|       - name: make empty clients |  | ||||||
|         run: | |  | ||||||
|           mkdir -p ./gen-ts-api |  | ||||||
|           mkdir -p ./gen-go-api |  | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|         with: |         with: | ||||||
| @ -99,7 +93,6 @@ jobs: | |||||||
|             ghcr.io/goauthentik/${{ matrix.type }}:latest |             ghcr.io/goauthentik/${{ matrix.type }}:latest | ||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |  | ||||||
|           build-args: | |           build-args: | | ||||||
|             VERSION=${{ steps.ev.outputs.version }} |             VERSION=${{ steps.ev.outputs.version }} | ||||||
|             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
| @ -120,7 +113,7 @@ jobs: | |||||||
|       - uses: actions/setup-go@v4 |       - uses: actions/setup-go@v4 | ||||||
|         with: |         with: | ||||||
|           go-version-file: "go.mod" |           go-version-file: "go.mod" | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           cache: "npm" |           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 "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
|           echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env |           echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env | ||||||
|           docker buildx install |           docker buildx install | ||||||
|           mkdir -p ./gen-ts-api |  | ||||||
|           docker build -t testing:latest . |           docker build -t testing:latest . | ||||||
|           echo "AUTHENTIK_IMAGE=testing" >> .env |           echo "AUTHENTIK_IMAGE=testing" >> .env | ||||||
|           echo "AUTHENTIK_TAG=latest" >> .env |           echo "AUTHENTIK_TAG=latest" >> .env | ||||||
|  | |||||||
							
								
								
									
										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 |       - uses: actions/checkout@v4 | ||||||
|         with: |         with: | ||||||
|           token: ${{ steps.generate_token.outputs.token }} |           token: ${{ steps.generate_token.outputs.token }} | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: "20" |           node-version: "20" | ||||||
|           registry-url: "https://registry.npmjs.org" |           registry-url: "https://registry.npmjs.org" | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -206,6 +206,3 @@ data/ | |||||||
| .netlify | .netlify | ||||||
| .ruff_cache | .ruff_cache | ||||||
| source_docs/ | source_docs/ | ||||||
|  |  | ||||||
| ### Golang ### |  | ||||||
| /vendor/ |  | ||||||
|  | |||||||
| @ -9,8 +9,6 @@ lifecycle/                      @goauthentik/backend | |||||||
| schemas/                        @goauthentik/backend | schemas/                        @goauthentik/backend | ||||||
| scripts/                        @goauthentik/backend | scripts/                        @goauthentik/backend | ||||||
| tests/                          @goauthentik/backend | tests/                          @goauthentik/backend | ||||||
| pyproject.toml                  @goauthentik/backend |  | ||||||
| poetry.lock                     @goauthentik/backend |  | ||||||
| # Infrastructure | # Infrastructure | ||||||
| .github/                        @goauthentik/infrastructure | .github/                        @goauthentik/infrastructure | ||||||
| Dockerfile                      @goauthentik/infrastructure | Dockerfile                      @goauthentik/infrastructure | ||||||
| @ -19,7 +17,6 @@ Dockerfile                      @goauthentik/infrastructure | |||||||
| docker-compose.yml              @goauthentik/infrastructure | docker-compose.yml              @goauthentik/infrastructure | ||||||
| # Web | # Web | ||||||
| web/                            @goauthentik/frontend | web/                            @goauthentik/frontend | ||||||
| tests/wdio/                     @goauthentik/frontend |  | ||||||
| # Docs & Website | # Docs & Website | ||||||
| website/                        @goauthentik/docs | website/                        @goauthentik/docs | ||||||
| # Security | # Security | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,5 +1,5 @@ | |||||||
| # Stage 1: Build website | # 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 | ENV NODE_ENV=production | ||||||
|  |  | ||||||
| @ -17,7 +17,7 @@ COPY ./SECURITY.md /work/ | |||||||
| RUN npm run build-docs-only | RUN npm run build-docs-only | ||||||
|  |  | ||||||
| # Stage 2: Build webui | # 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 | ENV NODE_ENV=production | ||||||
|  |  | ||||||
| @ -35,7 +35,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | |||||||
| RUN npm run build | RUN npm run build | ||||||
|  |  | ||||||
| # Stage 3: Build go proxy | # 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 | WORKDIR /go/src/goauthentik.io | ||||||
|  |  | ||||||
| @ -146,10 +146,10 @@ USER 1000 | |||||||
| ENV TMPDIR=/dev/shm/ \ | ENV TMPDIR=/dev/shm/ \ | ||||||
|     PYTHONDONTWRITEBYTECODE=1 \ |     PYTHONDONTWRITEBYTECODE=1 \ | ||||||
|     PYTHONUNBUFFERED=1 \ |     PYTHONUNBUFFERED=1 \ | ||||||
|     PATH="/ak-root/venv/bin:/lifecycle:$PATH" \ |     PATH="/ak-root/venv/bin:$PATH" \ | ||||||
|     VENV_PATH="/ak-root/venv" \ |     VENV_PATH="/ak-root/venv" \ | ||||||
|     POETRY_VIRTUALENVS_CREATE=false |     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 | 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 | help:  ## Show this help | ||||||
| 	@echo "\nSpecify a command. The choices are:\n" | 	@echo "\nSpecify a command. The choices are:\n" | ||||||
| 	@grep -Eh '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ | 	@grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ | ||||||
| 		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[0;36m%-$(HELP_WIDTH)s  \033[m %s\n", $$1, $$2}' | \ | 		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[0;36m%-24s\033[m %s\n", $$1, $$2}' | \ | ||||||
| 		sort | 		sort | ||||||
| 	@echo "" | 	@echo "" | ||||||
|  |  | ||||||
| @ -56,15 +53,14 @@ test: ## Run the server tests and produce a coverage report (locally) | |||||||
| 	coverage report | 	coverage report | ||||||
|  |  | ||||||
| lint-fix:  ## Lint and automatically fix errors in the python source code. Reports spelling errors. | lint-fix:  ## Lint and automatically fix errors in the python source code. Reports spelling errors. | ||||||
| 	isort $(PY_SOURCES) | 	isort authentik $(PY_SOURCES) | ||||||
| 	black $(PY_SOURCES) | 	black authentik $(PY_SOURCES) | ||||||
| 	ruff $(PY_SOURCES) | 	ruff authentik $(PY_SOURCES) | ||||||
| 	codespell -w $(CODESPELL_ARGS) | 	codespell -w $(CODESPELL_ARGS) | ||||||
|  |  | ||||||
| lint: ## Lint the python and golang sources | lint: ## Lint the python and golang sources | ||||||
| 	bandit -r $(PY_SOURCES) -x node_modules |  | ||||||
| 	./web/node_modules/.bin/pyright $(PY_SOURCES) |  | ||||||
| 	pylint $(PY_SOURCES) | 	pylint $(PY_SOURCES) | ||||||
|  | 	bandit -r $(PY_SOURCES) -x node_modules | ||||||
| 	golangci-lint run -v | 	golangci-lint run -v | ||||||
|  |  | ||||||
| migrate: ## Run the Authentik Django server's migrations | migrate: ## Run the Authentik Django server's migrations | ||||||
| @ -79,10 +75,10 @@ install: web-install website-install  ## Install all requires dependencies for ` | |||||||
| 	poetry install | 	poetry install | ||||||
|  |  | ||||||
| dev-drop-db: | 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 | 	# Also remove the test-db if it exists | ||||||
| 	dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true | 	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: | dev-create-db: | ||||||
| 	createdb -U ${pg_user} -h ${pg_host} ${pg_name} | 	createdb -U ${pg_user} -h ${pg_host} ${pg_name} | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from os import environ | from os import environ | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| __version__ = "2023.10.2" | __version__ = "2023.8.3" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Meta API""" | """Meta API""" | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ViewSet | from rest_framework.viewsets import ViewSet | ||||||
| @ -21,7 +21,7 @@ class AppSerializer(PassiveSerializer): | |||||||
| class AppsViewSet(ViewSet): | class AppsViewSet(ViewSet): | ||||||
|     """Read-only view list all installed apps""" |     """Read-only view list all installed apps""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAuthenticated] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: AppSerializer(many=True)}) |     @extend_schema(responses={200: AppSerializer(many=True)}) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
| @ -35,7 +35,7 @@ class AppsViewSet(ViewSet): | |||||||
| class ModelViewSet(ViewSet): | class ModelViewSet(ViewSet): | ||||||
|     """Read-only view list all installed models""" |     """Read-only view list all installed models""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAuthenticated] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: AppSerializer(many=True)}) |     @extend_schema(responses={200: AppSerializer(many=True)}) | ||||||
|     def list(self, request: Request) -> Response: |     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 drf_spectacular.utils import extend_schema, extend_schema_field | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.fields import IntegerField, SerializerMethodField | 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.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
| @ -68,7 +68,7 @@ class LoginMetricsSerializer(PassiveSerializer): | |||||||
| class AdministrationMetricsViewSet(APIView): | class AdministrationMetricsViewSet(APIView): | ||||||
|     """Login Metrics per 1h""" |     """Login Metrics per 1h""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAuthenticated] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: LoginMetricsSerializer(many=False)}) |     @extend_schema(responses={200: LoginMetricsSerializer(many=False)}) | ||||||
|     def get(self, request: Request) -> Response: |     def get(self, request: Request) -> Response: | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ from django.utils.timezone import now | |||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from gunicorn import version_info as gunicorn_version | from gunicorn import version_info as gunicorn_version | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
|  | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
| @ -16,7 +17,6 @@ from authentik.core.api.utils import PassiveSerializer | |||||||
| from authentik.lib.utils.reflection import get_env | from authentik.lib.utils.reflection import get_env | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST | from authentik.outposts.apps import MANAGED_OUTPOST | ||||||
| from authentik.outposts.models import Outpost | from authentik.outposts.models import Outpost | ||||||
| from authentik.rbac.permissions import HasPermission |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class RuntimeDict(TypedDict): | class RuntimeDict(TypedDict): | ||||||
| @ -88,7 +88,7 @@ class SystemSerializer(PassiveSerializer): | |||||||
| class SystemView(APIView): | class SystemView(APIView): | ||||||
|     """Get system information.""" |     """Get system information.""" | ||||||
|  |  | ||||||
|     permission_classes = [HasPermission("authentik_rbac.view_system_info")] |     permission_classes = [IsAdminUser] | ||||||
|     pagination_class = None |     pagination_class = None | ||||||
|     filter_backends = [] |     filter_backends = [] | ||||||
|     serializer_class = SystemSerializer |     serializer_class = SystemSerializer | ||||||
|  | |||||||
| @ -14,15 +14,14 @@ from rest_framework.fields import ( | |||||||
|     ListField, |     ListField, | ||||||
|     SerializerMethodField, |     SerializerMethodField, | ||||||
| ) | ) | ||||||
|  | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ViewSet | from rest_framework.viewsets import ViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus | from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus | ||||||
| from authentik.rbac.permissions import HasPermission |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -64,7 +63,7 @@ class TaskSerializer(PassiveSerializer): | |||||||
| class TaskViewSet(ViewSet): | class TaskViewSet(ViewSet): | ||||||
|     """Read-only view set that returns all background tasks""" |     """Read-only view set that returns all background tasks""" | ||||||
|  |  | ||||||
|     permission_classes = [HasPermission("authentik_rbac.view_system_tasks")] |     permission_classes = [IsAdminUser] | ||||||
|     serializer_class = TaskSerializer |     serializer_class = TaskSerializer | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
| @ -94,7 +93,6 @@ class TaskViewSet(ViewSet): | |||||||
|         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) |         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) | ||||||
|         return Response(TaskSerializer(tasks, many=True).data) |         return Response(TaskSerializer(tasks, many=True).data) | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_rbac.run_system_tasks"]) |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=OpenApiTypes.NONE, |         request=OpenApiTypes.NONE, | ||||||
|         responses={ |         responses={ | ||||||
|  | |||||||
| @ -2,18 +2,18 @@ | |||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from drf_spectacular.utils import extend_schema, inline_serializer | from drf_spectacular.utils import extend_schema, inline_serializer | ||||||
| from rest_framework.fields import IntegerField | from rest_framework.fields import IntegerField | ||||||
|  | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from authentik.rbac.permissions import HasPermission |  | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
|  |  | ||||||
| class WorkerView(APIView): | class WorkerView(APIView): | ||||||
|     """Get currently connected worker count.""" |     """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()})) |     @extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()})) | ||||||
|     def get(self, request: Request) -> Response: |     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.filters import BaseFilterBackend | ||||||
| from rest_framework.permissions import BasePermission | from rest_framework.permissions import BasePermission | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
|  | from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||||
|  |  | ||||||
| from authentik.api.authentication import validate_auth | from authentik.api.authentication import validate_auth | ||||||
| from authentik.rbac.filters import ObjectFilter |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class OwnerFilter(BaseFilterBackend): | class OwnerFilter(BaseFilterBackend): | ||||||
| @ -26,14 +26,14 @@ class OwnerFilter(BaseFilterBackend): | |||||||
| class SecretKeyFilter(DjangoFilterBackend): | class SecretKeyFilter(DjangoFilterBackend): | ||||||
|     """Allow access to all objects when authenticated with secret key as token. |     """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: |     def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: | ||||||
|         auth_header = get_authorization_header(request) |         auth_header = get_authorization_header(request) | ||||||
|         token = validate_auth(auth_header) |         token = validate_auth(auth_header) | ||||||
|         if token and token == settings.SECRET_KEY: |         if token and token == settings.SECRET_KEY: | ||||||
|             return queryset |             return queryset | ||||||
|         queryset = ObjectFilter().filter_queryset(request, queryset, view) |         queryset = ObjectPermissionsFilter().filter_queryset(request, queryset, view) | ||||||
|         return super().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() | 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""" |     """Check permissions for a single custom action""" | ||||||
|  |  | ||||||
|     def wrapper_outter(func: Callable): |     def wrapper_outter(func: Callable): | ||||||
| @ -18,17 +18,15 @@ def permission_required(obj_perm: Optional[str] = None, global_perms: Optional[l | |||||||
|  |  | ||||||
|         @wraps(func) |         @wraps(func) | ||||||
|         def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response: |         def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response: | ||||||
|             if obj_perm: |             if perm: | ||||||
|                 obj = self.get_object() |                 obj = self.get_object() | ||||||
|                 if not request.user.has_perm(obj_perm, obj): |                 if not request.user.has_perm(perm, obj): | ||||||
|                     LOGGER.debug( |                     LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj) | ||||||
|                         "denying access for object", user=request.user, perm=obj_perm, obj=obj |  | ||||||
|                     ) |  | ||||||
|                     return self.permission_denied(request) |                     return self.permission_denied(request) | ||||||
|             if global_perms: |             if other_perms: | ||||||
|                 for other_perm in global_perms: |                 for other_perm in other_perms: | ||||||
|                     if not request.user.has_perm(other_perm): |                     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 self.permission_denied(request) | ||||||
|             return func(self, request, *args, **kwargs) |             return func(self, request, *args, **kwargs) | ||||||
|  |  | ||||||
|  | |||||||
| @ -77,10 +77,3 @@ class Pagination(pagination.PageNumberPagination): | |||||||
|             }, |             }, | ||||||
|             "required": ["pagination", "results"], |             "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): |     def tester(self: TestModelViewSets): | ||||||
|         self.assertIsNotNone(getattr(test_viewset, "search_fields", None)) |         self.assertIsNotNone(getattr(test_viewset, "search_fields", None)) | ||||||
|         self.assertIsNotNone(getattr(test_viewset, "ordering", None)) |  | ||||||
|         filterset_class = getattr(test_viewset, "filterset_class", None) |         filterset_class = getattr(test_viewset, "filterset_class", None) | ||||||
|         if not filterset_class: |         if not filterset_class: | ||||||
|             self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None)) |             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.decorators import action | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.fields import CharField, DateTimeField, JSONField | from rest_framework.fields import CharField, DateTimeField, JSONField | ||||||
|  | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ListSerializer, ModelSerializer | from rest_framework.serializers import ListSerializer, ModelSerializer | ||||||
| @ -86,11 +87,11 @@ class BlueprintInstanceSerializer(ModelSerializer): | |||||||
| class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet): | class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Blueprint instances""" |     """Blueprint instances""" | ||||||
|  |  | ||||||
|  |     permission_classes = [IsAdminUser] | ||||||
|     serializer_class = BlueprintInstanceSerializer |     serializer_class = BlueprintInstanceSerializer | ||||||
|     queryset = BlueprintInstance.objects.all() |     queryset = BlueprintInstance.objects.all() | ||||||
|     search_fields = ["name", "path"] |     search_fields = ["name", "path"] | ||||||
|     filterset_fields = ["name", "path"] |     filterset_fields = ["name", "path"] | ||||||
|     ordering = ["name"] |  | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|  | |||||||
| @ -6,7 +6,6 @@ from django.test import TestCase | |||||||
|  |  | ||||||
| from authentik.blueprints.v1.importer import is_model_allowed | from authentik.blueprints.v1.importer import is_model_allowed | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
| from authentik.providers.oauth2.models import RefreshToken |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestModels(TestCase): | class TestModels(TestCase): | ||||||
| @ -22,9 +21,6 @@ def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable: | |||||||
|         model_class = test_model() |         model_class = test_model() | ||||||
|         self.assertTrue(isinstance(model_class, SerializerModel)) |         self.assertTrue(isinstance(model_class, SerializerModel)) | ||||||
|         self.assertIsNotNone(model_class.serializer) |         self.assertIsNotNone(model_class.serializer) | ||||||
|         if model_class.serializer.Meta().model == RefreshToken: |  | ||||||
|             return |  | ||||||
|         self.assertEqual(model_class.serializer.Meta().model, test_model) |  | ||||||
|  |  | ||||||
|     return tester |     return tester | ||||||
|  |  | ||||||
|  | |||||||
| @ -584,17 +584,12 @@ class EntryInvalidError(SentryIgnoredException): | |||||||
|     entry_model: Optional[str] |     entry_model: Optional[str] | ||||||
|     entry_id: Optional[str] |     entry_id: Optional[str] | ||||||
|     validation_error: Optional[ValidationError] |     validation_error: Optional[ValidationError] | ||||||
|     serializer: Optional[Serializer] = None |  | ||||||
|  |  | ||||||
|     def __init__( |     def __init__(self, *args: object, validation_error: Optional[ValidationError] = None) -> None: | ||||||
|         self, *args: object, validation_error: Optional[ValidationError] = None, **kwargs |  | ||||||
|     ) -> None: |  | ||||||
|         super().__init__(*args) |         super().__init__(*args) | ||||||
|         self.entry_model = None |         self.entry_model = None | ||||||
|         self.entry_id = None |         self.entry_id = None | ||||||
|         self.validation_error = validation_error |         self.validation_error = validation_error | ||||||
|         for key, value in kwargs.items(): |  | ||||||
|             setattr(self, key, value) |  | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def from_entry( |     def from_entry( | ||||||
|  | |||||||
| @ -35,28 +35,25 @@ from authentik.core.models import ( | |||||||
|     Source, |     Source, | ||||||
|     UserSourceConnection, |     UserSourceConnection, | ||||||
| ) | ) | ||||||
| from authentik.enterprise.models import LicenseUsage |  | ||||||
| from authentik.events.utils import cleanse_dict | from authentik.events.utils import cleanse_dict | ||||||
| from authentik.flows.models import FlowToken, Stage | from authentik.flows.models import FlowToken, Stage | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.outposts.models import OutpostServiceConnection | from authentik.outposts.models import OutpostServiceConnection | ||||||
| from authentik.policies.models import Policy, PolicyBindingModel | from authentik.policies.models import Policy, PolicyBindingModel | ||||||
| from authentik.providers.scim.models import SCIMGroup, SCIMUser |  | ||||||
|  |  | ||||||
| # Context set when the serializer is created in a blueprint context | # Context set when the serializer is created in a blueprint context | ||||||
| # Update website/developer-docs/blueprints/v1/models.md when used | # Update website/developer-docs/blueprints/v1/models.md when used | ||||||
| SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry" | SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry" | ||||||
|  |  | ||||||
|  |  | ||||||
| def excluded_models() -> list[type[Model]]: | def is_model_allowed(model: type[Model]) -> bool: | ||||||
|     """Return a list of all excluded models that shouldn't be exposed via API |     """Check if model is allowed""" | ||||||
|     or other means (internal only, base classes, non-used objects, etc)""" |  | ||||||
|     # pylint: disable=imported-auth-user |     # pylint: disable=imported-auth-user | ||||||
|     from django.contrib.auth.models import Group as DjangoGroup |     from django.contrib.auth.models import Group as DjangoGroup | ||||||
|     from django.contrib.auth.models import User as DjangoUser |     from django.contrib.auth.models import User as DjangoUser | ||||||
|  |  | ||||||
|     return ( |     excluded_models = ( | ||||||
|         DjangoUser, |         DjangoUser, | ||||||
|         DjangoGroup, |         DjangoGroup, | ||||||
|         # Base classes |         # Base classes | ||||||
| @ -72,15 +69,8 @@ def excluded_models() -> list[type[Model]]: | |||||||
|         AuthenticatedSession, |         AuthenticatedSession, | ||||||
|         # Classes which are only internally managed |         # Classes which are only internally managed | ||||||
|         FlowToken, |         FlowToken, | ||||||
|         LicenseUsage, |  | ||||||
|         SCIMGroup, |  | ||||||
|         SCIMUser, |  | ||||||
|     ) |     ) | ||||||
|  |     return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel)) | ||||||
|  |  | ||||||
| def is_model_allowed(model: type[Model]) -> bool: |  | ||||||
|     """Check if model is allowed""" |  | ||||||
|     return model not in excluded_models() and issubclass(model, (SerializerModel, BaseMetaModel)) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DoRollback(SentryIgnoredException): | class DoRollback(SentryIgnoredException): | ||||||
| @ -255,10 +245,7 @@ class Importer: | |||||||
|         try: |         try: | ||||||
|             full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import)) |             full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import)) | ||||||
|         except ValueError as exc: |         except ValueError as exc: | ||||||
|             raise EntryInvalidError.from_entry( |             raise EntryInvalidError.from_entry(exc, entry) from exc | ||||||
|                 exc, |  | ||||||
|                 entry, |  | ||||||
|             ) from exc |  | ||||||
|         always_merger.merge(full_data, updated_identifiers) |         always_merger.merge(full_data, updated_identifiers) | ||||||
|         serializer_kwargs["data"] = full_data |         serializer_kwargs["data"] = full_data | ||||||
|  |  | ||||||
| @ -275,7 +262,6 @@ class Importer: | |||||||
|                 f"Serializer errors {serializer.errors}", |                 f"Serializer errors {serializer.errors}", | ||||||
|                 validation_error=exc, |                 validation_error=exc, | ||||||
|                 entry=entry, |                 entry=entry, | ||||||
|                 serializer=serializer, |  | ||||||
|             ) from exc |             ) from exc | ||||||
|         return serializer |         return serializer | ||||||
|  |  | ||||||
| @ -304,14 +290,12 @@ class Importer: | |||||||
|                 ) |                 ) | ||||||
|                 return False |                 return False | ||||||
|             # Validate each single entry |             # Validate each single entry | ||||||
|             serializer = None |  | ||||||
|             try: |             try: | ||||||
|                 serializer = self._validate_single(entry) |                 serializer = self._validate_single(entry) | ||||||
|             except EntryInvalidError as exc: |             except EntryInvalidError as exc: | ||||||
|                 # For deleting objects we don't need the serializer to be valid |                 # For deleting objects we don't need the serializer to be valid | ||||||
|                 if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT: |                 if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT: | ||||||
|                     serializer = exc.serializer |                     continue | ||||||
|                 else: |  | ||||||
|                 self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) |                 self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) | ||||||
|                 if raise_errors: |                 if raise_errors: | ||||||
|                     raise exc |                     raise exc | ||||||
|  | |||||||
| @ -82,7 +82,7 @@ class BlueprintEventHandler(FileSystemEventHandler): | |||||||
|             path = Path(event.src_path) |             path = Path(event.src_path) | ||||||
|             root = Path(CONFIG.get("blueprints_dir")).absolute() |             root = Path(CONFIG.get("blueprints_dir")).absolute() | ||||||
|             rel_path = str(path.relative_to(root)) |             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) |                 LOGGER.debug("modified blueprint file, starting apply", instance=instance) | ||||||
|                 apply_blueprint.delay(instance.pk.hex) |                 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.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  | from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| from structlog.testing import capture_logs | 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.api.exec import PolicyTestResultSerializer | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.types import PolicyResult | from authentik.policies.types import PolicyResult | ||||||
| from authentik.rbac.filters import ObjectFilter |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -98,7 +98,6 @@ class ApplicationSerializer(ModelSerializer): | |||||||
| class ApplicationViewSet(UsedByMixin, ModelViewSet): | class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Application Viewset""" |     """Application Viewset""" | ||||||
|  |  | ||||||
|     # pylint: disable=no-member |  | ||||||
|     queryset = Application.objects.all().prefetch_related("provider") |     queryset = Application.objects.all().prefetch_related("provider") | ||||||
|     serializer_class = ApplicationSerializer |     serializer_class = ApplicationSerializer | ||||||
|     search_fields = [ |     search_fields = [ | ||||||
| @ -123,7 +122,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: |     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" |         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||||
|         for backend in list(self.filter_backends): |         for backend in list(self.filter_backends): | ||||||
|             if backend == ObjectFilter: |             if backend == ObjectPermissionsFilter: | ||||||
|                 continue |                 continue | ||||||
|             queryset = backend().filter_queryset(self.request, queryset, self) |             queryset = backend().filter_queryset(self.request, queryset, self) | ||||||
|         return queryset |         return queryset | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ | |||||||
| from json import loads | from json import loads | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
|  | from django.db.models.query import QuerySet | ||||||
| from django.http import Http404 | from django.http import Http404 | ||||||
| from django_filters.filters import CharFilter, ModelMultipleChoiceFilter | from django_filters.filters import CharFilter, ModelMultipleChoiceFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| @ -13,12 +14,12 @@ from rest_framework.request import Request | |||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError | from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  | from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer, is_dict | from authentik.core.api.utils import PassiveSerializer, is_dict | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| from authentik.rbac.api.roles import RoleSerializer |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupMemberSerializer(ModelSerializer): | class GroupMemberSerializer(ModelSerializer): | ||||||
| @ -48,12 +49,6 @@ class GroupSerializer(ModelSerializer): | |||||||
|     users_obj = ListSerializer( |     users_obj = ListSerializer( | ||||||
|         child=GroupMemberSerializer(), read_only=True, source="users", required=False |         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) |     parent_name = CharField(source="parent.name", read_only=True, allow_null=True) | ||||||
|  |  | ||||||
|     num_pk = IntegerField(read_only=True) |     num_pk = IntegerField(read_only=True) | ||||||
| @ -76,10 +71,8 @@ class GroupSerializer(ModelSerializer): | |||||||
|             "parent", |             "parent", | ||||||
|             "parent_name", |             "parent_name", | ||||||
|             "users", |             "users", | ||||||
|             "users_obj", |  | ||||||
|             "attributes", |             "attributes", | ||||||
|             "roles", |             "users_obj", | ||||||
|             "roles_obj", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "users": { |             "users": { | ||||||
| @ -139,13 +132,25 @@ class UserAccountSerializer(PassiveSerializer): | |||||||
| class GroupViewSet(UsedByMixin, ModelViewSet): | class GroupViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Group Viewset""" |     """Group Viewset""" | ||||||
|  |  | ||||||
|     # pylint: disable=no-member |  | ||||||
|     queryset = Group.objects.all().select_related("parent").prefetch_related("users") |     queryset = Group.objects.all().select_related("parent").prefetch_related("users") | ||||||
|     serializer_class = GroupSerializer |     serializer_class = GroupSerializer | ||||||
|     search_fields = ["name", "is_superuser"] |     search_fields = ["name", "is_superuser"] | ||||||
|     filterset_class = GroupFilter |     filterset_class = GroupFilter | ||||||
|     ordering = ["name"] |     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"]) |     @permission_required(None, ["authentik_core.add_user"]) | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=UserAccountSerializer, |         request=UserAccountSerializer, | ||||||
|  | |||||||
| @ -119,7 +119,6 @@ class TransactionApplicationResponseSerializer(PassiveSerializer): | |||||||
| class TransactionalApplicationView(APIView): | class TransactionalApplicationView(APIView): | ||||||
|     """Create provider and application and attach them in a single transaction""" |     """Create provider and application and attach them in a single transaction""" | ||||||
|  |  | ||||||
|     # TODO: Migrate to a more specific permission |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|  | |||||||
| @ -73,11 +73,6 @@ class UsedByMixin: | |||||||
|             # but so we only apply them once, have a simple flag for the first object |             # but so we only apply them once, have a simple flag for the first object | ||||||
|             first_object = True |             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( |             for obj in get_objects_for_user( | ||||||
|                 request.user, f"{app}.view_{model_name}", manager |                 request.user, f"{app}.view_{model_name}", manager | ||||||
|             ).all(): |             ).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.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models.functions import ExtractHour | from django.db.models.functions import ExtractHour | ||||||
|  | from django.db.models.query import QuerySet | ||||||
| from django.db.transaction import atomic | from django.db.transaction import atomic | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| @ -51,6 +52,7 @@ from rest_framework.serializers import ( | |||||||
| ) | ) | ||||||
| from rest_framework.validators import UniqueValidator | from rest_framework.validators import UniqueValidator | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  | from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.admin.api.metrics import CoordinateSerializer | from authentik.admin.api.metrics import CoordinateSerializer | ||||||
| @ -188,7 +190,6 @@ class UserSerializer(ModelSerializer): | |||||||
|             "uid", |             "uid", | ||||||
|             "path", |             "path", | ||||||
|             "type", |             "type", | ||||||
|             "uuid", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "name": {"allow_blank": True}, |             "name": {"allow_blank": True}, | ||||||
| @ -203,7 +204,6 @@ class UserSelfSerializer(ModelSerializer): | |||||||
|     groups = SerializerMethodField() |     groups = SerializerMethodField() | ||||||
|     uid = CharField(read_only=True) |     uid = CharField(read_only=True) | ||||||
|     settings = SerializerMethodField() |     settings = SerializerMethodField() | ||||||
|     system_permissions = SerializerMethodField() |  | ||||||
|  |  | ||||||
|     @extend_schema_field( |     @extend_schema_field( | ||||||
|         ListSerializer( |         ListSerializer( | ||||||
| @ -225,14 +225,6 @@ class UserSelfSerializer(ModelSerializer): | |||||||
|         """Get user settings with tenant and group settings applied""" |         """Get user settings with tenant and group settings applied""" | ||||||
|         return user.group_attributes(self._context["request"]).get("settings", {}) |         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: |     class Meta: | ||||||
|         model = User |         model = User | ||||||
|         fields = [ |         fields = [ | ||||||
| @ -247,7 +239,6 @@ class UserSelfSerializer(ModelSerializer): | |||||||
|             "uid", |             "uid", | ||||||
|             "settings", |             "settings", | ||||||
|             "type", |             "type", | ||||||
|             "system_permissions", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "is_active": {"read_only": True}, |             "is_active": {"read_only": True}, | ||||||
| @ -662,6 +653,19 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|         return Response(status=204) |         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( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: inline_serializer( |             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""" | """authentik core models""" | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
| from hashlib import sha256 | from hashlib import sha256 | ||||||
| from typing import Any, Optional, Self | from typing import Any, Optional | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from deepmerge import always_merger | from deepmerge import always_merger | ||||||
| @ -88,8 +88,6 @@ class Group(SerializerModel): | |||||||
|         default=False, help_text=_("Users added to this group will be superusers.") |         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( |     parent = models.ForeignKey( | ||||||
|         "Group", |         "Group", | ||||||
|         blank=True, |         blank=True, | ||||||
| @ -117,38 +115,6 @@ class Group(SerializerModel): | |||||||
|         """Recursively check if `user` is member of us, or any parent.""" |         """Recursively check if `user` is member of us, or any parent.""" | ||||||
|         return user.all_groups().filter(group_uuid=self.group_uuid).exists() |         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): |     def __str__(self): | ||||||
|         return f"Group {self.name}" |         return f"Group {self.name}" | ||||||
|  |  | ||||||
| @ -159,8 +125,6 @@ class Group(SerializerModel): | |||||||
|                 "parent", |                 "parent", | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|         verbose_name = _("Group") |  | ||||||
|         verbose_name_plural = _("Groups") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserManager(DjangoUserManager): | class UserManager(DjangoUserManager): | ||||||
| @ -196,7 +160,33 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): | |||||||
|         """Recursively get all groups this user is a member of. |         """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 |         At least one query is done to get the direct groups of the user, with groups | ||||||
|         there are at most 3 queries done""" |         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]: |     def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]: | ||||||
|         """Get a dictionary containing the attributes from all groups the user belongs to, |         """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) |         return get_avatar(self) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |         permissions = ( | ||||||
|  |             ("reset_user_password", "Reset Password"), | ||||||
|  |             ("impersonate", "Can impersonate other users"), | ||||||
|  |         ) | ||||||
|         verbose_name = _("User") |         verbose_name = _("User") | ||||||
|         verbose_name_plural = _("Users") |         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): | class Provider(SerializerModel): | ||||||
| @ -587,6 +575,23 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel): | |||||||
|         unique_together = (("user", "source"),) |         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): | class ExpiringModel(models.Model): | ||||||
|     """Base Model which can expire, and is automatically cleaned up.""" |     """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=["identifier"]), | ||||||
|             models.Index(fields=["key"]), |             models.Index(fields=["key"]), | ||||||
|         ] |         ] | ||||||
|         permissions = [("view_token_key", _("View token's key"))] |         permissions = (("view_token_key", "View token's key"),) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PropertyMapping(SerializerModel, ManagedModel): | 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.db.models.signals import post_save, pre_delete, pre_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from structlog.stdlib import get_logger |  | ||||||
|  |  | ||||||
| from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider, User | 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 | # Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage | ||||||
| login_failed = Signal() | login_failed = Signal() | ||||||
|  |  | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save, sender=Application) | @receiver(post_save, sender=Application) | ||||||
| def post_save_application(sender: type[Model], instance, created: bool, **_): | def post_save_application(sender: type[Model], instance, created: bool, **_): | ||||||
|  | |||||||
| @ -97,7 +97,6 @@ class SourceFlowManager: | |||||||
|         if self.request.user.is_authenticated: |         if self.request.user.is_authenticated: | ||||||
|             new_connection.user = self.request.user |             new_connection.user = self.request.user | ||||||
|             new_connection = self.update_connection(new_connection, **kwargs) |             new_connection = self.update_connection(new_connection, **kwargs) | ||||||
|             # pylint: disable=no-member |  | ||||||
|             new_connection.save() |             new_connection.save() | ||||||
|             return Action.LINK, new_connection |             return Action.LINK, new_connection | ||||||
|  |  | ||||||
|  | |||||||
| @ -16,8 +16,8 @@ You've logged out of {{ application }}. | |||||||
| {% block card %} | {% block card %} | ||||||
| <form method="POST" class="pf-c-form"> | <form method="POST" class="pf-c-form"> | ||||||
|     <p> |     <p> | ||||||
|         {% blocktrans with application=application.name branding_title=tenant.branding_title %} |         {% 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 {{ branding_title }} account. |             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 %} |         {% endblocktrans %} | ||||||
|     </p> |     </p> | ||||||
|  |  | ||||||
|  | |||||||
| @ -21,9 +21,10 @@ def create_test_flow( | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_test_user(name: Optional[str] = None, **kwargs) -> User: | def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User: | ||||||
|     """Generate a test user""" |     """Generate a test-admin user""" | ||||||
|     uid = generate_id(20) if not name else name |     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("email", f"{uid}@goauthentik.io") | ||||||
|     kwargs.setdefault("username", uid) |     kwargs.setdefault("username", uid) | ||||||
|     user: User = User.objects.create( |     user: User = User.objects.create( | ||||||
| @ -32,13 +33,6 @@ def create_test_user(name: Optional[str] = None, **kwargs) -> User: | |||||||
|     ) |     ) | ||||||
|     user.set_password(uid) |     user.set_password(uid) | ||||||
|     user.save() |     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) |     group.users.add(user) | ||||||
|     return user |     return user | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,10 +1,13 @@ | |||||||
| """authentik crypto app config""" | """authentik crypto app config""" | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from typing import Optional | from typing import TYPE_CHECKING, Optional | ||||||
|  |  | ||||||
| from authentik.blueprints.apps import ManagedAppConfig | from authentik.blueprints.apps import ManagedAppConfig | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
|  |  | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from authentik.crypto.models import CertificateKeyPair | ||||||
|  |  | ||||||
| MANAGED_KEY = "goauthentik.io/crypto/jwt-managed" | MANAGED_KEY = "goauthentik.io/crypto/jwt-managed" | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -20,37 +23,33 @@ class AuthentikCryptoConfig(ManagedAppConfig): | |||||||
|         """Load crypto tasks""" |         """Load crypto tasks""" | ||||||
|         self.import_module("authentik.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.builder import CertificateBuilder | ||||||
|         from authentik.crypto.models import CertificateKeyPair |         from authentik.crypto.models import CertificateKeyPair | ||||||
|  |  | ||||||
|         common_name = "authentik Internal JWT Certificate" |         builder = CertificateBuilder("authentik Internal JWT Certificate") | ||||||
|         builder = CertificateBuilder(common_name) |  | ||||||
|         builder.build( |         builder.build( | ||||||
|             subject_alt_names=["goauthentik.io"], |             subject_alt_names=["goauthentik.io"], | ||||||
|             validity_days=360, |             validity_days=360, | ||||||
|         ) |         ) | ||||||
|         CertificateKeyPair.objects.update_or_create( |         if not cert: | ||||||
|             managed=MANAGED_KEY, |             cert = CertificateKeyPair() | ||||||
|             defaults={ |         builder.cert = cert | ||||||
|                 "name": common_name, |         builder.cert.managed = MANAGED_KEY | ||||||
|                 "certificate_data": builder.certificate, |         builder.save() | ||||||
|                 "key_data": builder.private_key, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def reconcile_managed_jwt_cert(self): |     def reconcile_managed_jwt_cert(self): | ||||||
|         """Ensure managed JWT certificate""" |         """Ensure managed JWT certificate""" | ||||||
|         from authentik.crypto.models import CertificateKeyPair |         from authentik.crypto.models import CertificateKeyPair | ||||||
|  |  | ||||||
|         cert: Optional[CertificateKeyPair] = CertificateKeyPair.objects.filter( |         certs = CertificateKeyPair.objects.filter(managed=MANAGED_KEY) | ||||||
|             managed=MANAGED_KEY |         if not certs.exists(): | ||||||
|         ).first() |  | ||||||
|         now = datetime.now() |  | ||||||
|         if not cert or ( |  | ||||||
|             now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after |  | ||||||
|         ): |  | ||||||
|             self._create_update_cert() |             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): |     def reconcile_self_signed(self): | ||||||
|         """Create self-signed keypair""" |         """Create self-signed keypair""" | ||||||
| @ -62,10 +61,4 @@ class AuthentikCryptoConfig(ManagedAppConfig): | |||||||
|             return |             return | ||||||
|         builder = CertificateBuilder(name) |         builder = CertificateBuilder(name) | ||||||
|         builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"]) |         builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"]) | ||||||
|         CertificateKeyPair.objects.get_or_create( |         builder.save() | ||||||
|             name=name, |  | ||||||
|             defaults={ |  | ||||||
|                 "certificate_data": builder.certificate, |  | ||||||
|                 "key_data": builder.private_key, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from drf_spectacular.types import OpenApiTypes | |||||||
| from drf_spectacular.utils import extend_schema, inline_serializer | from drf_spectacular.utils import extend_schema, inline_serializer | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField | 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.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| @ -84,7 +84,7 @@ class LicenseViewSet(UsedByMixin, ModelViewSet): | |||||||
|             200: inline_serializer("InstallIDSerializer", {"install_id": CharField(required=True)}), |             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: |     def get_install_id(self, request: Request) -> Response: | ||||||
|         """Get install_id""" |         """Get install_id""" | ||||||
|         return Response( |         return Response( | ||||||
|  | |||||||
| @ -33,8 +33,4 @@ class Migration(migrations.Migration): | |||||||
|                 "verbose_name_plural": "License Usage Records", |                 "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 guardian.shortcuts import get_anonymous_user | ||||||
| from jwt import PyJWTError, decode, get_unverified_header | from jwt import PyJWTError, decode, get_unverified_header | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.serializers import BaseSerializer |  | ||||||
|  |  | ||||||
| from authentik.core.models import ExpiringModel, User, UserTypes | from authentik.core.models import ExpiringModel, User, UserTypes | ||||||
| from authentik.lib.models import SerializerModel |  | ||||||
| from authentik.root.install_id import get_install_id | from authentik.root.install_id import get_install_id | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -136,9 +134,6 @@ class LicenseKey: | |||||||
|  |  | ||||||
|     def record_usage(self): |     def record_usage(self): | ||||||
|         """Capture the current validity status and metrics and save them""" |         """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( |         LicenseUsage.objects.create( | ||||||
|             user_count=self.get_default_user_count(), |             user_count=self.get_default_user_count(), | ||||||
|             external_user_count=self.get_external_user_count(), |             external_user_count=self.get_external_user_count(), | ||||||
| @ -156,7 +151,7 @@ class LicenseKey: | |||||||
|         return usage.record_date |         return usage.record_date | ||||||
|  |  | ||||||
|  |  | ||||||
| class License(SerializerModel): | class License(models.Model): | ||||||
|     """An authentik enterprise license""" |     """An authentik enterprise license""" | ||||||
|  |  | ||||||
|     license_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) |     license_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
| @ -167,12 +162,6 @@ class License(SerializerModel): | |||||||
|     internal_users = models.BigIntegerField() |     internal_users = models.BigIntegerField() | ||||||
|     external_users = models.BigIntegerField() |     external_users = models.BigIntegerField() | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[BaseSerializer]: |  | ||||||
|         from authentik.enterprise.api import LicenseSerializer |  | ||||||
|  |  | ||||||
|         return LicenseSerializer |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def status(self) -> LicenseKey: |     def status(self) -> LicenseKey: | ||||||
|         """Get parsed license status""" |         """Get parsed license status""" | ||||||
| @ -180,8 +169,6 @@ class License(SerializerModel): | |||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         indexes = (HashIndex(fields=("key",)),) |         indexes = (HashIndex(fields=("key",)),) | ||||||
|         verbose_name = _("License") |  | ||||||
|         verbose_name_plural = _("Licenses") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def usage_expiry(): | def usage_expiry(): | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from authentik.lib.utils.time import fqdn_rand | |||||||
| CELERY_BEAT_SCHEDULE = { | CELERY_BEAT_SCHEDULE = { | ||||||
|     "enterprise_calculate_license": { |     "enterprise_calculate_license": { | ||||||
|         "task": "authentik.enterprise.tasks.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"}, |         "options": {"queue": "authentik_scheduled"}, | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -6,4 +6,5 @@ from authentik.root.celery import CELERY_APP | |||||||
| @CELERY_APP.task() | @CELERY_APP.task() | ||||||
| def calculate_license(): | def calculate_license(): | ||||||
|     """Calculate licensing status""" |     """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]: |     def send_email(self, notification: "Notification") -> list[str]: | ||||||
|         """Send notification via global email configuration""" |         """Send notification via global email configuration""" | ||||||
|         subject_prefix = "authentik Notification: " |         subject = "authentik Notification: " | ||||||
|         context = { |         key_value = { | ||||||
|             "key_value": { |  | ||||||
|             "user_email": notification.user.email, |             "user_email": notification.user.email, | ||||||
|             "user_username": notification.user.username, |             "user_username": notification.user.username, | ||||||
|             }, |  | ||||||
|             "body": notification.body, |  | ||||||
|             "title": "", |  | ||||||
|         } |         } | ||||||
|         if notification.event and notification.event.user: |         if notification.event and notification.event.user: | ||||||
|             context["key_value"]["event_user_email"] = notification.event.user.get("email", None) |             key_value["event_user_email"] = notification.event.user.get("email", None) | ||||||
|             context["key_value"]["event_user_username"] = notification.event.user.get( |             key_value["event_user_username"] = notification.event.user.get("username", None) | ||||||
|                 "username", None |  | ||||||
|             ) |  | ||||||
|         if notification.event: |         if notification.event: | ||||||
|             context["title"] += notification.event.action |             subject += notification.event.action | ||||||
|             for key, value in notification.event.context.items(): |             for key, value in notification.event.context.items(): | ||||||
|                 if not isinstance(value, str): |                 if not isinstance(value, str): | ||||||
|                     continue |                     continue | ||||||
|                 context["key_value"][key] = value |                 key_value[key] = value | ||||||
|         else: |         else: | ||||||
|             context["title"] += notification.body[:75] |             subject += notification.body[:75] | ||||||
|         # TODO: improve permission check |  | ||||||
|         if notification.user.is_superuser: |  | ||||||
|             context["source"] = { |  | ||||||
|                 "from": self.name, |  | ||||||
|             } |  | ||||||
|         mail = TemplateEmailMessage( |         mail = TemplateEmailMessage( | ||||||
|             subject=subject_prefix + context["title"], |             subject=subject, | ||||||
|             to=[notification.user.email], |             to=[notification.user.email], | ||||||
|             language=notification.user.locale(), |             language=notification.user.locale(), | ||||||
|             template_name="email/event_notification.html", |             template_name="email/generic.html", | ||||||
|             template_context=context, |             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. |         # Email is sent directly here, as the call to send() should have been from a task. | ||||||
|         try: |         try: | ||||||
|  | |||||||
| @ -206,8 +206,8 @@ def prefill_task(func): | |||||||
|         task_call_module=func.__module__, |         task_call_module=func.__module__, | ||||||
|         task_call_func=func.__name__, |         task_call_func=func.__name__, | ||||||
|         # We don't have real values for these attributes but they cannot be null |         # We don't have real values for these attributes but they cannot be null | ||||||
|         start_timestamp=0, |         start_timestamp=default_timer(), | ||||||
|         finish_timestamp=0, |         finish_timestamp=default_timer(), | ||||||
|         finish_time=datetime.now(), |         finish_time=datetime.now(), | ||||||
|     ).save(86400) |     ).save(86400) | ||||||
|     LOGGER.debug("prefilled task", task_name=func.__name__) |     LOGGER.debug("prefilled task", task_name=func.__name__) | ||||||
|  | |||||||
| @ -2,7 +2,6 @@ | |||||||
| import re | import re | ||||||
| from copy import copy | from copy import copy | ||||||
| from dataclasses import asdict, is_dataclass | from dataclasses import asdict, is_dataclass | ||||||
| from datetime import date, datetime, time, timedelta |  | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from types import GeneratorType | from types import GeneratorType | ||||||
| @ -14,7 +13,6 @@ from django.core.handlers.wsgi import WSGIRequest | |||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models.base import Model | from django.db.models.base import Model | ||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from django.utils import timezone |  | ||||||
| from django.views.debug import SafeExceptionReporterFilter | from django.views.debug import SafeExceptionReporterFilter | ||||||
| from geoip2.models import City | from geoip2.models import City | ||||||
| from guardian.utils import get_anonymous_user | 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 |     return user_data | ||||||
|  |  | ||||||
|  |  | ||||||
| # pylint: disable=too-many-return-statements,too-many-branches | # pylint: disable=too-many-return-statements | ||||||
| def sanitize_item(value: Any) -> Any: | def sanitize_item(value: Any) -> Any: | ||||||
|     """Sanitize a single item, ensure it is JSON parsable""" |     """Sanitize a single item, ensure it is JSON parsable""" | ||||||
|     if is_dataclass(value): |     if is_dataclass(value): | ||||||
| @ -136,23 +134,6 @@ def sanitize_item(value: Any) -> Any: | |||||||
|             "type": value.__name__, |             "type": value.__name__, | ||||||
|             "module": value.__module__, |             "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 |     return value | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -45,4 +45,3 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet): | |||||||
|     serializer_class = FlowStageBindingSerializer |     serializer_class = FlowStageBindingSerializer | ||||||
|     filterset_fields = "__all__" |     filterset_fields = "__all__" | ||||||
|     search_fields = ["stage__name"] |     search_fields = ["stage__name"] | ||||||
|     ordering = ["order"] |  | ||||||
|  | |||||||
| @ -8,11 +8,6 @@ GAUGE_FLOWS_CACHED = Gauge( | |||||||
|     "authentik_flows_cached", |     "authentik_flows_cached", | ||||||
|     "Cached flows", |     "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( | HIST_FLOWS_PLAN_TIME = Histogram( | ||||||
|     "authentik_flows_plan_time", |     "authentik_flows_plan_time", | ||||||
|     "Duration to build a plan for a flow", |     "Duration to build a plan for a flow", | ||||||
|  | |||||||
| @ -132,6 +132,13 @@ class PermissionDict(TypedDict): | |||||||
|     name: str |     name: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PermissionSerializer(PassiveSerializer): | ||||||
|  |     """Permission used for consent""" | ||||||
|  |  | ||||||
|  |     name = CharField(allow_blank=True) | ||||||
|  |     id = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
| class ChallengeResponse(PassiveSerializer): | class ChallengeResponse(PassiveSerializer): | ||||||
|     """Base class for all challenge responses""" |     """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") |         verbose_name_plural = _("Flows") | ||||||
|  |  | ||||||
|         permissions = [ |         permissions = [ | ||||||
|             ("export_flow", _("Can export a Flow")), |             ("export_flow", "Can export a Flow"), | ||||||
|             ("inspect_flow", _("Can inspect a Flow's execution")), |             ("view_flow_cache", "View Flow's cache metrics"), | ||||||
|             ("view_flow_cache", _("View Flow's cache metrics")), |             ("clear_flow_cache", "Clear 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.core.models import Application | ||||||
| from authentik.events.models import Event, EventAction, cleanse_dict | from authentik.events.models import Event, EventAction, cleanse_dict | ||||||
| from authentik.flows.apps import HIST_FLOW_EXECUTION_STAGE_TIME |  | ||||||
| from authentik.flows.challenge import ( | from authentik.flows.challenge import ( | ||||||
|     Challenge, |     Challenge, | ||||||
|     ChallengeResponse, |     ChallengeResponse, | ||||||
| @ -267,21 +266,17 @@ class FlowExecutorView(APIView): | |||||||
|     ) |     ) | ||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         """Get the next pending challenge from the currently active flow.""" |         """Get the next pending challenge from the currently active flow.""" | ||||||
|         class_path = class_to_path(self.current_stage_view.__class__) |  | ||||||
|         self._logger.debug( |         self._logger.debug( | ||||||
|             "f(exec): Passing GET", |             "f(exec): Passing GET", | ||||||
|             view_class=class_path, |             view_class=class_to_path(self.current_stage_view.__class__), | ||||||
|             stage=self.current_stage, |             stage=self.current_stage, | ||||||
|         ) |         ) | ||||||
|         try: |         try: | ||||||
|             with Hub.current.start_span( |             with Hub.current.start_span( | ||||||
|                 op="authentik.flow.executor.stage", |                 op="authentik.flow.executor.stage", | ||||||
|                 description=class_path, |                 description=class_to_path(self.current_stage_view.__class__), | ||||||
|             ) as span, HIST_FLOW_EXECUTION_STAGE_TIME.labels( |             ) as span: | ||||||
|                 method=request.method.upper(), |                 span.set_data("Method", "GET") | ||||||
|                 stage_type=class_path, |  | ||||||
|             ).time(): |  | ||||||
|                 span.set_data("Method", request.method.upper()) |  | ||||||
|                 span.set_data("authentik Stage", self.current_stage_view) |                 span.set_data("authentik Stage", self.current_stage_view) | ||||||
|                 span.set_data("authentik Flow", self.flow.slug) |                 span.set_data("authentik Flow", self.flow.slug) | ||||||
|                 stage_response = self.current_stage_view.dispatch(request) |                 stage_response = self.current_stage_view.dispatch(request) | ||||||
| @ -315,21 +310,17 @@ class FlowExecutorView(APIView): | |||||||
|     ) |     ) | ||||||
|     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         """Solve the previously retrieved challenge and advanced to the next stage.""" |         """Solve the previously retrieved challenge and advanced to the next stage.""" | ||||||
|         class_path = class_to_path(self.current_stage_view.__class__) |  | ||||||
|         self._logger.debug( |         self._logger.debug( | ||||||
|             "f(exec): Passing POST", |             "f(exec): Passing POST", | ||||||
|             view_class=class_path, |             view_class=class_to_path(self.current_stage_view.__class__), | ||||||
|             stage=self.current_stage, |             stage=self.current_stage, | ||||||
|         ) |         ) | ||||||
|         try: |         try: | ||||||
|             with Hub.current.start_span( |             with Hub.current.start_span( | ||||||
|                 op="authentik.flow.executor.stage", |                 op="authentik.flow.executor.stage", | ||||||
|                 description=class_path, |                 description=class_to_path(self.current_stage_view.__class__), | ||||||
|             ) as span, HIST_FLOW_EXECUTION_STAGE_TIME.labels( |             ) as span: | ||||||
|                 method=request.method.upper(), |                 span.set_data("Method", "POST") | ||||||
|                 stage_type=class_path, |  | ||||||
|             ).time(): |  | ||||||
|                 span.set_data("Method", request.method.upper()) |  | ||||||
|                 span.set_data("authentik Stage", self.current_stage_view) |                 span.set_data("authentik Stage", self.current_stage_view) | ||||||
|                 span.set_data("authentik Flow", self.flow.slug) |                 span.set_data("authentik Flow", self.flow.slug) | ||||||
|                 stage_response = self.current_stage_view.dispatch(request) |                 stage_response = self.current_stage_view.dispatch(request) | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ from hashlib import sha256 | |||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.http import Http404 |  | ||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from django.http.response import HttpResponse | from django.http.response import HttpResponse | ||||||
| from django.shortcuts import get_object_or_404 | 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.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
| from rest_framework.fields import BooleanField, ListField, SerializerMethodField | from rest_framework.fields import BooleanField, ListField, SerializerMethodField | ||||||
|  | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
| @ -68,19 +68,21 @@ class FlowInspectionSerializer(PassiveSerializer): | |||||||
| class FlowInspectorView(APIView): | class FlowInspectorView(APIView): | ||||||
|     """Flow inspector API""" |     """Flow inspector API""" | ||||||
|  |  | ||||||
|  |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     flow: Flow |     flow: Flow | ||||||
|     _logger: BoundLogger |     _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): |     def setup(self, request: HttpRequest, flow_slug: str): | ||||||
|         super().setup(request, flow_slug=flow_slug) |         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) |         self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) | ||||||
|         if settings.DEBUG: |         self._logger = get_logger().bind(flow_slug=flow_slug) | ||||||
|             return |  | ||||||
|         if request.user.has_perm("authentik_flow.inspect_flow", self.flow): |  | ||||||
|             return |  | ||||||
|         raise Http404 |  | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={ |         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: | 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""" |     If at any point a dict does not exist, return default""" | ||||||
|     for comp in path.split(sep): |     for comp in path.split(sep): | ||||||
|         if root and comp in root: |         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 |     return root | ||||||
|  |  | ||||||
|  |  | ||||||
| def set_path_in_dict(root: dict, path: str, value: Any, sep="."): | @dataclass | ||||||
|     """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) |  | ||||||
| class Attr: | class Attr: | ||||||
|     """Single configuration attribute""" |     """Single configuration attribute""" | ||||||
|  |  | ||||||
| @ -67,10 +55,6 @@ class Attr: | |||||||
|     # to the config file containing this change or the file containing this value |     # to the config file containing this change or the file containing this value | ||||||
|     source: Optional[str] = field(default=None) |     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): | class AttrEncoder(JSONEncoder): | ||||||
|     """JSON encoder that can deal with `Attr` classes""" |     """JSON encoder that can deal with `Attr` classes""" | ||||||
| @ -243,7 +227,15 @@ class ConfigLoader: | |||||||
|  |  | ||||||
|     def set(self, path: str, value: Any, sep="."): |     def set(self, path: str, value: Any, sep="."): | ||||||
|         """Set value using same syntax as get()""" |         """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() | CONFIG = ConfigLoader() | ||||||
|  | |||||||
| @ -141,7 +141,7 @@ class BaseEvaluator: | |||||||
|         """Create event with supplied data and try to extract as much relevant data |         """Create event with supplied data and try to extract as much relevant data | ||||||
|         from the context""" |         from the context""" | ||||||
|         context = self._context.copy() |         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("result", None) | ||||||
|         context.pop("handler", None) |         context.pop("handler", None) | ||||||
|         event_kwargs = context |         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 enum import IntEnum | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
| 
 | 
 | ||||||
| from asgiref.sync import async_to_sync |  | ||||||
| from channels.exceptions import DenyConnection | from channels.exceptions import DenyConnection | ||||||
| from dacite.core import from_dict | from dacite.core import from_dict | ||||||
| from dacite.data import Data | 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.apps import GAUGE_OUTPOSTS_CONNECTED, GAUGE_OUTPOSTS_LAST_UPDATE | ||||||
| from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState | from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState | ||||||
| 
 | 
 | ||||||
| OUTPOST_GROUP = "group_outpost_%(outpost_pk)s" |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| class WebsocketMessageInstruction(IntEnum): | class WebsocketMessageInstruction(IntEnum): | ||||||
|     """Commands which can be triggered over Websocket""" |     """Commands which can be triggered over Websocket""" | ||||||
| @ -30,9 +27,6 @@ class WebsocketMessageInstruction(IntEnum): | |||||||
|     # Message sent by us to trigger an Update |     # Message sent by us to trigger an Update | ||||||
|     TRIGGER_UPDATE = 2 |     TRIGGER_UPDATE = 2 | ||||||
| 
 | 
 | ||||||
|     # Provider specific message |  | ||||||
|     PROVIDER_SPECIFIC = 3 |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| @dataclass(slots=True) | @dataclass(slots=True) | ||||||
| class WebsocketMessage: | class WebsocketMessage: | ||||||
| @ -50,6 +44,8 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
| 
 | 
 | ||||||
|     last_uid: Optional[str] = None |     last_uid: Optional[str] = None | ||||||
| 
 | 
 | ||||||
|  |     first_msg = False | ||||||
|  | 
 | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|         self.logger = get_logger() |         self.logger = get_logger() | ||||||
| @ -72,26 +68,22 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|             raise DenyConnection() |             raise DenyConnection() | ||||||
|         self.outpost = outpost |         self.outpost = outpost | ||||||
|         self.last_uid = self.channel_name |         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): |     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: |         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( |             GAUGE_OUTPOSTS_CONNECTED.labels( | ||||||
|                 outpost=self.outpost.name, |                 outpost=self.outpost.name, | ||||||
|                 uid=self.last_uid, |                 uid=self.last_uid, | ||||||
|                 expected=self.outpost.config.kubernetes_replicas, |                 expected=self.outpost.config.kubernetes_replicas, | ||||||
|             ).dec() |             ).dec() | ||||||
|  |         self.logger.debug( | ||||||
|  |             "removed outpost instance from cache", | ||||||
|  |             instance_uuid=self.last_uid, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     def receive_json(self, content: Data): |     def receive_json(self, content: Data): | ||||||
|         msg = from_dict(WebsocketMessage, content) |         msg = from_dict(WebsocketMessage, content) | ||||||
| @ -102,13 +94,26 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|             raise DenyConnection() |             raise DenyConnection() | ||||||
| 
 | 
 | ||||||
|         state = OutpostState.for_instance_uid(self.outpost, uid) |         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.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: |         if msg.instruction == WebsocketMessageInstruction.HELLO: | ||||||
|             state.version = msg.args.pop("version", None) |             state.version = msg.args.get("version", None) | ||||||
|             state.build_hash = msg.args.pop("buildHash", "") |             state.build_hash = msg.args.get("buildHash", "") | ||||||
|             state.args = msg.args |  | ||||||
|         elif msg.instruction == WebsocketMessageInstruction.ACK: |         elif msg.instruction == WebsocketMessageInstruction.ACK: | ||||||
|             return |             return | ||||||
|         GAUGE_OUTPOSTS_LAST_UPDATE.labels( |         GAUGE_OUTPOSTS_LAST_UPDATE.labels( | ||||||
| @ -126,14 +131,3 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|         self.send_json( |         self.send_json( | ||||||
|             asdict(WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE)) |             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", |                 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, |                 managed=managed, | ||||||
|             ) |             ) | ||||||
|         except IntegrityError: |         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(managed=managed).delete() | ||||||
|             Token.objects.filter(identifier=self.token_identifier).delete() |             Token.objects.filter(identifier=self.token_identifier).delete() | ||||||
|             return self.token |             return self.token | ||||||
| @ -405,22 +405,18 @@ class Outpost(SerializerModel, ManagedModel): | |||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Outpost {self.name}" |         return f"Outpost {self.name}" | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Outpost") |  | ||||||
|         verbose_name_plural = _("Outposts") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass | @dataclass | ||||||
| class OutpostState: | class OutpostState: | ||||||
|     """Outpost instance state, last_seen and version""" |     """Outpost instance state, last_seen and version""" | ||||||
|  |  | ||||||
|     uid: str |     uid: str | ||||||
|  |     channel_ids: list[str] = field(default_factory=list) | ||||||
|     last_seen: Optional[datetime] = field(default=None) |     last_seen: Optional[datetime] = field(default=None) | ||||||
|     version: Optional[str] = field(default=None) |     version: Optional[str] = field(default=None) | ||||||
|     version_should: Version = field(default=OUR_VERSION) |     version_should: Version = field(default=OUR_VERSION) | ||||||
|     build_hash: str = field(default="") |     build_hash: str = field(default="") | ||||||
|     hostname: str = field(default="") |     hostname: str = field(default="") | ||||||
|     args: dict = field(default_factory=dict) |  | ||||||
|  |  | ||||||
|     _outpost: Optional[Outpost] = field(default=None) |     _outpost: Optional[Outpost] = field(default=None) | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ from socket import gethostname | |||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
| from urllib.parse import urlparse | from urllib.parse import urlparse | ||||||
|  |  | ||||||
|  | import yaml | ||||||
| from asgiref.sync import async_to_sync | from asgiref.sync import async_to_sync | ||||||
| from channels.layers import get_channel_layer | from channels.layers import get_channel_layer | ||||||
| from django.core.cache import cache | 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.incluster_config import SERVICE_TOKEN_FILENAME | ||||||
| from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION | from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| from yaml import safe_load |  | ||||||
|  |  | ||||||
| from authentik.events.monitored_tasks import ( | from authentik.events.monitored_tasks import ( | ||||||
|     MonitoredTask, |     MonitoredTask, | ||||||
| @ -25,7 +25,6 @@ from authentik.events.monitored_tasks import ( | |||||||
| ) | ) | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.reflection import path_to_class | 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.base import BaseController, ControllerException | ||||||
| from authentik.outposts.controllers.docker import DockerClient | from authentik.outposts.controllers.docker import DockerClient | ||||||
| from authentik.outposts.controllers.kubernetes import KubernetesClient | from authentik.outposts.controllers.kubernetes import KubernetesClient | ||||||
| @ -35,6 +34,7 @@ from authentik.outposts.models import ( | |||||||
|     Outpost, |     Outpost, | ||||||
|     OutpostModel, |     OutpostModel, | ||||||
|     OutpostServiceConnection, |     OutpostServiceConnection, | ||||||
|  |     OutpostState, | ||||||
|     OutpostType, |     OutpostType, | ||||||
|     ServiceConnectionInvalid, |     ServiceConnectionInvalid, | ||||||
| ) | ) | ||||||
| @ -243,9 +243,10 @@ def _outpost_single_update(outpost: Outpost, layer=None): | |||||||
|     outpost.build_user_permissions(outpost.user) |     outpost.build_user_permissions(outpost.user) | ||||||
|     if not layer:  # pragma: no cover |     if not layer:  # pragma: no cover | ||||||
|         layer = get_channel_layer() |         layer = get_channel_layer() | ||||||
|     group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)} |     for state in OutpostState.for_outpost(outpost): | ||||||
|     LOGGER.debug("sending update", channel=group, outpost=outpost) |         for channel in state.channel_ids: | ||||||
|     async_to_sync(layer.group_send)(group, {"type": "event.update"}) |             LOGGER.debug("sending update", channel=channel, instance=state.uid, outpost=outpost) | ||||||
|  |             async_to_sync(layer.send)(channel, {"type": "event.update"}) | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task( | @CELERY_APP.task( | ||||||
| @ -278,7 +279,7 @@ def outpost_connection_discovery(self: MonitoredTask): | |||||||
|             with kubeconfig_path.open("r", encoding="utf8") as _kubeconfig: |             with kubeconfig_path.open("r", encoding="utf8") as _kubeconfig: | ||||||
|                 KubernetesServiceConnection.objects.create( |                 KubernetesServiceConnection.objects.create( | ||||||
|                     name=kubeconfig_local_name, |                     name=kubeconfig_local_name, | ||||||
|                     kubeconfig=safe_load(_kubeconfig), |                     kubeconfig=yaml.safe_load(_kubeconfig), | ||||||
|                 ) |                 ) | ||||||
|     unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path |     unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path | ||||||
|     socket = Path(unix_socket_path) |     socket = Path(unix_socket_path) | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ from django.test import TransactionTestCase | |||||||
|  |  | ||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.core.tests.utils import create_test_flow | 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.outposts.models import Outpost, OutpostType | ||||||
| from authentik.providers.proxy.models import ProxyProvider | from authentik.providers.proxy.models import ProxyProvider | ||||||
| from authentik.root import websocket | from authentik.root import websocket | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ from authentik.outposts.api.service_connections import ( | |||||||
|     KubernetesServiceConnectionViewSet, |     KubernetesServiceConnectionViewSet, | ||||||
|     ServiceConnectionViewSet, |     ServiceConnectionViewSet, | ||||||
| ) | ) | ||||||
| from authentik.outposts.consumer import OutpostConsumer | from authentik.outposts.channels import OutpostConsumer | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
|  |  | ||||||
| websocket_urlpatterns = [ | websocket_urlpatterns = [ | ||||||
|  | |||||||
| @ -7,11 +7,7 @@ GAUGE_POLICIES_CACHED = Gauge( | |||||||
|     "authentik_policies_cached", |     "authentik_policies_cached", | ||||||
|     "Cached Policies", |     "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( | HIST_POLICIES_EXECUTION_TIME = Histogram( | ||||||
|     "authentik_policies_execution_time", |     "authentik_policies_execution_time", | ||||||
|     "Execution times for single policies", |     "Execution times for single policies", | ||||||
| @ -21,7 +17,6 @@ HIST_POLICIES_EXECUTION_TIME = Histogram( | |||||||
|         "binding_target_name", |         "binding_target_name", | ||||||
|         "object_pk", |         "object_pk", | ||||||
|         "object_type", |         "object_type", | ||||||
|         "mode", |  | ||||||
|     ], |     ], | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """authentik policy engine""" | """authentik policy engine""" | ||||||
| from multiprocessing import Pipe, current_process | from multiprocessing import Pipe, current_process | ||||||
| from multiprocessing.connection import Connection | from multiprocessing.connection import Connection | ||||||
| from timeit import default_timer |  | ||||||
| from typing import Iterator, Optional | from typing import Iterator, Optional | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| @ -11,8 +10,6 @@ from sentry_sdk.tracing import Span | |||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import User | 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.exceptions import PolicyEngineException | ||||||
| from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode | from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode | ||||||
| from authentik.policies.process import PolicyProcess, cache_key | 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: |         if binding.policy is not None and binding.policy.__class__ == Policy: | ||||||
|             raise PolicyEngineException(f"Policy '{binding.policy}' is root type") |             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": |     def build(self) -> "PolicyEngine": | ||||||
|         """Build wrapper which monitors performance""" |         """Build wrapper which monitors performance""" | ||||||
|         with ( |         with ( | ||||||
| @ -114,10 +84,6 @@ class PolicyEngine: | |||||||
|                 op="authentik.policy.engine.build", |                 op="authentik.policy.engine.build", | ||||||
|                 description=self.__pbm, |                 description=self.__pbm, | ||||||
|             ) as span, |             ) 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: Span | ||||||
|             span.set_data("pbm", self.__pbm) |             span.set_data("pbm", self.__pbm) | ||||||
| @ -126,7 +92,16 @@ class PolicyEngine: | |||||||
|                 self.__expected_result_count += 1 |                 self.__expected_result_count += 1 | ||||||
|  |  | ||||||
|                 self._check_policy_type(binding) |                 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 |                     continue | ||||||
|                 self.logger.debug("P_ENG: Evaluating policy", binding=binding, request=self.request) |                 self.logger.debug("P_ENG: Evaluating policy", binding=binding, request=self.request) | ||||||
|                 our_end, task_end = Pipe(False) |                 our_end, task_end = Pipe(False) | ||||||
|  | |||||||
| @ -190,8 +190,8 @@ class Policy(SerializerModel, CreatedUpdatedModel): | |||||||
|         verbose_name_plural = _("Policies") |         verbose_name_plural = _("Policies") | ||||||
|  |  | ||||||
|         permissions = [ |         permissions = [ | ||||||
|             ("view_policy_cache", _("View Policy's cache metrics")), |             ("view_policy_cache", "View Policy's cache metrics"), | ||||||
|             ("clear_policy_cache", _("Clear Policy's cache metrics")), |             ("clear_policy_cache", "Clear Policy's cache metrics"), | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     class PolicyMeta: |     class PolicyMeta: | ||||||
|  | |||||||
| @ -11,7 +11,6 @@ from structlog.stdlib import get_logger | |||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.errors import exception_to_string | 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.apps import HIST_POLICIES_EXECUTION_TIME | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
| @ -129,8 +128,9 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|                 binding_target_type=self.binding.target_type, |                 binding_target_type=self.binding.target_type, | ||||||
|                 binding_target_name=self.binding.target_name, |                 binding_target_name=self.binding.target_name, | ||||||
|                 object_pk=str(self.request.obj.pk), |                 object_pk=str(self.request.obj.pk), | ||||||
|                 object_type=class_to_path(self.request.obj.__class__), |                 object_type=( | ||||||
|                 mode="execute_process", |                     f"{self.request.obj._meta.app_label}.{self.request.obj._meta.model_name}" | ||||||
|  |                 ), | ||||||
|             ).time(), |             ).time(), | ||||||
|         ): |         ): | ||||||
|             span: Span |             span: Span | ||||||
|  | |||||||
| @ -17,7 +17,7 @@ LOGGER = get_logger() | |||||||
| @receiver(monitoring_set) | @receiver(monitoring_set) | ||||||
| def monitoring_set_policies(sender, **kwargs): | def monitoring_set_policies(sender, **kwargs): | ||||||
|     """set policy gauges""" |     """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) | @receiver(post_save, sender=Policy) | ||||||
|  | |||||||
| @ -9,7 +9,3 @@ class AuthentikProviderProxyConfig(ManagedAppConfig): | |||||||
|     label = "authentik_providers_proxy" |     label = "authentik_providers_proxy" | ||||||
|     verbose_name = "authentik Providers.Proxy" |     verbose_name = "authentik Providers.Proxy" | ||||||
|     default = True |     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""" | """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 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.providers.proxy.models import ProxyProvider | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
| @ -17,19 +13,3 @@ def proxy_set_defaults(): | |||||||
|     for provider in ProxyProvider.objects.all(): |     for provider in ProxyProvider.objects.all(): | ||||||
|         provider.set_oauth_defaults() |         provider.set_oauth_defaults() | ||||||
|         provider.save() |         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 |             # an admin might have to view it | ||||||
|             "shared_secret", |             "shared_secret", | ||||||
|             "outpost_set", |             "outpost_set", | ||||||
|             "mfa_support", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = ProviderSerializer.Meta.extra_kwargs |         extra_kwargs = ProviderSerializer.Meta.extra_kwargs | ||||||
|  |  | ||||||
| @ -56,7 +55,6 @@ class RadiusOutpostConfigSerializer(ModelSerializer): | |||||||
|             "auth_flow_slug", |             "auth_flow_slug", | ||||||
|             "client_networks", |             "client_networks", | ||||||
|             "shared_secret", |             "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 |     @property | ||||||
|     def launch_url(self) -> Optional[str]: |     def launch_url(self) -> Optional[str]: | ||||||
|         """Radius never has a launch URL""" |         """Radius never has a launch URL""" | ||||||
|  | |||||||
| @ -146,7 +146,6 @@ class SAMLProviderSerializer(ProviderSerializer): | |||||||
|             "signing_kp", |             "signing_kp", | ||||||
|             "verification_kp", |             "verification_kp", | ||||||
|             "sp_binding", |             "sp_binding", | ||||||
|             "default_relay_state", |  | ||||||
|             "url_download_metadata", |             "url_download_metadata", | ||||||
|             "url_sso_post", |             "url_sso_post", | ||||||
|             "url_sso_redirect", |             "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"), |         verbose_name=_("Signing Keypair"), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     default_relay_state = models.TextField( |  | ||||||
|         default="", blank=True, help_text=_("Default relay_state value for IDP-initiated logins") |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def launch_url(self) -> Optional[str]: |     def launch_url(self) -> Optional[str]: | ||||||
|         """Use IDP-Initiated SAML flow as launch URL""" |         """Use IDP-Initiated SAML flow as launch URL""" | ||||||
|  | |||||||
| @ -175,7 +175,4 @@ class AuthNRequestParser: | |||||||
|  |  | ||||||
|     def idp_initiated(self) -> AuthNRequest: |     def idp_initiated(self) -> AuthNRequest: | ||||||
|         """Create IdP Initiated AuthNRequest""" |         """Create IdP Initiated AuthNRequest""" | ||||||
|         relay_state = None |         return AuthNRequest() | ||||||
|         if self.provider.default_relay_state != "": |  | ||||||
|             relay_state = self.provider.default_relay_state |  | ||||||
|         return AuthNRequest(relay_state=relay_state) |  | ||||||
|  | |||||||
| @ -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.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.generators import generate_id |  | ||||||
| from authentik.lib.tests.utils import get_request | from authentik.lib.tests.utils import get_request | ||||||
| from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider | from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider | ||||||
| from authentik.providers.saml.processors.assertion import AssertionProcessor | from authentik.providers.saml.processors.assertion import AssertionProcessor | ||||||
| @ -265,10 +264,3 @@ class TestAuthNRequest(TestCase): | |||||||
|             events.first().context["message"], |             events.first().context["message"], | ||||||
|             "Failed to evaluate property-mapping: 'test'", |             "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
	