Compare commits
	
		
			50 Commits
		
	
	
		
			version-20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1a21479b0d | |||
| 38154f72e0 | |||
| 19318d4c00 | |||
| be3d7c0666 | |||
| 5afceaa55f | |||
| 72dc27f1c9 | |||
| b5ffd16861 | |||
| 8af754e88c | |||
| ade1f08c89 | |||
| 9240fa1037 | |||
| 1f5953b5b7 | |||
| 5befccc1fd | |||
| ff193d809a | |||
| 23bbb6e5ef | |||
| 225d02d02d | |||
| 90fe1eda66 | |||
| 35ba88a203 | |||
| 8414a9dcad | |||
| 1d626f5b57 | |||
| 508dd0ac64 | |||
| f4b82a8b09 | |||
| 2900f01976 | |||
| 0f6ece5eb7 | |||
| b9936fe532 | |||
| d0b3cc5916 | |||
| e034f5e5dc | |||
| 9d6816bbc8 | |||
| 82d4ea9e8a | |||
| c8a804f2a7 | |||
| ca70c963e5 | |||
| 4c89d4a4a4 | |||
| 8a47acac3a | |||
| 4a3b22491c | |||
| f991d656c7 | |||
| e86aa11131 | |||
| 03725ae086 | |||
| f2a37e8c7c | |||
| e935690b1b | |||
| 02709e4ede | |||
| f78adab9d1 | |||
| 61f3a72fd9 | |||
| 541becfe30 | |||
| 11ff7955f7 | |||
| afa4234036 | |||
| ca22a4deaf | |||
| 7b7a3d34ec | |||
| b1ca579397 | |||
| c8072579c8 | |||
| 378a701fb9 | |||
| bba793d94c | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2024.6.5 | current_version = 2024.4.4 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||||
| @ -17,8 +17,6 @@ optional_value = final | |||||||
|  |  | ||||||
| [bumpversion:file:pyproject.toml] | [bumpversion:file:pyproject.toml] | ||||||
|  |  | ||||||
| [bumpversion:file:package.json] |  | ||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [bumpversion:file:docker-compose.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:schema.yml] | [bumpversion:file:schema.yml] | ||||||
|  | |||||||
| @ -54,9 +54,9 @@ image_main_tag = image_tags[0] | |||||||
| image_tags_rendered = ",".join(image_tags) | image_tags_rendered = ",".join(image_tags) | ||||||
|  |  | ||||||
| with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output: | with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output: | ||||||
|     print(f"shouldBuild={should_build}", file=_output) |     print("shouldBuild=%s" % should_build, file=_output) | ||||||
|     print(f"sha={sha}", file=_output) |     print("sha=%s" % sha, file=_output) | ||||||
|     print(f"version={version}", file=_output) |     print("version=%s" % version, file=_output) | ||||||
|     print(f"prerelease={prerelease}", file=_output) |     print("prerelease=%s" % prerelease, file=_output) | ||||||
|     print(f"imageTags={image_tags_rendered}", file=_output) |     print("imageTags=%s" % image_tags_rendered, file=_output) | ||||||
|     print(f"imageMainTag={image_main_tag}", file=_output) |     print("imageMainTag=%s" % image_main_tag, file=_output) | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/actions/setup/docker-compose.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/setup/docker-compose.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,3 +1,5 @@ | |||||||
|  | version: "3.7" | ||||||
|  |  | ||||||
| services: | services: | ||||||
|   postgresql: |   postgresql: | ||||||
|     image: docker.io/library/postgres:${PSQL_TAG:-16} |     image: docker.io/library/postgres:${PSQL_TAG:-16} | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/codespell-words.txt
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/codespell-words.txt
									
									
									
									
										vendored
									
									
								
							| @ -4,4 +4,3 @@ hass | |||||||
| warmup | warmup | ||||||
| ontext | ontext | ||||||
| singed | singed | ||||||
| assertIn |  | ||||||
|  | |||||||
							
								
								
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -21,10 +21,7 @@ updates: | |||||||
|     labels: |     labels: | ||||||
|       - dependencies |       - dependencies | ||||||
|   - package-ecosystem: npm |   - package-ecosystem: npm | ||||||
|     directories: |     directory: "/web" | ||||||
|       - "/web" |  | ||||||
|       - "/tests/wdio" |  | ||||||
|       - "/web/sfe" |  | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
|       time: "04:00" |       time: "04:00" | ||||||
| @ -33,6 +30,7 @@ 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: | ||||||
| @ -58,6 +56,38 @@ updates: | |||||||
|         patterns: |         patterns: | ||||||
|           - "@rollup/*" |           - "@rollup/*" | ||||||
|           - "rollup-*" |           - "rollup-*" | ||||||
|  |   - package-ecosystem: npm | ||||||
|  |     directory: "/tests/wdio" | ||||||
|  |     schedule: | ||||||
|  |       interval: daily | ||||||
|  |       time: "04:00" | ||||||
|  |     labels: | ||||||
|  |       - dependencies | ||||||
|  |     open-pull-requests-limit: 10 | ||||||
|  |     commit-message: | ||||||
|  |       prefix: "web:" | ||||||
|  |     # TODO: deduplicate these groups | ||||||
|  |     groups: | ||||||
|  |       sentry: | ||||||
|  |         patterns: | ||||||
|  |           - "@sentry/*" | ||||||
|  |           - "@spotlightjs/*" | ||||||
|  |       babel: | ||||||
|  |         patterns: | ||||||
|  |           - "@babel/*" | ||||||
|  |           - "babel-*" | ||||||
|  |       eslint: | ||||||
|  |         patterns: | ||||||
|  |           - "@typescript-eslint/*" | ||||||
|  |           - "eslint" | ||||||
|  |           - "eslint-*" | ||||||
|  |       storybook: | ||||||
|  |         patterns: | ||||||
|  |           - "@storybook/*" | ||||||
|  |           - "*storybook*" | ||||||
|  |       esbuild: | ||||||
|  |         patterns: | ||||||
|  |           - "@esbuild/*" | ||||||
|       wdio: |       wdio: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@wdio/*" |           - "@wdio/*" | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -31,12 +31,7 @@ jobs: | |||||||
|         env: |         env: | ||||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} |           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} | ||||||
|       - name: Upgrade /web |       - name: Upgrade /web | ||||||
|         working-directory: web |         working-directory: web/ | ||||||
|         run: | |  | ||||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` |  | ||||||
|           npm i @goauthentik/api@$VERSION |  | ||||||
|       - name: Upgrade /web/sfe |  | ||||||
|         working-directory: web/sfe |  | ||||||
|         run: | |         run: | | ||||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` |           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||||
|           npm i @goauthentik/api@$VERSION |           npm i @goauthentik/api@$VERSION | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -50,6 +50,7 @@ jobs: | |||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         psql: |         psql: | ||||||
|  |           - 12-alpine | ||||||
|           - 15-alpine |           - 15-alpine | ||||||
|           - 16-alpine |           - 16-alpine | ||||||
|     steps: |     steps: | ||||||
| @ -103,6 +104,7 @@ jobs: | |||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         psql: |         psql: | ||||||
|  |           - 12-alpine | ||||||
|           - 15-alpine |           - 15-alpine | ||||||
|           - 16-alpine |           - 16-alpine | ||||||
|     steps: |     steps: | ||||||
| @ -128,7 +130,7 @@ jobs: | |||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - name: Create k8s Kind Cluster |       - name: Create k8s Kind Cluster | ||||||
|         uses: helm/kind-action@v1.10.0 |         uses: helm/kind-action@v1.9.0 | ||||||
|       - name: run integration |       - name: run integration | ||||||
|         run: | |         run: | | ||||||
|           poetry run coverage run manage.py test tests/integration |           poetry run coverage run manage.py test tests/integration | ||||||
| @ -250,8 +252,8 @@ jobs: | |||||||
|           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|           build-args: | |           build-args: | | ||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache |           cache-from: type=gha | ||||||
|           cache-to: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max |           cache-to: type=gha,mode=max | ||||||
|           platforms: linux/${{ matrix.arch }} |           platforms: linux/${{ matrix.arch }} | ||||||
|   pr-comment: |   pr-comment: | ||||||
|     needs: |     needs: | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -29,7 +29,7 @@ jobs: | |||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-client-go |         run: make gen-client-go | ||||||
|       - name: golangci-lint |       - name: golangci-lint | ||||||
|         uses: golangci/golangci-lint-action@v6 |         uses: golangci/golangci-lint-action@v4 | ||||||
|         with: |         with: | ||||||
|           version: v1.54.2 |           version: v1.54.2 | ||||||
|           args: --timeout 5000s --verbose |           args: --timeout 5000s --verbose | ||||||
| @ -105,8 +105,8 @@ jobs: | |||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache |           cache-from: type=gha | ||||||
|           cache-to: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache,mode=max |           cache-to: type=gha,mode=max | ||||||
|   build-binary: |   build-binary: | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
|     needs: |     needs: | ||||||
|  | |||||||
							
								
								
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -20,16 +20,6 @@ jobs: | |||||||
|         project: |         project: | ||||||
|           - web |           - web | ||||||
|           - tests/wdio |           - tests/wdio | ||||||
|         include: |  | ||||||
|           - command: tsc |  | ||||||
|             project: web |  | ||||||
|             extra_setup: | |  | ||||||
|               cd sfe/ && npm ci |  | ||||||
|         exclude: |  | ||||||
|           - command: lint:lockfile |  | ||||||
|             project: tests/wdio |  | ||||||
|           - command: tsc |  | ||||||
|             project: tests/wdio |  | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -155,8 +155,8 @@ jobs: | |||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - name: Run test suite in final docker images |       - name: Run test suite in final docker images | ||||||
|         run: | |         run: | | ||||||
|           echo "PG_PASS=$(openssl rand 32 | base64)" >> .env |           echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
|           echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64)" >> .env |           echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env | ||||||
|           docker compose pull -q |           docker compose pull -q | ||||||
|           docker compose up --no-start |           docker compose up --no-start | ||||||
|           docker compose start postgresql redis |           docker compose start postgresql redis | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -14,8 +14,8 @@ jobs: | |||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - name: Pre-release test |       - name: Pre-release test | ||||||
|         run: | |         run: | | ||||||
|           echo "PG_PASS=$(openssl rand 32 | base64)" >> .env |           echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
|           echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64)" >> .env |           echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env | ||||||
|           docker buildx install |           docker buildx install | ||||||
|           mkdir -p ./gen-ts-api |           mkdir -p ./gen-ts-api | ||||||
|           docker build -t testing:latest . |           docker build -t testing:latest . | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -4,21 +4,20 @@ | |||||||
|         "asgi", |         "asgi", | ||||||
|         "authentik", |         "authentik", | ||||||
|         "authn", |         "authn", | ||||||
|         "entra", |  | ||||||
|         "goauthentik", |         "goauthentik", | ||||||
|         "jwks", |         "jwks", | ||||||
|         "kubernetes", |  | ||||||
|         "oidc", |         "oidc", | ||||||
|         "openid", |         "openid", | ||||||
|         "passwordless", |  | ||||||
|         "plex", |         "plex", | ||||||
|         "saml", |         "saml", | ||||||
|         "scim", |  | ||||||
|         "slo", |  | ||||||
|         "sso", |  | ||||||
|         "totp", |         "totp", | ||||||
|         "traefik", |  | ||||||
|         "webauthn", |         "webauthn", | ||||||
|  |         "traefik", | ||||||
|  |         "passwordless", | ||||||
|  |         "kubernetes", | ||||||
|  |         "sso", | ||||||
|  |         "slo", | ||||||
|  |         "scim", | ||||||
|     ], |     ], | ||||||
|     "todo-tree.tree.showCountsInTree": true, |     "todo-tree.tree.showCountsInTree": true, | ||||||
|     "todo-tree.tree.showBadges": true, |     "todo-tree.tree.showBadges": true, | ||||||
|  | |||||||
							
								
								
									
										46
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										46
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| # syntax=docker/dockerfile:1 | # syntax=docker/dockerfile:1 | ||||||
|  |  | ||||||
| # Stage 1: Build website | # Stage 1: Build website | ||||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:22 as website-builder | FROM --platform=${BUILDPLATFORM} docker.io/node:21 as website-builder | ||||||
|  |  | ||||||
| ENV NODE_ENV=production | ENV NODE_ENV=production | ||||||
|  |  | ||||||
| @ -20,35 +20,25 @@ COPY ./SECURITY.md /work/ | |||||||
| RUN npm run build-bundled | RUN npm run build-bundled | ||||||
|  |  | ||||||
| # Stage 2: Build webui | # Stage 2: Build webui | ||||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:22 as web-builder | FROM --platform=${BUILDPLATFORM} docker.io/node:21 as web-builder | ||||||
|  |  | ||||||
| ARG GIT_BUILD_HASH |  | ||||||
| ENV GIT_BUILD_HASH=$GIT_BUILD_HASH |  | ||||||
| ENV NODE_ENV=production | ENV NODE_ENV=production | ||||||
|  |  | ||||||
| WORKDIR /work/web | WORKDIR /work/web | ||||||
|  |  | ||||||
| RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | ||||||
|     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ |     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ | ||||||
|     --mount=type=bind,target=/work/web/sfe/package.json,src=./web/sfe/package.json \ |  | ||||||
|     --mount=type=bind,target=/work/web/sfe/package-lock.json,src=./web/sfe/package-lock.json \ |  | ||||||
|     --mount=type=bind,target=/work/web/scripts,src=./web/scripts \ |  | ||||||
|     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ |     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ | ||||||
|     npm ci --include=dev && \ |  | ||||||
|     cd sfe && \ |  | ||||||
|     npm ci --include=dev |     npm ci --include=dev | ||||||
|  |  | ||||||
| COPY ./package.json /work |  | ||||||
| COPY ./web /work/web/ | COPY ./web /work/web/ | ||||||
| COPY ./website /work/website/ | COPY ./website /work/website/ | ||||||
| COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | ||||||
|  |  | ||||||
| RUN npm run build && \ | RUN npm run build | ||||||
|     cd sfe && \ |  | ||||||
|     npm run build |  | ||||||
|  |  | ||||||
| # Stage 3: Build go proxy | # Stage 3: Build go proxy | ||||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder | FROM --platform=${BUILDPLATFORM} docker.io/golang:1.22.2-bookworm AS go-builder | ||||||
|  |  | ||||||
| ARG TARGETOS | ARG TARGETOS | ||||||
| ARG TARGETARCH | ARG TARGETARCH | ||||||
| @ -59,11 +49,6 @@ ARG GOARCH=$TARGETARCH | |||||||
|  |  | ||||||
| WORKDIR /go/src/goauthentik.io | WORKDIR /go/src/goauthentik.io | ||||||
|  |  | ||||||
| RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ |  | ||||||
|     dpkg --add-architecture arm64 && \ |  | ||||||
|     apt-get update && \ |  | ||||||
|     apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu |  | ||||||
|  |  | ||||||
| RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \ | RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \ | ||||||
|     --mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \ |     --mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \ | ||||||
|     --mount=type=cache,target=/go/pkg/mod \ |     --mount=type=cache,target=/go/pkg/mod \ | ||||||
| @ -78,11 +63,11 @@ COPY ./internal /go/src/goauthentik.io/internal | |||||||
| COPY ./go.mod /go/src/goauthentik.io/go.mod | COPY ./go.mod /go/src/goauthentik.io/go.mod | ||||||
| COPY ./go.sum /go/src/goauthentik.io/go.sum | COPY ./go.sum /go/src/goauthentik.io/go.sum | ||||||
|  |  | ||||||
|  | ENV CGO_ENABLED=0 | ||||||
|  |  | ||||||
| RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | ||||||
|     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ |     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ | ||||||
|     if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ |     GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server | ||||||
|     CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \ |  | ||||||
|     go build -o /go/authentik ./cmd/server |  | ||||||
|  |  | ||||||
| # Stage 4: MaxMind GeoIP | # Stage 4: MaxMind GeoIP | ||||||
| FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip | FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip | ||||||
| @ -99,7 +84,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | |||||||
|     /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" |     /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" | ||||||
|  |  | ||||||
| # Stage 5: Python dependencies | # Stage 5: Python dependencies | ||||||
| FROM ghcr.io/goauthentik/fips-python:3.12.3-slim-bookworm-fips-full AS python-deps | FROM docker.io/python:3.12.3-slim-bookworm AS python-deps | ||||||
|  |  | ||||||
| WORKDIR /ak-root/poetry | WORKDIR /ak-root/poetry | ||||||
|  |  | ||||||
| @ -112,7 +97,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloa | |||||||
| RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ | RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ | ||||||
|     apt-get update && \ |     apt-get update && \ | ||||||
|     # Required for installing pip packages |     # Required for installing pip packages | ||||||
|     apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev |     apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev libpq-dev | ||||||
|  |  | ||||||
| RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \ | RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \ | ||||||
|     --mount=type=bind,target=./poetry.lock,src=./poetry.lock \ |     --mount=type=bind,target=./poetry.lock,src=./poetry.lock \ | ||||||
| @ -120,13 +105,12 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \ | |||||||
|     --mount=type=cache,target=/root/.cache/pypoetry \ |     --mount=type=cache,target=/root/.cache/pypoetry \ | ||||||
|     python -m venv /ak-root/venv/ && \ |     python -m venv /ak-root/venv/ && \ | ||||||
|     bash -c "source ${VENV_PATH}/bin/activate && \ |     bash -c "source ${VENV_PATH}/bin/activate && \ | ||||||
|     pip3 install --upgrade pip && \ |         pip3 install --upgrade pip && \ | ||||||
|     pip3 install poetry && \ |         pip3 install poetry && \ | ||||||
|     poetry install --only=main --no-ansi --no-interaction --no-root && \ |         poetry install --only=main --no-ansi --no-interaction --no-root" | ||||||
|     pip install --force-reinstall /wheels/*" |  | ||||||
|  |  | ||||||
| # Stage 6: Run | # Stage 6: Run | ||||||
| FROM ghcr.io/goauthentik/fips-python:3.12.3-slim-bookworm-fips-full AS final-image | FROM docker.io/python:3.12.3-slim-bookworm AS final-image | ||||||
|  |  | ||||||
| ARG GIT_BUILD_HASH | ARG GIT_BUILD_HASH | ||||||
| ARG VERSION | ARG VERSION | ||||||
| @ -143,7 +127,7 @@ WORKDIR / | |||||||
| # We cannot cache this layer otherwise we'll end up with a bigger image | # We cannot cache this layer otherwise we'll end up with a bigger image | ||||||
| RUN apt-get update && \ | RUN apt-get update && \ | ||||||
|     # Required for runtime |     # Required for runtime | ||||||
|     apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates && \ |     apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 ca-certificates && \ | ||||||
|     # Required for bootstrap & healtcheck |     # Required for bootstrap & healtcheck | ||||||
|     apt-get install -y --no-install-recommends runit && \ |     apt-get install -y --no-install-recommends runit && \ | ||||||
|     apt-get clean && \ |     apt-get clean && \ | ||||||
| @ -179,8 +163,6 @@ ENV TMPDIR=/dev/shm/ \ | |||||||
|     VENV_PATH="/ak-root/venv" \ |     VENV_PATH="/ak-root/venv" \ | ||||||
|     POETRY_VIRTUALENVS_CREATE=false |     POETRY_VIRTUALENVS_CREATE=false | ||||||
|  |  | ||||||
| ENV GOFIPS=1 |  | ||||||
|  |  | ||||||
| HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ] | HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ] | ||||||
|  |  | ||||||
| ENTRYPOINT [ "dumb-init", "--", "ak" ] | ENTRYPOINT [ "dumb-init", "--", "ak" ] | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								Makefile
									
									
									
									
									
								
							| @ -19,7 +19,6 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null) | |||||||
| CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ | CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ | ||||||
| 		-I .github/codespell-words.txt \ | 		-I .github/codespell-words.txt \ | ||||||
| 		-S 'web/src/locales/**' \ | 		-S 'web/src/locales/**' \ | ||||||
| 		-S 'website/developer-docs/api/reference/**' \ |  | ||||||
| 		authentik \ | 		authentik \ | ||||||
| 		internal \ | 		internal \ | ||||||
| 		cmd \ | 		cmd \ | ||||||
| @ -47,8 +46,8 @@ test-go: | |||||||
| 	go test -timeout 0 -v -race -cover ./... | 	go test -timeout 0 -v -race -cover ./... | ||||||
|  |  | ||||||
| test-docker:  ## Run all tests in a docker-compose | test-docker:  ## Run all tests in a docker-compose | ||||||
| 	echo "PG_PASS=$(shell openssl rand 32 | base64)" >> .env | 	echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
| 	echo "AUTHENTIK_SECRET_KEY=$(shell openssl rand 32 | base64)" >> .env | 	echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env | ||||||
| 	docker compose pull -q | 	docker compose pull -q | ||||||
| 	docker compose up --no-start | 	docker compose up --no-start | ||||||
| 	docker compose start postgresql redis | 	docker compose start postgresql redis | ||||||
| @ -253,7 +252,6 @@ website-watch:  ## Build and watch the documentation website, updating automatic | |||||||
| ######################### | ######################### | ||||||
|  |  | ||||||
| docker:  ## Build a docker image of the current source tree | docker:  ## Build a docker image of the current source tree | ||||||
| 	mkdir -p ${GEN_API_TS} |  | ||||||
| 	DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE} | 	DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE} | ||||||
|  |  | ||||||
| ######################### | ######################### | ||||||
|  | |||||||
| @ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni | |||||||
|  |  | ||||||
| (.x being the latest patch release for each version) | (.x being the latest patch release for each version) | ||||||
|  |  | ||||||
| | Version  | Supported | | | Version   | Supported | | ||||||
| | -------- | --------- | | | --------- | --------- | | ||||||
| | 2024.4.x | ✅        | | | 2023.10.x | ✅        | | ||||||
| | 2024.6.x | ✅        | | | 2024.2.x  | ✅        | | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| __version__ = "2024.6.5" | __version__ = "2024.4.4" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,21 +2,18 @@ | |||||||
|  |  | ||||||
| import platform | import platform | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from ssl import OPENSSL_VERSION |  | ||||||
| from sys import version as python_version | from sys import version as python_version | ||||||
| from typing import TypedDict | from typing import TypedDict | ||||||
|  |  | ||||||
| from cryptography.hazmat.backends.openssl.backend import backend |  | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
|  | from gunicorn import version_info as gunicorn_version | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from authentik import get_full_version |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.enterprise.license import LicenseKey |  | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.reflection import get_env | from authentik.lib.utils.reflection import get_env | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST | from authentik.outposts.apps import MANAGED_OUTPOST | ||||||
| @ -28,13 +25,11 @@ class RuntimeDict(TypedDict): | |||||||
|     """Runtime information""" |     """Runtime information""" | ||||||
|  |  | ||||||
|     python_version: str |     python_version: str | ||||||
|  |     gunicorn_version: str | ||||||
|     environment: str |     environment: str | ||||||
|     architecture: str |     architecture: str | ||||||
|     platform: str |     platform: str | ||||||
|     uname: str |     uname: str | ||||||
|     openssl_version: str |  | ||||||
|     openssl_fips_enabled: bool | None |  | ||||||
|     authentik_version: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SystemInfoSerializer(PassiveSerializer): | class SystemInfoSerializer(PassiveSerializer): | ||||||
| @ -69,15 +64,11 @@ class SystemInfoSerializer(PassiveSerializer): | |||||||
|     def get_runtime(self, request: Request) -> RuntimeDict: |     def get_runtime(self, request: Request) -> RuntimeDict: | ||||||
|         """Get versions""" |         """Get versions""" | ||||||
|         return { |         return { | ||||||
|             "architecture": platform.machine(), |  | ||||||
|             "authentik_version": get_full_version(), |  | ||||||
|             "environment": get_env(), |  | ||||||
|             "openssl_fips_enabled": ( |  | ||||||
|                 backend._fips_enabled if LicenseKey.get_total().is_valid() else None |  | ||||||
|             ), |  | ||||||
|             "openssl_version": OPENSSL_VERSION, |  | ||||||
|             "platform": platform.platform(), |  | ||||||
|             "python_version": python_version, |             "python_version": python_version, | ||||||
|  |             "gunicorn_version": ".".join(str(x) for x in gunicorn_version), | ||||||
|  |             "environment": get_env(), | ||||||
|  |             "architecture": platform.machine(), | ||||||
|  |             "platform": platform.platform(), | ||||||
|             "uname": " ".join(platform.uname()), |             "uname": " ".join(platform.uname()), | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,13 +1,13 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
|  |  | ||||||
| {% load authentik_core %} | {% load static %} | ||||||
|  |  | ||||||
| {% block title %} | {% block title %} | ||||||
| API Browser - {{ brand.branding_title }} | API Browser - {{ brand.branding_title }} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| {% versioned_script "dist/standalone/api-browser/index-%v.js" %} | <script src="{% static 'dist/standalone/api-browser/index.js' %}?version={{ version }}" type="module"></script> | ||||||
| <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> | ||||||
| <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  | |||||||
| @ -75,7 +75,7 @@ class BlueprintEntry: | |||||||
|     _state: BlueprintEntryState = field(default_factory=BlueprintEntryState) |     _state: BlueprintEntryState = field(default_factory=BlueprintEntryState) | ||||||
|  |  | ||||||
|     def __post_init__(self, *args, **kwargs) -> None: |     def __post_init__(self, *args, **kwargs) -> None: | ||||||
|         self.__tag_contexts: list[YAMLTagContext] = [] |         self.__tag_contexts: list["YAMLTagContext"] = [] | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry": |     def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry": | ||||||
|  | |||||||
| @ -39,14 +39,6 @@ from authentik.core.models import ( | |||||||
| ) | ) | ||||||
| from authentik.enterprise.license import LicenseKey | from authentik.enterprise.license import LicenseKey | ||||||
| from authentik.enterprise.models import LicenseUsage | from authentik.enterprise.models import LicenseUsage | ||||||
| from authentik.enterprise.providers.google_workspace.models import ( |  | ||||||
|     GoogleWorkspaceProviderGroup, |  | ||||||
|     GoogleWorkspaceProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import ( |  | ||||||
|     MicrosoftEntraProviderGroup, |  | ||||||
|     MicrosoftEntraProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.rac.models import ConnectionToken | from authentik.enterprise.providers.rac.models import ConnectionToken | ||||||
| from authentik.events.logs import LogEvent, capture_logs | from authentik.events.logs import LogEvent, capture_logs | ||||||
| from authentik.events.models import SystemTask | from authentik.events.models import SystemTask | ||||||
| @ -58,7 +50,7 @@ from authentik.outposts.models import OutpostServiceConnection | |||||||
| from authentik.policies.models import Policy, PolicyBindingModel | from authentik.policies.models import Policy, PolicyBindingModel | ||||||
| from authentik.policies.reputation.models import Reputation | from authentik.policies.reputation.models import Reputation | ||||||
| from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | ||||||
| from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser | from authentik.providers.scim.models import SCIMGroup, SCIMUser | ||||||
| from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | ||||||
| from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType | from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType | ||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
| @ -94,11 +86,10 @@ def excluded_models() -> list[type[Model]]: | |||||||
|         # Classes that have other dependencies |         # Classes that have other dependencies | ||||||
|         AuthenticatedSession, |         AuthenticatedSession, | ||||||
|         # Classes which are only internally managed |         # Classes which are only internally managed | ||||||
|         # FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin |  | ||||||
|         FlowToken, |         FlowToken, | ||||||
|         LicenseUsage, |         LicenseUsage, | ||||||
|         SCIMProviderGroup, |         SCIMGroup, | ||||||
|         SCIMProviderUser, |         SCIMUser, | ||||||
|         Tenant, |         Tenant, | ||||||
|         SystemTask, |         SystemTask, | ||||||
|         ConnectionToken, |         ConnectionToken, | ||||||
| @ -109,10 +100,6 @@ def excluded_models() -> list[type[Model]]: | |||||||
|         WebAuthnDeviceType, |         WebAuthnDeviceType, | ||||||
|         SCIMSourceUser, |         SCIMSourceUser, | ||||||
|         SCIMSourceGroup, |         SCIMSourceGroup, | ||||||
|         GoogleWorkspaceProviderUser, |  | ||||||
|         GoogleWorkspaceProviderGroup, |  | ||||||
|         MicrosoftEntraProviderUser, |  | ||||||
|         MicrosoftEntraProviderGroup, |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,20 +11,21 @@ from rest_framework.filters import OrderingFilter, SearchFilter | |||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| 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.validators import UniqueValidator | from rest_framework.validators import UniqueValidator | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.authorization import SecretKeyFilter | from authentik.api.authorization import SecretKeyFilter | ||||||
| from authentik.brands.models import Brand | from authentik.brands.models import Brand | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ModelSerializer, PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.tenants.utils import get_current_tenant | from authentik.tenants.utils import get_current_tenant | ||||||
|  |  | ||||||
|  |  | ||||||
| class FooterLinkSerializer(PassiveSerializer): | class FooterLinkSerializer(PassiveSerializer): | ||||||
|     """Links returned in Config API""" |     """Links returned in Config API""" | ||||||
|  |  | ||||||
|     href = CharField(read_only=True, allow_null=True) |     href = CharField(read_only=True) | ||||||
|     name = CharField(read_only=True) |     name = CharField(read_only=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -17,6 +17,7 @@ from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodFiel | |||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -25,7 +26,6 @@ from authentik.api.pagination import Pagination | |||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ModelSerializer |  | ||||||
| from authentik.core.models import Application, User | from authentik.core.models import Application, User | ||||||
| from authentik.events.logs import LogEventSerializer, capture_logs | from authentik.events.logs import LogEventSerializer, capture_logs | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
|  | |||||||
| @ -8,12 +8,12 @@ from rest_framework import mixins | |||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
|  | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from ua_parser import user_agent_parser | from ua_parser import user_agent_parser | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerSuperuserPermissions | from authentik.api.authorization import OwnerSuperuserPermissions | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ModelSerializer |  | ||||||
| from authentik.core.models import AuthenticatedSession | from authentik.core.models import AuthenticatedSession | ||||||
| from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict | from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict | ||||||
| from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict | from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict | ||||||
|  | |||||||
| @ -2,7 +2,6 @@ | |||||||
|  |  | ||||||
| from json import loads | from json import loads | ||||||
|  |  | ||||||
| from django.db.models import Prefetch |  | ||||||
| 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 | ||||||
| @ -17,12 +16,11 @@ from rest_framework.decorators import action | |||||||
| from rest_framework.fields import CharField, IntegerField, SerializerMethodField | from rest_framework.fields import CharField, IntegerField, SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ListSerializer, ValidationError | from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError | ||||||
| from rest_framework.validators import UniqueValidator |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer | from authentik.core.api.utils import JSONDictField, PassiveSerializer | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| from authentik.rbac.api.roles import RoleSerializer | from authentik.rbac.api.roles import RoleSerializer | ||||||
| from authentik.rbac.decorators import permission_required | from authentik.rbac.decorators import permission_required | ||||||
| @ -102,10 +100,7 @@ class GroupSerializer(ModelSerializer): | |||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "users": { |             "users": { | ||||||
|                 "default": list, |                 "default": list, | ||||||
|             }, |             } | ||||||
|             # TODO: This field isn't unique on the database which is hard to backport |  | ||||||
|             # hence we just validate the uniqueness here |  | ||||||
|             "name": {"validators": [UniqueValidator(Group.objects.all())]}, |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -167,14 +162,8 @@ class GroupViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|         base_qs = Group.objects.all().select_related("parent").prefetch_related("roles") |         base_qs = Group.objects.all().select_related("parent").prefetch_related("roles") | ||||||
|  |  | ||||||
|         if self.serializer_class(context={"request": self.request})._should_include_users: |         if self.serializer_class(context={"request": self.request})._should_include_users: | ||||||
|             base_qs = base_qs.prefetch_related("users") |             base_qs = base_qs.prefetch_related("users") | ||||||
|         else: |  | ||||||
|             base_qs = base_qs.prefetch_related( |  | ||||||
|                 Prefetch("users", queryset=User.objects.all().only("id")) |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         return base_qs |         return base_qs | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
| @ -185,14 +174,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet): | |||||||
|     def list(self, request, *args, **kwargs): |     def list(self, request, *args, **kwargs): | ||||||
|         return super().list(request, *args, **kwargs) |         return super().list(request, *args, **kwargs) | ||||||
|  |  | ||||||
|     @extend_schema( |  | ||||||
|         parameters=[ |  | ||||||
|             OpenApiParameter("include_users", bool, default=True), |  | ||||||
|         ] |  | ||||||
|     ) |  | ||||||
|     def retrieve(self, request, *args, **kwargs): |  | ||||||
|         return super().retrieve(request, *args, **kwargs) |  | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.add_user_to_group") |     @permission_required("authentik_core.add_user_to_group") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=UserAccountSerializer, |         request=UserAccountSerializer, | ||||||
|  | |||||||
| @ -1,79 +0,0 @@ | |||||||
| """API Utilities""" |  | ||||||
|  |  | ||||||
| from drf_spectacular.utils import extend_schema |  | ||||||
| from rest_framework.decorators import action |  | ||||||
| from rest_framework.fields import ( |  | ||||||
|     BooleanField, |  | ||||||
|     CharField, |  | ||||||
| ) |  | ||||||
| from rest_framework.request import Request |  | ||||||
| from rest_framework.response import Response |  | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer |  | ||||||
| from authentik.enterprise.apps import EnterpriseConfig |  | ||||||
| from authentik.lib.utils.reflection import all_subclasses |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TypeCreateSerializer(PassiveSerializer): |  | ||||||
|     """Types of an object that can be created""" |  | ||||||
|  |  | ||||||
|     name = CharField(required=True) |  | ||||||
|     description = CharField(required=True) |  | ||||||
|     component = CharField(required=True) |  | ||||||
|     model_name = CharField(required=True) |  | ||||||
|  |  | ||||||
|     icon_url = CharField(required=False) |  | ||||||
|     requires_enterprise = BooleanField(default=False) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class CreatableType: |  | ||||||
|     """Class to inherit from to mark a model as creatable, even if the model itself is marked |  | ||||||
|     as abstract""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class NonCreatableType: |  | ||||||
|     """Class to inherit from to mark a model as non-creatable even if it is not abstract""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TypesMixin: |  | ||||||
|     """Mixin which adds an API endpoint to list all possible types that can be created""" |  | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) |  | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |  | ||||||
|     def types(self, request: Request, additional: list[dict] | None = None) -> Response: |  | ||||||
|         """Get all creatable types""" |  | ||||||
|         data = [] |  | ||||||
|         for subclass in all_subclasses(self.queryset.model): |  | ||||||
|             instance = None |  | ||||||
|             if subclass._meta.abstract: |  | ||||||
|                 if not issubclass(subclass, CreatableType): |  | ||||||
|                     continue |  | ||||||
|                 # Circumvent the django protection for not being able to instantiate |  | ||||||
|                 # abstract models. We need a model instance to access .component |  | ||||||
|                 # and further down .icon_url |  | ||||||
|                 instance = subclass.__new__(subclass) |  | ||||||
|                 # Django re-sets abstract = False so we need to override that |  | ||||||
|                 instance.Meta.abstract = True |  | ||||||
|             else: |  | ||||||
|                 if issubclass(subclass, NonCreatableType): |  | ||||||
|                     continue |  | ||||||
|                 instance = subclass() |  | ||||||
|             try: |  | ||||||
|                 data.append( |  | ||||||
|                     { |  | ||||||
|                         "name": subclass._meta.verbose_name, |  | ||||||
|                         "description": subclass.__doc__, |  | ||||||
|                         "component": instance.component, |  | ||||||
|                         "model_name": subclass._meta.model_name, |  | ||||||
|                         "icon_url": getattr(instance, "icon_url", None), |  | ||||||
|                         "requires_enterprise": isinstance( |  | ||||||
|                             subclass._meta.app_config, EnterpriseConfig |  | ||||||
|                         ), |  | ||||||
|                     } |  | ||||||
|                 ) |  | ||||||
|             except NotImplementedError: |  | ||||||
|                 continue |  | ||||||
|         if additional: |  | ||||||
|             data.extend(additional) |  | ||||||
|         data = sorted(data, key=lambda x: x["name"]) |  | ||||||
|         return Response(TypeCreateSerializer(data, many=True).data) |  | ||||||
| @ -8,23 +8,19 @@ from guardian.shortcuts import get_objects_for_user | |||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.exceptions import PermissionDenied | from rest_framework.exceptions import PermissionDenied | ||||||
| from rest_framework.fields import BooleanField, CharField, SerializerMethodField | from rest_framework.fields import BooleanField, CharField | ||||||
| from rest_framework.relations import PrimaryKeyRelatedField |  | ||||||
| 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, SerializerMethodField | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| 
 | 
 | ||||||
| from authentik.blueprints.api import ManagedSerializer | from authentik.blueprints.api import ManagedSerializer | ||||||
| from authentik.core.api.object_types import TypesMixin |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer | ||||||
|     MetaNameSerializer, |  | ||||||
|     ModelSerializer, |  | ||||||
|     PassiveSerializer, |  | ||||||
| ) |  | ||||||
| from authentik.core.expression.evaluator import PropertyMappingEvaluator | from authentik.core.expression.evaluator import PropertyMappingEvaluator | ||||||
| from authentik.core.models import Group, PropertyMapping, User | from authentik.core.models import PropertyMapping | ||||||
| from authentik.events.utils import sanitize_item | from authentik.events.utils import sanitize_item | ||||||
|  | from authentik.lib.utils.reflection import all_subclasses | ||||||
| from authentik.policies.api.exec import PolicyTestSerializer | from authentik.policies.api.exec import PolicyTestSerializer | ||||||
| from authentik.rbac.decorators import permission_required | from authentik.rbac.decorators import permission_required | ||||||
| 
 | 
 | ||||||
| @ -68,7 +64,6 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class PropertyMappingViewSet( | class PropertyMappingViewSet( | ||||||
|     TypesMixin, |  | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |     UsedByMixin, | ||||||
| @ -77,15 +72,7 @@ class PropertyMappingViewSet( | |||||||
| ): | ): | ||||||
|     """PropertyMapping Viewset""" |     """PropertyMapping Viewset""" | ||||||
| 
 | 
 | ||||||
|     class PropertyMappingTestSerializer(PolicyTestSerializer): |     queryset = PropertyMapping.objects.none() | ||||||
|         """Test property mapping execution for a user/group with context""" |  | ||||||
| 
 |  | ||||||
|         user = PrimaryKeyRelatedField(queryset=User.objects.all(), required=False, allow_null=True) |  | ||||||
|         group = PrimaryKeyRelatedField( |  | ||||||
|             queryset=Group.objects.all(), required=False, allow_null=True |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     queryset = PropertyMapping.objects.select_subclasses() |  | ||||||
|     serializer_class = PropertyMappingSerializer |     serializer_class = PropertyMappingSerializer | ||||||
|     search_fields = [ |     search_fields = [ | ||||||
|         "name", |         "name", | ||||||
| @ -93,9 +80,29 @@ class PropertyMappingViewSet( | |||||||
|     filterset_fields = {"managed": ["isnull"]} |     filterset_fields = {"managed": ["isnull"]} | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
| 
 | 
 | ||||||
|  |     def get_queryset(self):  # pragma: no cover | ||||||
|  |         return PropertyMapping.objects.select_subclasses() | ||||||
|  | 
 | ||||||
|  |     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|  |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|  |     def types(self, request: Request) -> Response: | ||||||
|  |         """Get all creatable property-mapping types""" | ||||||
|  |         data = [] | ||||||
|  |         for subclass in all_subclasses(self.queryset.model): | ||||||
|  |             subclass: PropertyMapping | ||||||
|  |             data.append( | ||||||
|  |                 { | ||||||
|  |                     "name": subclass._meta.verbose_name, | ||||||
|  |                     "description": subclass.__doc__, | ||||||
|  |                     "component": subclass().component, | ||||||
|  |                     "model_name": subclass._meta.model_name, | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |         return Response(TypeCreateSerializer(data, many=True).data) | ||||||
|  | 
 | ||||||
|     @permission_required("authentik_core.view_propertymapping") |     @permission_required("authentik_core.view_propertymapping") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=PropertyMappingTestSerializer(), |         request=PolicyTestSerializer(), | ||||||
|         responses={ |         responses={ | ||||||
|             200: PropertyMappingTestResultSerializer, |             200: PropertyMappingTestResultSerializer, | ||||||
|             400: OpenApiResponse(description="Invalid parameters"), |             400: OpenApiResponse(description="Invalid parameters"), | ||||||
| @ -113,39 +120,29 @@ class PropertyMappingViewSet( | |||||||
|         """Test Property Mapping""" |         """Test Property Mapping""" | ||||||
|         _mapping: PropertyMapping = self.get_object() |         _mapping: PropertyMapping = self.get_object() | ||||||
|         # Use `get_subclass` to get correct class and correct `.evaluate` implementation |         # Use `get_subclass` to get correct class and correct `.evaluate` implementation | ||||||
|         mapping: PropertyMapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk) |         mapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk) | ||||||
|         # FIXME: when we separate policy mappings between ones for sources |         # FIXME: when we separate policy mappings between ones for sources | ||||||
|         # and ones for providers, we need to make the user field optional for the source mapping |         # and ones for providers, we need to make the user field optional for the source mapping | ||||||
|         test_params = self.PropertyMappingTestSerializer(data=request.data) |         test_params = PolicyTestSerializer(data=request.data) | ||||||
|         if not test_params.is_valid(): |         if not test_params.is_valid(): | ||||||
|             return Response(test_params.errors, status=400) |             return Response(test_params.errors, status=400) | ||||||
| 
 | 
 | ||||||
|         format_result = str(request.GET.get("format_result", "false")).lower() == "true" |         format_result = str(request.GET.get("format_result", "false")).lower() == "true" | ||||||
| 
 | 
 | ||||||
|         context: dict = test_params.validated_data.get("context", {}) |         # User permission check, only allow mapping testing for users that are readable | ||||||
|         context.setdefault("user", None) |         users = get_objects_for_user(request.user, "authentik_core.view_user").filter( | ||||||
| 
 |             pk=test_params.validated_data["user"].pk | ||||||
|         if user := test_params.validated_data.get("user"): |         ) | ||||||
|             # User permission check, only allow mapping testing for users that are readable |         if not users.exists(): | ||||||
|             users = get_objects_for_user(request.user, "authentik_core.view_user").filter( |             raise PermissionDenied() | ||||||
|                 pk=user.pk |  | ||||||
|             ) |  | ||||||
|             if not users.exists(): |  | ||||||
|                 raise PermissionDenied() |  | ||||||
|             context["user"] = user |  | ||||||
|         if group := test_params.validated_data.get("group"): |  | ||||||
|             # Group permission check, only allow mapping testing for groups that are readable |  | ||||||
|             groups = get_objects_for_user(request.user, "authentik_core.view_group").filter( |  | ||||||
|                 pk=group.pk |  | ||||||
|             ) |  | ||||||
|             if not groups.exists(): |  | ||||||
|                 raise PermissionDenied() |  | ||||||
|             context["group"] = group |  | ||||||
|         context["request"] = self.request |  | ||||||
| 
 | 
 | ||||||
|         response_data = {"successful": True, "result": ""} |         response_data = {"successful": True, "result": ""} | ||||||
|         try: |         try: | ||||||
|             result = mapping.evaluate(**context) |             result = mapping.evaluate( | ||||||
|  |                 users.first(), | ||||||
|  |                 self.request, | ||||||
|  |                 **test_params.validated_data.get("context", {}), | ||||||
|  |             ) | ||||||
|             response_data["result"] = dumps( |             response_data["result"] = dumps( | ||||||
|                 sanitize_item(result), indent=(4 if format_result else None) |                 sanitize_item(result), indent=(4 if format_result else None) | ||||||
|             ) |             ) | ||||||
| @ -5,14 +5,20 @@ from django.db.models.query import Q | |||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from django_filters.filters import BooleanFilter | from django_filters.filters import BooleanFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
|  | from drf_spectacular.utils import extend_schema | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.fields import ReadOnlyField, SerializerMethodField | from rest_framework.decorators import action | ||||||
|  | from rest_framework.fields import ReadOnlyField | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  | from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.object_types import TypesMixin |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import MetaNameSerializer, ModelSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
|  | from authentik.enterprise.apps import EnterpriseConfig | ||||||
|  | from authentik.lib.utils.reflection import all_subclasses | ||||||
|  |  | ||||||
|  |  | ||||||
| class ProviderSerializer(ModelSerializer, MetaNameSerializer): | class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||||
| @ -57,12 +63,8 @@ class ProviderFilter(FilterSet): | |||||||
|     """Filter for providers""" |     """Filter for providers""" | ||||||
|  |  | ||||||
|     application__isnull = BooleanFilter(method="filter_application__isnull") |     application__isnull = BooleanFilter(method="filter_application__isnull") | ||||||
|     backchannel = BooleanFilter( |     backchannel_only = BooleanFilter( | ||||||
|         method="filter_backchannel", |         method="filter_backchannel_only", | ||||||
|         label=_( |  | ||||||
|             "When not set all providers are returned. When set to true, only backchannel " |  | ||||||
|             "providers are returned. When set to false, backchannel providers are excluded" |  | ||||||
|         ), |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def filter_application__isnull(self, queryset: QuerySet, name, value): |     def filter_application__isnull(self, queryset: QuerySet, name, value): | ||||||
| @ -73,14 +75,12 @@ class ProviderFilter(FilterSet): | |||||||
|             | Q(application__isnull=value) |             | Q(application__isnull=value) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def filter_backchannel(self, queryset: QuerySet, name, value): |     def filter_backchannel_only(self, queryset: QuerySet, name, value): | ||||||
|         """By default all providers are returned. When set to true, only backchannel providers are |         """Only return backchannel providers""" | ||||||
|         returned. When set to false, backchannel providers are excluded""" |  | ||||||
|         return queryset.filter(is_backchannel=value) |         return queryset.filter(is_backchannel=value) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ProviderViewSet( | class ProviderViewSet( | ||||||
|     TypesMixin, |  | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |     UsedByMixin, | ||||||
| @ -99,3 +99,31 @@ class ProviderViewSet( | |||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self):  # pragma: no cover | ||||||
|         return Provider.objects.select_subclasses() |         return Provider.objects.select_subclasses() | ||||||
|  |  | ||||||
|  |     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|  |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|  |     def types(self, request: Request) -> Response: | ||||||
|  |         """Get all creatable provider types""" | ||||||
|  |         data = [] | ||||||
|  |         for subclass in all_subclasses(self.queryset.model): | ||||||
|  |             subclass: Provider | ||||||
|  |             if subclass._meta.abstract: | ||||||
|  |                 continue | ||||||
|  |             data.append( | ||||||
|  |                 { | ||||||
|  |                     "name": subclass._meta.verbose_name, | ||||||
|  |                     "description": subclass.__doc__, | ||||||
|  |                     "component": subclass().component, | ||||||
|  |                     "model_name": subclass._meta.model_name, | ||||||
|  |                     "requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig), | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |         data.append( | ||||||
|  |             { | ||||||
|  |                 "name": _("SAML Provider from Metadata"), | ||||||
|  |                 "description": _("Create a SAML Provider by importing its Metadata."), | ||||||
|  |                 "component": "ak-provider-saml-import-form", | ||||||
|  |                 "model_name": "", | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |         return Response(TypeCreateSerializer(data, many=True).data) | ||||||
|  | |||||||
| @ -11,14 +11,14 @@ from rest_framework.filters import OrderingFilter, SearchFilter | |||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions | from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions | ||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| from authentik.core.api.object_types import TypesMixin |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import MetaNameSerializer, ModelSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.models import Source, UserSourceConnection | from authentik.core.models import Source, UserSourceConnection | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
| from authentik.lib.utils.file import ( | from authentik.lib.utils.file import ( | ||||||
| @ -27,6 +27,7 @@ from authentik.lib.utils.file import ( | |||||||
|     set_file, |     set_file, | ||||||
|     set_file_url, |     set_file_url, | ||||||
| ) | ) | ||||||
|  | from authentik.lib.utils.reflection import all_subclasses | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.rbac.decorators import permission_required | from authentik.rbac.decorators import permission_required | ||||||
|  |  | ||||||
| @ -73,7 +74,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|  |  | ||||||
|  |  | ||||||
| class SourceViewSet( | class SourceViewSet( | ||||||
|     TypesMixin, |  | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |     UsedByMixin, | ||||||
| @ -132,6 +132,30 @@ class SourceViewSet( | |||||||
|         source: Source = self.get_object() |         source: Source = self.get_object() | ||||||
|         return set_file_url(request, source, "icon") |         return set_file_url(request, source, "icon") | ||||||
|  |  | ||||||
|  |     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|  |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|  |     def types(self, request: Request) -> Response: | ||||||
|  |         """Get all creatable source types""" | ||||||
|  |         data = [] | ||||||
|  |         for subclass in all_subclasses(self.queryset.model): | ||||||
|  |             subclass: Source | ||||||
|  |             component = "" | ||||||
|  |             if len(subclass.__subclasses__()) > 0: | ||||||
|  |                 continue | ||||||
|  |             if subclass._meta.abstract: | ||||||
|  |                 component = subclass.__bases__[0]().component | ||||||
|  |             else: | ||||||
|  |                 component = subclass().component | ||||||
|  |             data.append( | ||||||
|  |                 { | ||||||
|  |                     "name": subclass._meta.verbose_name, | ||||||
|  |                     "description": subclass.__doc__, | ||||||
|  |                     "component": component, | ||||||
|  |                     "model_name": subclass._meta.model_name, | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |         return Response(TypeCreateSerializer(data, many=True).data) | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: UserSettingSerializer(many=True)}) |     @extend_schema(responses={200: UserSettingSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def user_settings(self, request: Request) -> Response: |     def user_settings(self, request: Request) -> Response: | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ from rest_framework.fields import CharField | |||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerSuperuserPermissions | from authentik.api.authorization import OwnerSuperuserPermissions | ||||||
| @ -19,7 +20,7 @@ from authentik.blueprints.api import ManagedSerializer | |||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserSerializer | from authentik.core.api.users import UserSerializer | ||||||
| from authentik.core.api.utils import ModelSerializer, PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
|     USER_ATTRIBUTE_TOKEN_EXPIRING, |     USER_ATTRIBUTE_TOKEN_EXPIRING, | ||||||
|     USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, |     USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, | ||||||
|  | |||||||
| @ -40,12 +40,12 @@ def get_delete_action(manager: Manager) -> str: | |||||||
|     """Get the delete action from the Foreign key, falls back to cascade""" |     """Get the delete action from the Foreign key, falls back to cascade""" | ||||||
|     if hasattr(manager, "field"): |     if hasattr(manager, "field"): | ||||||
|         if manager.field.remote_field.on_delete.__name__ == SET_NULL.__name__: |         if manager.field.remote_field.on_delete.__name__ == SET_NULL.__name__: | ||||||
|             return DeleteAction.SET_NULL.value |             return DeleteAction.SET_NULL.name | ||||||
|         if manager.field.remote_field.on_delete.__name__ == SET_DEFAULT.__name__: |         if manager.field.remote_field.on_delete.__name__ == SET_DEFAULT.__name__: | ||||||
|             return DeleteAction.SET_DEFAULT.value |             return DeleteAction.SET_DEFAULT.name | ||||||
|     if hasattr(manager, "source_field"): |     if hasattr(manager, "source_field"): | ||||||
|         return DeleteAction.CASCADE_MANY.value |         return DeleteAction.CASCADE_MANY.name | ||||||
|     return DeleteAction.CASCADE.value |     return DeleteAction.CASCADE.name | ||||||
|  |  | ||||||
|  |  | ||||||
| class UsedByMixin: | class UsedByMixin: | ||||||
|  | |||||||
| @ -40,6 +40,7 @@ from rest_framework.serializers import ( | |||||||
|     BooleanField, |     BooleanField, | ||||||
|     DateTimeField, |     DateTimeField, | ||||||
|     ListSerializer, |     ListSerializer, | ||||||
|  |     ModelSerializer, | ||||||
|     PrimaryKeyRelatedField, |     PrimaryKeyRelatedField, | ||||||
|     ValidationError, |     ValidationError, | ||||||
| ) | ) | ||||||
| @ -51,12 +52,7 @@ from authentik.admin.api.metrics import CoordinateSerializer | |||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| from authentik.brands.models import Brand | from authentik.brands.models import Brand | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer | ||||||
|     JSONDictField, |  | ||||||
|     LinkSerializer, |  | ||||||
|     ModelSerializer, |  | ||||||
|     PassiveSerializer, |  | ||||||
| ) |  | ||||||
| from authentik.core.middleware import ( | from authentik.core.middleware import ( | ||||||
|     SESSION_KEY_IMPERSONATE_ORIGINAL_USER, |     SESSION_KEY_IMPERSONATE_ORIGINAL_USER, | ||||||
|     SESSION_KEY_IMPERSONATE_USER, |     SESSION_KEY_IMPERSONATE_USER, | ||||||
|  | |||||||
| @ -6,19 +6,8 @@ from django.db.models import Model | |||||||
| from drf_spectacular.extensions import OpenApiSerializerFieldExtension | from drf_spectacular.extensions import OpenApiSerializerFieldExtension | ||||||
| from drf_spectacular.plumbing import build_basic_type | from drf_spectacular.plumbing import build_basic_type | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from rest_framework.fields import ( | from rest_framework.fields import BooleanField, CharField, IntegerField, JSONField | ||||||
|     CharField, | from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError | ||||||
|     IntegerField, |  | ||||||
|     JSONField, |  | ||||||
|     SerializerMethodField, |  | ||||||
| ) |  | ||||||
| from rest_framework.serializers import ModelSerializer as BaseModelSerializer |  | ||||||
| from rest_framework.serializers import ( |  | ||||||
|     Serializer, |  | ||||||
|     ValidationError, |  | ||||||
|     model_meta, |  | ||||||
|     raise_errors_on_nested_writes, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def is_dict(value: Any): | def is_dict(value: Any): | ||||||
| @ -28,39 +17,6 @@ def is_dict(value: Any): | |||||||
|     raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") |     raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") | ||||||
|  |  | ||||||
|  |  | ||||||
| class ModelSerializer(BaseModelSerializer): |  | ||||||
|  |  | ||||||
|     def update(self, instance: Model, validated_data): |  | ||||||
|         raise_errors_on_nested_writes("update", self, validated_data) |  | ||||||
|         info = model_meta.get_field_info(instance) |  | ||||||
|  |  | ||||||
|         # Simply set each attribute on the instance, and then save it. |  | ||||||
|         # Note that unlike `.create()` we don't need to treat many-to-many |  | ||||||
|         # relationships as being a special case. During updates we already |  | ||||||
|         # have an instance pk for the relationships to be associated with. |  | ||||||
|         m2m_fields = [] |  | ||||||
|         for attr, value in validated_data.items(): |  | ||||||
|             if attr in info.relations and info.relations[attr].to_many: |  | ||||||
|                 m2m_fields.append((attr, value)) |  | ||||||
|             else: |  | ||||||
|                 setattr(instance, attr, value) |  | ||||||
|  |  | ||||||
|         instance.save() |  | ||||||
|  |  | ||||||
|         # Note that many-to-many fields are set after updating instance. |  | ||||||
|         # Setting m2m fields triggers signals which could potentially change |  | ||||||
|         # updated instance and we do not want it to collide with .update() |  | ||||||
|         for attr, value in m2m_fields: |  | ||||||
|             field = getattr(instance, attr) |  | ||||||
|             # We can't check for inheritance here as m2m managers are generated dynamically |  | ||||||
|             if field.__class__.__name__ == "RelatedManager": |  | ||||||
|                 field.set(value, bulk=False) |  | ||||||
|             else: |  | ||||||
|                 field.set(value) |  | ||||||
|  |  | ||||||
|         return instance |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class JSONDictField(JSONField): | class JSONDictField(JSONField): | ||||||
|     """JSON Field which only allows dictionaries""" |     """JSON Field which only allows dictionaries""" | ||||||
|  |  | ||||||
| @ -112,6 +68,16 @@ class MetaNameSerializer(PassiveSerializer): | |||||||
|         return f"{obj._meta.app_label}.{obj._meta.model_name}" |         return f"{obj._meta.app_label}.{obj._meta.model_name}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TypeCreateSerializer(PassiveSerializer): | ||||||
|  |     """Types of an object that can be created""" | ||||||
|  |  | ||||||
|  |     name = CharField(required=True) | ||||||
|  |     description = CharField(required=True) | ||||||
|  |     component = CharField(required=True) | ||||||
|  |     model_name = CharField(required=True) | ||||||
|  |     requires_enterprise = BooleanField(default=False) | ||||||
|  |  | ||||||
|  |  | ||||||
| class CacheSerializer(PassiveSerializer): | class CacheSerializer(PassiveSerializer): | ||||||
|     """Generic cache stats for an object""" |     """Generic cache stats for an object""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -31,9 +31,8 @@ class InbuiltBackend(ModelBackend): | |||||||
|         # Since we can't directly pass other variables to signals, and we want to log the method |         # Since we can't directly pass other variables to signals, and we want to log the method | ||||||
|         # and the token used, we assume we're running in a flow and set a variable in the context |         # and the token used, we assume we're running in a flow and set a variable in the context | ||||||
|         flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan("")) |         flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan("")) | ||||||
|         flow_plan.context.setdefault(PLAN_CONTEXT_METHOD, method) |         flow_plan.context[PLAN_CONTEXT_METHOD] = method | ||||||
|         flow_plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {}) |         flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs)) | ||||||
|         flow_plan.context[PLAN_CONTEXT_METHOD_ARGS].update(cleanse_dict(sanitize_dict(kwargs))) |  | ||||||
|         request.session[SESSION_KEY_PLAN] = flow_plan |         request.session[SESSION_KEY_PLAN] = flow_plan | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								authentik/core/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								authentik/core/exceptions.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | """authentik core exceptions""" | ||||||
|  |  | ||||||
|  | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PropertyMappingExpressionException(SentryIgnoredException): | ||||||
|  |     """Error when a PropertyMapping Exception expression could not be parsed or evaluated.""" | ||||||
| @ -1,13 +1,11 @@ | |||||||
| """Property Mapping Evaluator""" | """Property Mapping Evaluator""" | ||||||
|  |  | ||||||
| from types import CodeType |  | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from prometheus_client import Histogram | from prometheus_client import Histogram | ||||||
|  |  | ||||||
| from authentik.core.expression.exceptions import SkipObjectException |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.expression.evaluator import BaseEvaluator | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
| @ -25,8 +23,6 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|     """Custom Evaluator that adds some different context variables.""" |     """Custom Evaluator that adds some different context variables.""" | ||||||
|  |  | ||||||
|     dry_run: bool |     dry_run: bool | ||||||
|     model: Model |  | ||||||
|     _compiled: CodeType | None = None |  | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
| @ -36,32 +32,22 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|         dry_run: bool | None = False, |         dry_run: bool | None = False, | ||||||
|         **kwargs, |         **kwargs, | ||||||
|     ): |     ): | ||||||
|         self.model = model |  | ||||||
|         if hasattr(model, "name"): |         if hasattr(model, "name"): | ||||||
|             _filename = model.name |             _filename = model.name | ||||||
|         else: |         else: | ||||||
|             _filename = str(model) |             _filename = str(model) | ||||||
|         super().__init__(filename=_filename) |         super().__init__(filename=_filename) | ||||||
|         self.dry_run = dry_run |  | ||||||
|         self.set_context(user, request, **kwargs) |  | ||||||
|  |  | ||||||
|     def set_context( |  | ||||||
|         self, |  | ||||||
|         user: User | None = None, |  | ||||||
|         request: HttpRequest | None = None, |  | ||||||
|         **kwargs, |  | ||||||
|     ): |  | ||||||
|         req = PolicyRequest(user=User()) |         req = PolicyRequest(user=User()) | ||||||
|         req.obj = self.model |         req.obj = model | ||||||
|         if user: |         if user: | ||||||
|             req.user = user |             req.user = user | ||||||
|             self._context["user"] = user |             self._context["user"] = user | ||||||
|         if request: |         if request: | ||||||
|             req.http_request = request |             req.http_request = request | ||||||
|         req.context.update(**kwargs) |  | ||||||
|         self._context["request"] = req |         self._context["request"] = req | ||||||
|  |         req.context.update(**kwargs) | ||||||
|         self._context.update(**kwargs) |         self._context.update(**kwargs) | ||||||
|         self._globals["SkipObject"] = SkipObjectException |         self.dry_run = dry_run | ||||||
|  |  | ||||||
|     def handle_error(self, exc: Exception, expression_source: str): |     def handle_error(self, exc: Exception, expression_source: str): | ||||||
|         """Exception Handler""" |         """Exception Handler""" | ||||||
| @ -76,19 +62,10 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|         ) |         ) | ||||||
|         if "request" in self._context: |         if "request" in self._context: | ||||||
|             req: PolicyRequest = self._context["request"] |             req: PolicyRequest = self._context["request"] | ||||||
|             if req.http_request: |             event.from_http(req.http_request, req.user) | ||||||
|                 event.from_http(req.http_request, req.user) |             return | ||||||
|                 return |  | ||||||
|             elif req.user: |  | ||||||
|                 event.set_user(req.user) |  | ||||||
|         event.save() |         event.save() | ||||||
|  |  | ||||||
|     def evaluate(self, *args, **kwargs) -> Any: |     def evaluate(self, *args, **kwargs) -> Any: | ||||||
|         with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time(): |         with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time(): | ||||||
|             return super().evaluate(*args, **kwargs) |             return super().evaluate(*args, **kwargs) | ||||||
|  |  | ||||||
|     def compile(self, expression: str | None = None) -> Any: |  | ||||||
|         if not self._compiled: |  | ||||||
|             compiled = super().compile(expression or self.model.expression) |  | ||||||
|             self._compiled = compiled |  | ||||||
|         return self._compiled |  | ||||||
|  | |||||||
| @ -1,19 +0,0 @@ | |||||||
| """authentik core exceptions""" |  | ||||||
|  |  | ||||||
| from authentik.lib.expression.exceptions import ControlFlowException |  | ||||||
| from authentik.lib.sentry import SentryIgnoredException |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PropertyMappingExpressionException(SentryIgnoredException): |  | ||||||
|     """Error when a PropertyMapping Exception expression could not be parsed or evaluated.""" |  | ||||||
|  |  | ||||||
|     def __init__(self, exc: Exception, mapping) -> None: |  | ||||||
|         super().__init__() |  | ||||||
|         self.exc = exc |  | ||||||
|         self.mapping = mapping |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SkipObjectException(ControlFlowException): |  | ||||||
|     """Exception which can be raised in a property mapping to skip syncing an object. |  | ||||||
|     Only applies to Property mappings which sync objects, and not on mappings which transitively |  | ||||||
|     apply to a single user""" |  | ||||||
| @ -7,13 +7,11 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor | |||||||
|  |  | ||||||
|  |  | ||||||
| def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     db_alias = schema_editor.connection.alias |     from authentik.core.models import BackchannelProvider | ||||||
|     from authentik.providers.ldap.models import LDAPProvider |  | ||||||
|     from authentik.providers.scim.models import SCIMProvider |  | ||||||
|  |  | ||||||
|     for model in [LDAPProvider, SCIMProvider]: |     for model in BackchannelProvider.__subclasses__(): | ||||||
|         try: |         try: | ||||||
|             for obj in model.objects.using(db_alias).only("is_backchannel"): |             for obj in model.objects.only("is_backchannel"): | ||||||
|                 obj.is_backchannel = True |                 obj.is_backchannel = True | ||||||
|                 obj.save() |                 obj.save() | ||||||
|         except (DatabaseError, InternalError, ProgrammingError): |         except (DatabaseError, InternalError, ProgrammingError): | ||||||
|  | |||||||
| @ -15,7 +15,6 @@ from django.http import HttpRequest | |||||||
| from django.utils.functional import SimpleLazyObject, cached_property | from django.utils.functional import SimpleLazyObject, cached_property | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from django_cte import CTEQuerySet, With |  | ||||||
| from guardian.conf import settings | from guardian.conf import settings | ||||||
| from guardian.mixins import GuardianUserMixin | from guardian.mixins import GuardianUserMixin | ||||||
| from model_utils.managers import InheritanceManager | from model_utils.managers import InheritanceManager | ||||||
| @ -23,10 +22,9 @@ from rest_framework.serializers import Serializer | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.blueprints.models import ManagedModel | from authentik.blueprints.models import ManagedModel | ||||||
| from authentik.core.expression.exceptions import PropertyMappingExpressionException | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
| from authentik.core.types import UILoginButton, UserSettingSerializer | from authentik.core.types import UILoginButton, UserSettingSerializer | ||||||
| from authentik.lib.avatars import get_avatar | from authentik.lib.avatars import get_avatar | ||||||
| from authentik.lib.expression.exceptions import ControlFlowException |  | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.lib.models import ( | from authentik.lib.models import ( | ||||||
|     CreatedUpdatedModel, |     CreatedUpdatedModel, | ||||||
| @ -58,8 +56,6 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + ( | |||||||
|     "authentik_used_by_shadows", |     "authentik_used_by_shadows", | ||||||
| ) | ) | ||||||
|  |  | ||||||
| GROUP_RECURSION_LIMIT = 20 |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_token_duration() -> datetime: | def default_token_duration() -> datetime: | ||||||
|     """Default duration a Token is valid""" |     """Default duration a Token is valid""" | ||||||
| @ -100,40 +96,6 @@ class UserTypes(models.TextChoices): | |||||||
|     INTERNAL_SERVICE_ACCOUNT = "internal_service_account" |     INTERNAL_SERVICE_ACCOUNT = "internal_service_account" | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupQuerySet(CTEQuerySet): |  | ||||||
|     def with_children_recursive(self): |  | ||||||
|         """Recursively get all groups that have the current queryset as parents |  | ||||||
|         or are indirectly related.""" |  | ||||||
|  |  | ||||||
|         def make_cte(cte): |  | ||||||
|             """Build the query that ends up in WITH RECURSIVE""" |  | ||||||
|             # Start from self, aka the current query |  | ||||||
|             # Add a depth attribute to limit the recursion |  | ||||||
|             return self.annotate( |  | ||||||
|                 relative_depth=models.Value(0, output_field=models.IntegerField()) |  | ||||||
|             ).union( |  | ||||||
|                 # Here is the recursive part of the query. cte refers to the previous iteration |  | ||||||
|                 # Only select groups for which the parent is part of the previous iteration |  | ||||||
|                 # and increase the depth |  | ||||||
|                 # Finally, limit the depth |  | ||||||
|                 cte.join(Group, group_uuid=cte.col.parent_id) |  | ||||||
|                 .annotate( |  | ||||||
|                     relative_depth=models.ExpressionWrapper( |  | ||||||
|                         cte.col.relative_depth |  | ||||||
|                         + models.Value(1, output_field=models.IntegerField()), |  | ||||||
|                         output_field=models.IntegerField(), |  | ||||||
|                     ) |  | ||||||
|                 ) |  | ||||||
|                 .filter(relative_depth__lt=GROUP_RECURSION_LIMIT), |  | ||||||
|                 all=True, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         # Build the recursive query, see above |  | ||||||
|         cte = With.recursive(make_cte) |  | ||||||
|         # Return the result, as a usable queryset for Group. |  | ||||||
|         return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Group(SerializerModel): | class Group(SerializerModel): | ||||||
|     """Group model which supports a basic hierarchy and has attributes""" |     """Group model which supports a basic hierarchy and has attributes""" | ||||||
|  |  | ||||||
| @ -156,8 +118,6 @@ class Group(SerializerModel): | |||||||
|     ) |     ) | ||||||
|     attributes = models.JSONField(default=dict, blank=True) |     attributes = models.JSONField(default=dict, blank=True) | ||||||
|  |  | ||||||
|     objects = GroupQuerySet.as_manager() |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Serializer: |     def serializer(self) -> Serializer: | ||||||
|         from authentik.core.api.groups import GroupSerializer |         from authentik.core.api.groups import GroupSerializer | ||||||
| @ -176,11 +136,36 @@ class Group(SerializerModel): | |||||||
|         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"]: |     def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]: | ||||||
|         """Compatibility layer for Group.objects.with_children_recursive()""" |         """Recursively get all groups that have this as parent or are indirectly related""" | ||||||
|         qs = self |         direct_groups = [] | ||||||
|         if not isinstance(self, QuerySet): |         if isinstance(self, QuerySet): | ||||||
|             qs = Group.objects.filter(group_uuid=self.group_uuid) |             direct_groups = list(x for x in self.all().values_list("pk", flat=True).iterator()) | ||||||
|         return qs.with_children_recursive() |         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}" | ||||||
| @ -247,8 +232,10 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): | |||||||
|         return User._meta.get_field("path").default |         return User._meta.get_field("path").default | ||||||
|  |  | ||||||
|     def all_groups(self) -> QuerySet[Group]: |     def all_groups(self) -> QuerySet[Group]: | ||||||
|         """Recursively get all groups this user is a member of.""" |         """Recursively get all groups this user is a member of. | ||||||
|         return self.ak_groups.all().with_children_recursive() |         At least one query is done to get the direct groups of the user, with groups | ||||||
|  |         there are at most 3 queries done""" | ||||||
|  |         return Group.children_recursive(self.ak_groups.all()) | ||||||
|  |  | ||||||
|     def group_attributes(self, request: HttpRequest | None = None) -> dict[str, Any]: |     def group_attributes(self, request: HttpRequest | None = 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, | ||||||
| @ -390,10 +377,6 @@ class Provider(SerializerModel): | |||||||
|         Can return None for providers that are not URL-based""" |         Can return None for providers that are not URL-based""" | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def icon_url(self) -> str | None: |  | ||||||
|         return None |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def component(self) -> str: |     def component(self) -> str: | ||||||
|         """Return component used to edit this object""" |         """Return component used to edit this object""" | ||||||
| @ -784,10 +767,8 @@ class PropertyMapping(SerializerModel, ManagedModel): | |||||||
|         evaluator = PropertyMappingEvaluator(self, user, request, **kwargs) |         evaluator = PropertyMappingEvaluator(self, user, request, **kwargs) | ||||||
|         try: |         try: | ||||||
|             return evaluator.evaluate(self.expression) |             return evaluator.evaluate(self.expression) | ||||||
|         except ControlFlowException as exc: |  | ||||||
|             raise exc |  | ||||||
|         except Exception as exc: |         except Exception as exc: | ||||||
|             raise PropertyMappingExpressionException(self, exc) from exc |             raise PropertyMappingExpressionException(exc) from exc | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return f"Property Mapping {self.name}" |         return f"Property Mapping {self.name}" | ||||||
|  | |||||||
| @ -212,7 +212,7 @@ class SourceFlowManager: | |||||||
|  |  | ||||||
|     def _prepare_flow( |     def _prepare_flow( | ||||||
|         self, |         self, | ||||||
|         flow: Flow | None, |         flow: Flow, | ||||||
|         connection: UserSourceConnection, |         connection: UserSourceConnection, | ||||||
|         stages: list[StageView] | None = None, |         stages: list[StageView] | None = None, | ||||||
|         **kwargs, |         **kwargs, | ||||||
| @ -309,9 +309,7 @@ class SourceFlowManager: | |||||||
|         # When request isn't authenticated we jump straight to auth |         # When request isn't authenticated we jump straight to auth | ||||||
|         if not self.request.user.is_authenticated: |         if not self.request.user.is_authenticated: | ||||||
|             return self.handle_auth(connection) |             return self.handle_auth(connection) | ||||||
|         if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: |         # Connection has already been saved | ||||||
|             return self._prepare_flow(None, connection) |  | ||||||
|         connection.save() |  | ||||||
|         Event.new( |         Event.new( | ||||||
|             EventAction.SOURCE_LINKED, |             EventAction.SOURCE_LINKED, | ||||||
|             message="Linked Source", |             message="Linked Source", | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ | |||||||
|         versionSubdomain: "{{ version_subdomain }}", |         versionSubdomain: "{{ version_subdomain }}", | ||||||
|         build: "{{ build }}", |         build: "{{ build }}", | ||||||
|     }; |     }; | ||||||
|     window.addEventListener("DOMContentLoaded", function () { |     window.addEventListener("DOMContentLoaded", () => { | ||||||
|         {% for message in messages %} |         {% for message in messages %} | ||||||
|         window.dispatchEvent( |         window.dispatchEvent( | ||||||
|             new CustomEvent("ak-message", { |             new CustomEvent("ak-message", { | ||||||
|  | |||||||
| @ -1,10 +1,9 @@ | |||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
| {% load authentik_core %} |  | ||||||
|  |  | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
|  |  | ||||||
| <html> | <html lang="en"> | ||||||
|     <head> |     <head> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
| @ -15,8 +14,8 @@ | |||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject> | ||||||
|         {% versioned_script "dist/poly-%v.js" %} |         <script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script> | ||||||
|         {% versioned_script "dist/standalone/loading/index-%v.js" %} |         <script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script> | ||||||
|         {% block head %} |         {% block head %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <meta name="sentry-trace" content="{{ sentry_trace }}" /> |         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||||
|  | |||||||
| @ -1,9 +1,9 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
|  |  | ||||||
| {% load authentik_core %} | {% load static %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| {% versioned_script "dist/admin/AdminInterface-%v.js" %} | <script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script> | ||||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||||
| {% include "base/header_js.html" %} | {% include "base/header_js.html" %} | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
| 
 | 
 | ||||||
| {% load static %} | {% load static %} | ||||||
| {% load authentik_core %} |  | ||||||
| 
 | 
 | ||||||
| {% block head_before %} | {% block head_before %} | ||||||
| {{ block.super }} | {{ block.super }} | ||||||
| @ -18,7 +17,7 @@ window.authentik.flow = { | |||||||
| {% endblock %} | {% endblock %} | ||||||
| 
 | 
 | ||||||
| {% block head %} | {% block head %} | ||||||
| {% versioned_script "dist/flow/FlowInterface-%v.js" %} | <script src="{% static 'dist/flow/FlowInterface.js' %}?version={{ version }}" type="module"></script> | ||||||
| <style> | <style> | ||||||
| :root { | :root { | ||||||
|     --ak-flow-background: url("{{ flow.background_url }}"); |     --ak-flow-background: url("{{ flow.background_url }}"); | ||||||
| @ -1,9 +1,9 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
|  |  | ||||||
| {% load authentik_core %} | {% load static %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| {% versioned_script "dist/user/UserInterface-%v.js" %} | <script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script> | ||||||
| <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)"> | ||||||
| <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)"> | ||||||
| {% include "base/header_js.html" %} | {% include "base/header_js.html" %} | ||||||
|  | |||||||
| @ -71,9 +71,9 @@ | |||||||
|                 </li> |                 </li> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|                 <li> |                 <li> | ||||||
|                     <span> |                     <a href="https://goauthentik.io?utm_source=authentik"> | ||||||
|                         {% trans 'Powered by authentik' %} |                         {% trans 'Powered by authentik' %} | ||||||
|                     </span> |                     </a> | ||||||
|                 </li> |                 </li> | ||||||
|             </ul> |             </ul> | ||||||
|         </footer> |         </footer> | ||||||
|  | |||||||
| @ -1,21 +0,0 @@ | |||||||
| """authentik core tags""" |  | ||||||
|  |  | ||||||
| from django import template |  | ||||||
| from django.templatetags.static import static as static_loader |  | ||||||
| from django.utils.safestring import mark_safe |  | ||||||
|  |  | ||||||
| from authentik import get_full_version |  | ||||||
|  |  | ||||||
| register = template.Library() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @register.simple_tag() |  | ||||||
| def versioned_script(path: str) -> str: |  | ||||||
|     """Wrapper around {% static %} tag that supports setting the version""" |  | ||||||
|     returned_lines = [ |  | ||||||
|         ( |  | ||||||
|             f'<script src="{static_loader(path.replace("%v", get_full_version()))}' |  | ||||||
|             '" type="module"></script>' |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
|     return mark_safe("".join(returned_lines))  # nosec |  | ||||||
| @ -23,17 +23,6 @@ class TestGroupsAPI(APITestCase): | |||||||
|         response = self.client.get(reverse("authentik_api:group-list"), {"include_users": "true"}) |         response = self.client.get(reverse("authentik_api:group-list"), {"include_users": "true"}) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|     def test_retrieve_with_users(self): |  | ||||||
|         """Test retrieve with users""" |  | ||||||
|         admin = create_test_admin_user() |  | ||||||
|         group = Group.objects.create(name=generate_id()) |  | ||||||
|         self.client.force_login(admin) |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse("authentik_api:group-detail", kwargs={"pk": group.pk}), |  | ||||||
|             {"include_users": "true"}, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  |  | ||||||
|     def test_add_user(self): |     def test_add_user(self): | ||||||
|         """Test add_user""" |         """Test add_user""" | ||||||
|         group = Group.objects.create(name=generate_id()) |         group = Group.objects.create(name=generate_id()) | ||||||
|  | |||||||
| @ -1,14 +1,14 @@ | |||||||
| """authentik core models tests""" | """authentik core models tests""" | ||||||
|  |  | ||||||
| from collections.abc import Callable | from collections.abc import Callable | ||||||
| from datetime import timedelta | from time import sleep | ||||||
|  |  | ||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from freezegun import freeze_time |  | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.models import Provider, Source, Token | from authentik.core.models import Provider, Source, Token | ||||||
|  | from authentik.flows.models import Stage | ||||||
| from authentik.lib.utils.reflection import all_subclasses | from authentik.lib.utils.reflection import all_subclasses | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -17,20 +17,18 @@ class TestModels(TestCase): | |||||||
|  |  | ||||||
|     def test_token_expire(self): |     def test_token_expire(self): | ||||||
|         """Test token expiring""" |         """Test token expiring""" | ||||||
|         with freeze_time() as freeze: |         token = Token.objects.create(expires=now(), user=get_anonymous_user()) | ||||||
|             token = Token.objects.create(expires=now(), user=get_anonymous_user()) |         sleep(0.5) | ||||||
|             freeze.tick(timedelta(seconds=1)) |         self.assertTrue(token.is_expired) | ||||||
|             self.assertTrue(token.is_expired) |  | ||||||
|  |  | ||||||
|     def test_token_expire_no_expire(self): |     def test_token_expire_no_expire(self): | ||||||
|         """Test token expiring with "expiring" set""" |         """Test token expiring with "expiring" set""" | ||||||
|         with freeze_time() as freeze: |         token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False) | ||||||
|             token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False) |         sleep(0.5) | ||||||
|             freeze.tick(timedelta(seconds=1)) |         self.assertFalse(token.is_expired) | ||||||
|             self.assertFalse(token.is_expired) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def source_tester_factory(test_model: type[Source]) -> Callable: | def source_tester_factory(test_model: type[Stage]) -> Callable: | ||||||
|     """Test source""" |     """Test source""" | ||||||
|  |  | ||||||
|     factory = RequestFactory() |     factory = RequestFactory() | ||||||
| @ -38,19 +36,19 @@ def source_tester_factory(test_model: type[Source]) -> Callable: | |||||||
|  |  | ||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|         model_class = None |         model_class = None | ||||||
|         if test_model._meta.abstract: |         if test_model._meta.abstract:  # pragma: no cover | ||||||
|             model_class = [x for x in test_model.__bases__ if issubclass(x, Source)][0]() |             model_class = test_model.__bases__[0]() | ||||||
|         else: |         else: | ||||||
|             model_class = test_model() |             model_class = test_model() | ||||||
|         model_class.slug = "test" |         model_class.slug = "test" | ||||||
|         self.assertIsNotNone(model_class.component) |         self.assertIsNotNone(model_class.component) | ||||||
|         model_class.ui_login_button(request) |         _ = model_class.ui_login_button(request) | ||||||
|         model_class.ui_user_settings() |         _ = model_class.ui_user_settings() | ||||||
|  |  | ||||||
|     return tester |     return tester | ||||||
|  |  | ||||||
|  |  | ||||||
| def provider_tester_factory(test_model: type[Provider]) -> Callable: | def provider_tester_factory(test_model: type[Stage]) -> Callable: | ||||||
|     """Test provider""" |     """Test provider""" | ||||||
|  |  | ||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|  | |||||||
| @ -3,10 +3,7 @@ | |||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.expression.exceptions import ( | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
|     PropertyMappingExpressionException, |  | ||||||
|     SkipObjectException, |  | ||||||
| ) |  | ||||||
| from authentik.core.models import PropertyMapping | from authentik.core.models import PropertyMapping | ||||||
| from authentik.core.tests.utils import create_test_admin_user | from authentik.core.tests.utils import create_test_admin_user | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| @ -45,17 +42,6 @@ class TestPropertyMappings(TestCase): | |||||||
|         self.assertTrue(events.exists()) |         self.assertTrue(events.exists()) | ||||||
|         self.assertEqual(len(events), 1) |         self.assertEqual(len(events), 1) | ||||||
|  |  | ||||||
|     def test_expression_skip(self): |  | ||||||
|         """Test expression error""" |  | ||||||
|         expr = "raise SkipObject" |  | ||||||
|         mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr) |  | ||||||
|         with self.assertRaises(SkipObjectException): |  | ||||||
|             mapping.evaluate(None, None) |  | ||||||
|         events = Event.objects.filter( |  | ||||||
|             action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr |  | ||||||
|         ) |  | ||||||
|         self.assertFalse(events.exists()) |  | ||||||
|  |  | ||||||
|     def test_expression_error_extended(self): |     def test_expression_error_extended(self): | ||||||
|         """Test expression error (with user and http request""" |         """Test expression error (with user and http request""" | ||||||
|         expr = "return aaa" |         expr = "return aaa" | ||||||
| @ -80,11 +66,14 @@ class TestPropertyMappings(TestCase): | |||||||
|             expression="return request.http_request.path", |             expression="return request.http_request.path", | ||||||
|         ) |         ) | ||||||
|         http_request = self.factory.get("/") |         http_request = self.factory.get("/") | ||||||
|         tmpl = f""" |         tmpl = ( | ||||||
|         res = ak_call_policy('{expr.name}') |             """ | ||||||
|  |         res = ak_call_policy('%s') | ||||||
|         result = [request.http_request.path, res.raw_result] |         result = [request.http_request.path, res.raw_result] | ||||||
|         return result |         return result | ||||||
|         """ |         """ | ||||||
|  |             % expr.name | ||||||
|  |         ) | ||||||
|         evaluator = PropertyMapping(expression=tmpl, name=generate_id()) |         evaluator = PropertyMapping(expression=tmpl, name=generate_id()) | ||||||
|         res = evaluator.evaluate(self.user, http_request) |         res = evaluator.evaluate(self.user, http_request) | ||||||
|         self.assertEqual(res, ["/", "/"]) |         self.assertEqual(res, ["/", "/"]) | ||||||
|  | |||||||
| @ -6,10 +6,9 @@ from django.urls import reverse | |||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.api.property_mappings import PropertyMappingSerializer | from authentik.core.api.propertymappings import PropertyMappingSerializer | ||||||
| from authentik.core.models import Group, PropertyMapping | from authentik.core.models import PropertyMapping | ||||||
| from authentik.core.tests.utils import create_test_admin_user | from authentik.core.tests.utils import create_test_admin_user | ||||||
| from authentik.lib.generators import generate_id |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestPropertyMappingAPI(APITestCase): | class TestPropertyMappingAPI(APITestCase): | ||||||
| @ -17,40 +16,23 @@ class TestPropertyMappingAPI(APITestCase): | |||||||
|  |  | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         super().setUp() |         super().setUp() | ||||||
|  |         self.mapping = PropertyMapping.objects.create( | ||||||
|  |             name="dummy", expression="""return {'foo': 'bar'}""" | ||||||
|  |         ) | ||||||
|         self.user = create_test_admin_user() |         self.user = create_test_admin_user() | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|  |  | ||||||
|     def test_test_call(self): |     def test_test_call(self): | ||||||
|         """Test PropertyMappings's test endpoint""" |         """Test PropertMappings's test endpoint""" | ||||||
|         mapping = PropertyMapping.objects.create( |  | ||||||
|             name="dummy", expression="""return {'foo': 'bar', 'baz': user.username}""" |  | ||||||
|         ) |  | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:propertymapping-test", kwargs={"pk": mapping.pk}), |             reverse("authentik_api:propertymapping-test", kwargs={"pk": self.mapping.pk}), | ||||||
|             data={ |             data={ | ||||||
|                 "user": self.user.pk, |                 "user": self.user.pk, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             response.content.decode(), |             response.content.decode(), | ||||||
|             {"result": dumps({"foo": "bar", "baz": self.user.username}), "successful": True}, |             {"result": dumps({"foo": "bar"}), "successful": True}, | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_test_call_group(self): |  | ||||||
|         """Test PropertyMappings's test endpoint""" |  | ||||||
|         mapping = PropertyMapping.objects.create( |  | ||||||
|             name="dummy", expression="""return {'foo': 'bar', 'baz': group.name}""" |  | ||||||
|         ) |  | ||||||
|         group = Group.objects.create(name=generate_id()) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_api:propertymapping-test", kwargs={"pk": mapping.pk}), |  | ||||||
|             data={ |  | ||||||
|                 "group": group.pk, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             response.content.decode(), |  | ||||||
|             {"result": dumps({"foo": "bar", "baz": group.name}), "successful": True}, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_validate(self): |     def test_validate(self): | ||||||
|  | |||||||
| @ -42,8 +42,8 @@ class TestUsersAvatars(APITestCase): | |||||||
|         with Mocker() as mocker: |         with Mocker() as mocker: | ||||||
|             mocker.head( |             mocker.head( | ||||||
|                 ( |                 ( | ||||||
|                     "https://www.gravatar.com/avatar/76eb3c74c8beb6faa037f1b6e2ecb3e252bdac" |                     "https://secure.gravatar.com/avatar/84730f9c1851d1ea03f1a" | ||||||
|                     "6cf71fb567ae36025a9d4ea86b?size=158&rating=g&default=404" |                     "a9ed85bd1ea?size=158&rating=g&default=404" | ||||||
|                 ), |                 ), | ||||||
|                 text="foo", |                 text="foo", | ||||||
|             ) |             ) | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ from authentik.core.api.applications import ApplicationViewSet | |||||||
| from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | ||||||
| from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet | from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet | ||||||
| from authentik.core.api.groups import GroupViewSet | from authentik.core.api.groups import GroupViewSet | ||||||
| from authentik.core.api.property_mappings import PropertyMappingViewSet | from authentik.core.api.propertymappings import PropertyMappingViewSet | ||||||
| from authentik.core.api.providers import ProviderViewSet | from authentik.core.api.providers import ProviderViewSet | ||||||
| from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet | from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet | ||||||
| from authentik.core.api.tokens import TokenViewSet | from authentik.core.api.tokens import TokenViewSet | ||||||
| @ -20,9 +20,8 @@ from authentik.core.api.transactional_applications import TransactionalApplicati | |||||||
| from authentik.core.api.users import UserViewSet | from authentik.core.api.users import UserViewSet | ||||||
| from authentik.core.views import apps | from authentik.core.views import apps | ||||||
| from authentik.core.views.debug import AccessDeniedView | from authentik.core.views.debug import AccessDeniedView | ||||||
| from authentik.core.views.interface import InterfaceView | from authentik.core.views.interface import FlowInterfaceView, InterfaceView | ||||||
| from authentik.core.views.session import EndSessionView | from authentik.core.views.session import EndSessionView | ||||||
| from authentik.flows.views.interface import FlowInterfaceView |  | ||||||
| from authentik.root.asgi_middleware import SessionMiddleware | from authentik.root.asgi_middleware import SessionMiddleware | ||||||
| from authentik.root.messages.consumer import MessageConsumer | from authentik.root.messages.consumer import MessageConsumer | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
| @ -54,8 +53,6 @@ urlpatterns = [ | |||||||
|     ), |     ), | ||||||
|     path( |     path( | ||||||
|         "if/flow/<slug:flow_slug>/", |         "if/flow/<slug:flow_slug>/", | ||||||
|         # FIXME: move this url to the flows app...also will cause all |  | ||||||
|         # of the reverse calls to be adjusted |  | ||||||
|         ensure_csrf_cookie(FlowInterfaceView.as_view()), |         ensure_csrf_cookie(FlowInterfaceView.as_view()), | ||||||
|         name="if-flow", |         name="if-flow", | ||||||
|     ), |     ), | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ | |||||||
| from json import dumps | from json import dumps | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
|  | from django.shortcuts import get_object_or_404 | ||||||
| from django.views.generic.base import TemplateView | from django.views.generic.base import TemplateView | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
|  |  | ||||||
| @ -10,6 +11,7 @@ from authentik import get_build_hash | |||||||
| from authentik.admin.tasks import LOCAL_VERSION | from authentik.admin.tasks import LOCAL_VERSION | ||||||
| from authentik.api.v3.config import ConfigView | from authentik.api.v3.config import ConfigView | ||||||
| from authentik.brands.api import CurrentBrandSerializer | from authentik.brands.api import CurrentBrandSerializer | ||||||
|  | from authentik.flows.models import Flow | ||||||
|  |  | ||||||
|  |  | ||||||
| class InterfaceView(TemplateView): | class InterfaceView(TemplateView): | ||||||
| @ -23,3 +25,14 @@ class InterfaceView(TemplateView): | |||||||
|         kwargs["build"] = get_build_hash() |         kwargs["build"] = get_build_hash() | ||||||
|         kwargs["url_kwargs"] = self.kwargs |         kwargs["url_kwargs"] = self.kwargs | ||||||
|         return super().get_context_data(**kwargs) |         return super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FlowInterfaceView(InterfaceView): | ||||||
|  |     """Flow interface""" | ||||||
|  |  | ||||||
|  |     template_name = "if/flow.html" | ||||||
|  |  | ||||||
|  |     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||||
|  |         kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) | ||||||
|  |         kwargs["inspector"] = "inspector" in self.request.GET | ||||||
|  |         return super().get_context_data(**kwargs) | ||||||
|  | |||||||
| @ -24,12 +24,13 @@ from rest_framework.fields import ( | |||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.authorization import SecretKeyFilter | from authentik.api.authorization import SecretKeyFilter | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ModelSerializer, PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.crypto.apps import MANAGED_KEY | from authentik.crypto.apps import MANAGED_KEY | ||||||
| from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg | from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
|  | |||||||
| @ -92,11 +92,7 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel): | |||||||
|     @property |     @property | ||||||
|     def kid(self): |     def kid(self): | ||||||
|         """Get Key ID used for JWKS""" |         """Get Key ID used for JWKS""" | ||||||
|         return ( |         return md5(self.key_data.encode("utf-8")).hexdigest() if self.key_data else ""  # nosec | ||||||
|             md5(self.key_data.encode("utf-8"), usedforsecurity=False).hexdigest() |  | ||||||
|             if self.key_data |  | ||||||
|             else "" |  | ||||||
|         )  # nosec |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Certificate-Key Pair {self.name}" |         return f"Certificate-Key Pair {self.name}" | ||||||
|  | |||||||
| @ -281,7 +281,7 @@ class TestCrypto(APITestCase): | |||||||
|                     "model_name": "oauth2provider", |                     "model_name": "oauth2provider", | ||||||
|                     "pk": str(provider.pk), |                     "pk": str(provider.pk), | ||||||
|                     "name": str(provider), |                     "name": str(provider), | ||||||
|                     "action": DeleteAction.SET_NULL.value, |                     "action": DeleteAction.SET_NULL.name, | ||||||
|                 } |                 } | ||||||
|             ], |             ], | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -13,10 +13,11 @@ from rest_framework.fields import CharField, IntegerField | |||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import 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.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ModelSerializer, PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import User, UserTypes | from authentik.core.models import User, UserTypes | ||||||
| from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer | from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer | ||||||
| from authentik.enterprise.models import License | from authentik.enterprise.models import License | ||||||
|  | |||||||
| @ -1,47 +0,0 @@ | |||||||
| """GoogleWorkspaceProviderGroup API Views""" |  | ||||||
|  |  | ||||||
| from rest_framework import mixins |  | ||||||
| from rest_framework.viewsets import GenericViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.users import UserGroupSerializer |  | ||||||
| from authentik.core.api.utils import ModelSerializer |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderGroup |  | ||||||
| from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderGroupSerializer(ModelSerializer): |  | ||||||
|     """GoogleWorkspaceProviderGroup Serializer""" |  | ||||||
|  |  | ||||||
|     group_obj = UserGroupSerializer(source="group", read_only=True) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|  |  | ||||||
|         model = GoogleWorkspaceProviderGroup |  | ||||||
|         fields = [ |  | ||||||
|             "id", |  | ||||||
|             "google_id", |  | ||||||
|             "group", |  | ||||||
|             "group_obj", |  | ||||||
|             "provider", |  | ||||||
|             "attributes", |  | ||||||
|         ] |  | ||||||
|         extra_kwargs = {"attributes": {"read_only": True}} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderGroupViewSet( |  | ||||||
|     mixins.CreateModelMixin, |  | ||||||
|     OutgoingSyncConnectionCreateMixin, |  | ||||||
|     mixins.RetrieveModelMixin, |  | ||||||
|     mixins.DestroyModelMixin, |  | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |  | ||||||
|     GenericViewSet, |  | ||||||
| ): |  | ||||||
|     """GoogleWorkspaceProviderGroup Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = GoogleWorkspaceProviderGroup.objects.all().select_related("group") |  | ||||||
|     serializer_class = GoogleWorkspaceProviderGroupSerializer |  | ||||||
|     filterset_fields = ["provider__id", "group__name", "group__group_uuid"] |  | ||||||
|     search_fields = ["provider__name", "group__name"] |  | ||||||
|     ordering = ["group__name"] |  | ||||||
| @ -1,39 +0,0 @@ | |||||||
| """google Property mappings API Views""" |  | ||||||
|  |  | ||||||
| from django_filters.filters import AllValuesMultipleFilter |  | ||||||
| from django_filters.filterset import FilterSet |  | ||||||
| from drf_spectacular.types import OpenApiTypes |  | ||||||
| from drf_spectacular.utils import extend_schema_field |  | ||||||
| from rest_framework.viewsets import ModelViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.property_mappings import PropertyMappingSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderMapping |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderMappingSerializer(PropertyMappingSerializer): |  | ||||||
|     """GoogleWorkspaceProviderMapping Serializer""" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = GoogleWorkspaceProviderMapping |  | ||||||
|         fields = PropertyMappingSerializer.Meta.fields |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderMappingFilter(FilterSet): |  | ||||||
|     """Filter for GoogleWorkspaceProviderMapping""" |  | ||||||
|  |  | ||||||
|     managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = GoogleWorkspaceProviderMapping |  | ||||||
|         fields = "__all__" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderMappingViewSet(UsedByMixin, ModelViewSet): |  | ||||||
|     """GoogleWorkspaceProviderMapping Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = GoogleWorkspaceProviderMapping.objects.all() |  | ||||||
|     serializer_class = GoogleWorkspaceProviderMappingSerializer |  | ||||||
|     filterset_class = GoogleWorkspaceProviderMappingFilter |  | ||||||
|     search_fields = ["name"] |  | ||||||
|     ordering = ["name"] |  | ||||||
| @ -1,54 +0,0 @@ | |||||||
| """Google Provider API Views""" |  | ||||||
|  |  | ||||||
| from rest_framework.viewsets import ModelViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.providers import ProviderSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.enterprise.api import EnterpriseRequiredMixin |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider |  | ||||||
| from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync |  | ||||||
| from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): |  | ||||||
|     """GoogleWorkspaceProvider Serializer""" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = GoogleWorkspaceProvider |  | ||||||
|         fields = [ |  | ||||||
|             "pk", |  | ||||||
|             "name", |  | ||||||
|             "property_mappings", |  | ||||||
|             "property_mappings_group", |  | ||||||
|             "component", |  | ||||||
|             "assigned_backchannel_application_slug", |  | ||||||
|             "assigned_backchannel_application_name", |  | ||||||
|             "verbose_name", |  | ||||||
|             "verbose_name_plural", |  | ||||||
|             "meta_model_name", |  | ||||||
|             "delegated_subject", |  | ||||||
|             "credentials", |  | ||||||
|             "scopes", |  | ||||||
|             "exclude_users_service_account", |  | ||||||
|             "filter_group", |  | ||||||
|             "user_delete_action", |  | ||||||
|             "group_delete_action", |  | ||||||
|             "default_group_email_domain", |  | ||||||
|         ] |  | ||||||
|         extra_kwargs = {} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet): |  | ||||||
|     """GoogleWorkspaceProvider Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = GoogleWorkspaceProvider.objects.all() |  | ||||||
|     serializer_class = GoogleWorkspaceProviderSerializer |  | ||||||
|     filterset_fields = [ |  | ||||||
|         "name", |  | ||||||
|         "exclude_users_service_account", |  | ||||||
|         "delegated_subject", |  | ||||||
|         "filter_group", |  | ||||||
|     ] |  | ||||||
|     search_fields = ["name"] |  | ||||||
|     ordering = ["name"] |  | ||||||
|     sync_single_task = google_workspace_sync |  | ||||||
| @ -1,47 +0,0 @@ | |||||||
| """GoogleWorkspaceProviderUser API Views""" |  | ||||||
|  |  | ||||||
| from rest_framework import mixins |  | ||||||
| from rest_framework.viewsets import GenericViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.groups import GroupMemberSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import ModelSerializer |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderUser |  | ||||||
| from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderUserSerializer(ModelSerializer): |  | ||||||
|     """GoogleWorkspaceProviderUser Serializer""" |  | ||||||
|  |  | ||||||
|     user_obj = GroupMemberSerializer(source="user", read_only=True) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|  |  | ||||||
|         model = GoogleWorkspaceProviderUser |  | ||||||
|         fields = [ |  | ||||||
|             "id", |  | ||||||
|             "google_id", |  | ||||||
|             "user", |  | ||||||
|             "user_obj", |  | ||||||
|             "provider", |  | ||||||
|             "attributes", |  | ||||||
|         ] |  | ||||||
|         extra_kwargs = {"attributes": {"read_only": True}} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderUserViewSet( |  | ||||||
|     mixins.CreateModelMixin, |  | ||||||
|     OutgoingSyncConnectionCreateMixin, |  | ||||||
|     mixins.RetrieveModelMixin, |  | ||||||
|     mixins.DestroyModelMixin, |  | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |  | ||||||
|     GenericViewSet, |  | ||||||
| ): |  | ||||||
|     """GoogleWorkspaceProviderUser Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = GoogleWorkspaceProviderUser.objects.all().select_related("user") |  | ||||||
|     serializer_class = GoogleWorkspaceProviderUserSerializer |  | ||||||
|     filterset_fields = ["provider__id", "user__username", "user__id"] |  | ||||||
|     search_fields = ["provider__name", "user__username"] |  | ||||||
|     ordering = ["user__username"] |  | ||||||
| @ -1,9 +0,0 @@ | |||||||
| from authentik.enterprise.apps import EnterpriseConfig |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikEnterpriseProviderGoogleConfig(EnterpriseConfig): |  | ||||||
|  |  | ||||||
|     name = "authentik.enterprise.providers.google_workspace" |  | ||||||
|     label = "authentik_providers_google_workspace" |  | ||||||
|     verbose_name = "authentik Enterprise.Providers.Google Workspace" |  | ||||||
|     default = True |  | ||||||
| @ -1,74 +0,0 @@ | |||||||
| from django.db.models import Model |  | ||||||
| from django.http import HttpResponseBadRequest, HttpResponseNotFound |  | ||||||
| from google.auth.exceptions import GoogleAuthError, TransportError |  | ||||||
| from googleapiclient.discovery import build |  | ||||||
| from googleapiclient.errors import Error, HttpError |  | ||||||
| from googleapiclient.http import HttpRequest |  | ||||||
| from httplib2 import HttpLib2Error, HttpLib2ErrorWithResponse |  | ||||||
|  |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider |  | ||||||
| from authentik.lib.sync.outgoing import HTTP_CONFLICT |  | ||||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient |  | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( |  | ||||||
|     BadRequestSyncException, |  | ||||||
|     NotFoundSyncException, |  | ||||||
|     ObjectExistsSyncException, |  | ||||||
|     StopSync, |  | ||||||
|     TransientSyncException, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceSyncClient[TModel: Model, TConnection: Model, TSchema: dict]( |  | ||||||
|     BaseOutgoingSyncClient[TModel, TConnection, TSchema, GoogleWorkspaceProvider] |  | ||||||
| ): |  | ||||||
|     """Base client for syncing to google workspace""" |  | ||||||
|  |  | ||||||
|     domains: list |  | ||||||
|  |  | ||||||
|     def __init__(self, provider: GoogleWorkspaceProvider) -> None: |  | ||||||
|         super().__init__(provider) |  | ||||||
|         self.directory_service = build( |  | ||||||
|             "admin", |  | ||||||
|             "directory_v1", |  | ||||||
|             cache_discovery=False, |  | ||||||
|             **provider.google_credentials(), |  | ||||||
|         ) |  | ||||||
|         self.__prefetch_domains() |  | ||||||
|  |  | ||||||
|     def __prefetch_domains(self): |  | ||||||
|         self.domains = [] |  | ||||||
|         domains = self._request(self.directory_service.domains().list(customer="my_customer")) |  | ||||||
|         for domain in domains.get("domains", []): |  | ||||||
|             domain_name = domain.get("domainName") |  | ||||||
|             self.domains.append(domain_name) |  | ||||||
|  |  | ||||||
|     def _request(self, request: HttpRequest): |  | ||||||
|         try: |  | ||||||
|             response = request.execute() |  | ||||||
|         except GoogleAuthError as exc: |  | ||||||
|             if isinstance(exc, TransportError): |  | ||||||
|                 raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc |  | ||||||
|             raise StopSync(exc) from exc |  | ||||||
|         except HttpLib2Error as exc: |  | ||||||
|             if isinstance(exc, HttpLib2ErrorWithResponse): |  | ||||||
|                 self._response_handle_status_code(request.body, exc.response.status, exc) |  | ||||||
|             raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc |  | ||||||
|         except HttpError as exc: |  | ||||||
|             self._response_handle_status_code(request.body, exc.status_code, exc) |  | ||||||
|             raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc |  | ||||||
|         except Error as exc: |  | ||||||
|             raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc |  | ||||||
|         return response |  | ||||||
|  |  | ||||||
|     def _response_handle_status_code(self, request: dict, status_code: int, root_exc: Exception): |  | ||||||
|         if status_code == HttpResponseNotFound.status_code: |  | ||||||
|             raise NotFoundSyncException("Object not found") from root_exc |  | ||||||
|         if status_code == HTTP_CONFLICT: |  | ||||||
|             raise ObjectExistsSyncException("Object exists") from root_exc |  | ||||||
|         if status_code == HttpResponseBadRequest.status_code: |  | ||||||
|             raise BadRequestSyncException("Bad request", request) from root_exc |  | ||||||
|  |  | ||||||
|     def check_email_valid(self, *emails: str): |  | ||||||
|         for email in emails: |  | ||||||
|             if not any(email.endswith(f"@{domain_name}") for domain_name in self.domains): |  | ||||||
|                 raise BadRequestSyncException(f"Invalid email domain: {email}") |  | ||||||
| @ -1,220 +0,0 @@ | |||||||
| from django.db import transaction |  | ||||||
| from django.utils.text import slugify |  | ||||||
|  |  | ||||||
| from authentik.core.models import Group |  | ||||||
| from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import ( |  | ||||||
|     GoogleWorkspaceProvider, |  | ||||||
|     GoogleWorkspaceProviderGroup, |  | ||||||
|     GoogleWorkspaceProviderMapping, |  | ||||||
|     GoogleWorkspaceProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.mapper import PropertyMappingManager |  | ||||||
| from authentik.lib.sync.outgoing.base import Direction |  | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( |  | ||||||
|     NotFoundSyncException, |  | ||||||
|     ObjectExistsSyncException, |  | ||||||
|     TransientSyncException, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceGroupClient( |  | ||||||
|     GoogleWorkspaceSyncClient[Group, GoogleWorkspaceProviderGroup, dict] |  | ||||||
| ): |  | ||||||
|     """Google client for groups""" |  | ||||||
|  |  | ||||||
|     connection_type = GoogleWorkspaceProviderGroup |  | ||||||
|     connection_type_query = "group" |  | ||||||
|     can_discover = True |  | ||||||
|  |  | ||||||
|     def __init__(self, provider: GoogleWorkspaceProvider) -> None: |  | ||||||
|         super().__init__(provider) |  | ||||||
|         self.mapper = PropertyMappingManager( |  | ||||||
|             self.provider.property_mappings_group.all().order_by("name").select_subclasses(), |  | ||||||
|             GoogleWorkspaceProviderMapping, |  | ||||||
|             ["group", "provider", "connection"], |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def to_schema(self, obj: Group, connection: GoogleWorkspaceProviderGroup) -> dict: |  | ||||||
|         """Convert authentik group""" |  | ||||||
|         return super().to_schema( |  | ||||||
|             obj, |  | ||||||
|             connection=connection, |  | ||||||
|             email=f"{slugify(obj.name)}@{self.provider.default_group_email_domain}", |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def delete(self, obj: Group): |  | ||||||
|         """Delete group""" |  | ||||||
|         google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|             provider=self.provider, group=obj |  | ||||||
|         ).first() |  | ||||||
|         if not google_group: |  | ||||||
|             self.logger.debug("Group does not exist in Google, skipping") |  | ||||||
|             return None |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             if self.provider.group_delete_action == OutgoingSyncDeleteAction.DELETE: |  | ||||||
|                 self._request( |  | ||||||
|                     self.directory_service.groups().delete(groupKey=google_group.google_id) |  | ||||||
|                 ) |  | ||||||
|             google_group.delete() |  | ||||||
|  |  | ||||||
|     def create(self, group: Group): |  | ||||||
|         """Create group from scratch and create a connection object""" |  | ||||||
|         google_group = self.to_schema(group, None) |  | ||||||
|         self.check_email_valid(google_group["email"]) |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             try: |  | ||||||
|                 response = self._request(self.directory_service.groups().insert(body=google_group)) |  | ||||||
|             except ObjectExistsSyncException: |  | ||||||
|                 # group already exists in google workspace, so we can connect them manually |  | ||||||
|                 # for groups we need to fetch the group from google as we connect on |  | ||||||
|                 # ID and not group email |  | ||||||
|                 group_data = self._request( |  | ||||||
|                     self.directory_service.groups().get(groupKey=google_group["email"]) |  | ||||||
|                 ) |  | ||||||
|                 return GoogleWorkspaceProviderGroup.objects.create( |  | ||||||
|                     provider=self.provider, |  | ||||||
|                     group=group, |  | ||||||
|                     google_id=group_data["id"], |  | ||||||
|                     attributes=group_data, |  | ||||||
|                 ) |  | ||||||
|             else: |  | ||||||
|                 return GoogleWorkspaceProviderGroup.objects.create( |  | ||||||
|                     provider=self.provider, |  | ||||||
|                     group=group, |  | ||||||
|                     google_id=response["id"], |  | ||||||
|                     attributes=response, |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|     def update(self, group: Group, connection: GoogleWorkspaceProviderGroup): |  | ||||||
|         """Update existing group""" |  | ||||||
|         google_group = self.to_schema(group, connection) |  | ||||||
|         self.check_email_valid(google_group["email"]) |  | ||||||
|         try: |  | ||||||
|             response = self._request( |  | ||||||
|                 self.directory_service.groups().update( |  | ||||||
|                     groupKey=connection.google_id, |  | ||||||
|                     body=google_group, |  | ||||||
|                 ) |  | ||||||
|             ) |  | ||||||
|             connection.attributes = response |  | ||||||
|             connection.save() |  | ||||||
|         except NotFoundSyncException: |  | ||||||
|             # Resource missing is handled by self.write, which will re-create the group |  | ||||||
|             raise |  | ||||||
|  |  | ||||||
|     def write(self, obj: Group): |  | ||||||
|         google_group, created = super().write(obj) |  | ||||||
|         self.create_sync_members(obj, google_group) |  | ||||||
|         return google_group, created |  | ||||||
|  |  | ||||||
|     def create_sync_members(self, obj: Group, google_group: GoogleWorkspaceProviderGroup): |  | ||||||
|         """Sync all members after a group was created""" |  | ||||||
|         users = list(obj.users.order_by("id").values_list("id", flat=True)) |  | ||||||
|         connections = GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|             provider=self.provider, user__pk__in=users |  | ||||||
|         ).values_list("google_id", flat=True) |  | ||||||
|         self._patch(google_group.google_id, Direction.add, connections) |  | ||||||
|  |  | ||||||
|     def update_group(self, group: Group, action: Direction, users_set: set[int]): |  | ||||||
|         """Update a groups members""" |  | ||||||
|         if action == Direction.add: |  | ||||||
|             return self._patch_add_users(group, users_set) |  | ||||||
|         if action == Direction.remove: |  | ||||||
|             return self._patch_remove_users(group, users_set) |  | ||||||
|  |  | ||||||
|     def _patch(self, google_group_id: str, direction: Direction, members: list[str]): |  | ||||||
|         for user in members: |  | ||||||
|             try: |  | ||||||
|                 if direction == Direction.add: |  | ||||||
|                     self._request( |  | ||||||
|                         self.directory_service.members().insert( |  | ||||||
|                             groupKey=google_group_id, body={"email": user} |  | ||||||
|                         ) |  | ||||||
|                     ) |  | ||||||
|                 if direction == Direction.remove: |  | ||||||
|                     self._request( |  | ||||||
|                         self.directory_service.members().delete( |  | ||||||
|                             groupKey=google_group_id, memberKey=user |  | ||||||
|                         ) |  | ||||||
|                     ) |  | ||||||
|             except ObjectExistsSyncException: |  | ||||||
|                 pass |  | ||||||
|             except TransientSyncException: |  | ||||||
|                 raise |  | ||||||
|  |  | ||||||
|     def _patch_add_users(self, group: Group, users_set: set[int]): |  | ||||||
|         """Add users in users_set to group""" |  | ||||||
|         if len(users_set) < 1: |  | ||||||
|             return |  | ||||||
|         google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|             provider=self.provider, group=group |  | ||||||
|         ).first() |  | ||||||
|         if not google_group: |  | ||||||
|             self.logger.warning( |  | ||||||
|                 "could not sync group membership, group does not exist", group=group |  | ||||||
|             ) |  | ||||||
|             return |  | ||||||
|         user_ids = list( |  | ||||||
|             GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                 user__pk__in=users_set, provider=self.provider |  | ||||||
|             ).values_list("google_id", flat=True) |  | ||||||
|         ) |  | ||||||
|         if len(user_ids) < 1: |  | ||||||
|             return |  | ||||||
|         self._patch(google_group.google_id, Direction.add, user_ids) |  | ||||||
|  |  | ||||||
|     def _patch_remove_users(self, group: Group, users_set: set[int]): |  | ||||||
|         """Remove users in users_set from group""" |  | ||||||
|         if len(users_set) < 1: |  | ||||||
|             return |  | ||||||
|         google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|             provider=self.provider, group=group |  | ||||||
|         ).first() |  | ||||||
|         if not google_group: |  | ||||||
|             self.logger.warning( |  | ||||||
|                 "could not sync group membership, group does not exist", group=group |  | ||||||
|             ) |  | ||||||
|             return |  | ||||||
|         user_ids = list( |  | ||||||
|             GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                 user__pk__in=users_set, provider=self.provider |  | ||||||
|             ).values_list("google_id", flat=True) |  | ||||||
|         ) |  | ||||||
|         if len(user_ids) < 1: |  | ||||||
|             return |  | ||||||
|         self._patch(google_group.google_id, Direction.remove, user_ids) |  | ||||||
|  |  | ||||||
|     def discover(self): |  | ||||||
|         """Iterate through all groups and connect them with authentik groups if possible""" |  | ||||||
|         request = self.directory_service.groups().list( |  | ||||||
|             customer="my_customer", maxResults=500, orderBy="email" |  | ||||||
|         ) |  | ||||||
|         while request: |  | ||||||
|             response = request.execute() |  | ||||||
|             for group in response.get("groups", []): |  | ||||||
|                 self._discover_single_group(group) |  | ||||||
|             request = self.directory_service.groups().list_next( |  | ||||||
|                 previous_request=request, previous_response=response |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def _discover_single_group(self, group: dict): |  | ||||||
|         """handle discovery of a single group""" |  | ||||||
|         google_name = group["name"] |  | ||||||
|         google_id = group["id"] |  | ||||||
|         matching_authentik_group = ( |  | ||||||
|             self.provider.get_object_qs(Group).filter(name=google_name).first() |  | ||||||
|         ) |  | ||||||
|         if not matching_authentik_group: |  | ||||||
|             return |  | ||||||
|         GoogleWorkspaceProviderGroup.objects.get_or_create( |  | ||||||
|             provider=self.provider, |  | ||||||
|             group=matching_authentik_group, |  | ||||||
|             google_id=google_id, |  | ||||||
|             attributes=group, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def update_single_attribute(self, connection: GoogleWorkspaceProviderUser): |  | ||||||
|         group = self.directory_service.groups().get(connection.google_id) |  | ||||||
|         connection.attributes = group |  | ||||||
| @ -1,41 +0,0 @@ | |||||||
| from json import dumps |  | ||||||
|  |  | ||||||
| from httplib2 import Response |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MockHTTP: |  | ||||||
|  |  | ||||||
|     _recorded_requests = [] |  | ||||||
|     _responses = {} |  | ||||||
|  |  | ||||||
|     def __init__( |  | ||||||
|         self, |  | ||||||
|         raise_on_unrecorded=True, |  | ||||||
|     ) -> None: |  | ||||||
|         self._recorded_requests = [] |  | ||||||
|         self._responses = {} |  | ||||||
|         self.raise_on_unrecorded = raise_on_unrecorded |  | ||||||
|  |  | ||||||
|     def add_response(self, uri: str, body: str | dict = "", meta: dict | None = None, method="GET"): |  | ||||||
|         if isinstance(body, dict): |  | ||||||
|             body = dumps(body) |  | ||||||
|         self._responses[(uri, method.upper())] = (body, meta or {"status": "200"}) |  | ||||||
|  |  | ||||||
|     def requests(self): |  | ||||||
|         return self._recorded_requests |  | ||||||
|  |  | ||||||
|     def request( |  | ||||||
|         self, |  | ||||||
|         uri, |  | ||||||
|         method="GET", |  | ||||||
|         body=None, |  | ||||||
|         headers=None, |  | ||||||
|         redirections=1, |  | ||||||
|         connection_type=None, |  | ||||||
|     ): |  | ||||||
|         key = (uri, method.upper()) |  | ||||||
|         self._recorded_requests.append((uri, method, body, headers)) |  | ||||||
|         if key not in self._responses and self.raise_on_unrecorded: |  | ||||||
|             raise AssertionError(key) |  | ||||||
|         body, meta = self._responses[key] |  | ||||||
|         return Response(meta), body.encode("utf-8") |  | ||||||
| @ -1,125 +0,0 @@ | |||||||
| from django.db import transaction |  | ||||||
|  |  | ||||||
| from authentik.core.models import User |  | ||||||
| from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import ( |  | ||||||
|     GoogleWorkspaceProvider, |  | ||||||
|     GoogleWorkspaceProviderMapping, |  | ||||||
|     GoogleWorkspaceProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.mapper import PropertyMappingManager |  | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( |  | ||||||
|     ObjectExistsSyncException, |  | ||||||
|     TransientSyncException, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction |  | ||||||
| from authentik.policies.utils import delete_none_values |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceProviderUser, dict]): |  | ||||||
|     """Sync authentik users into google workspace""" |  | ||||||
|  |  | ||||||
|     connection_type = GoogleWorkspaceProviderUser |  | ||||||
|     connection_type_query = "user" |  | ||||||
|     can_discover = True |  | ||||||
|  |  | ||||||
|     def __init__(self, provider: GoogleWorkspaceProvider) -> None: |  | ||||||
|         super().__init__(provider) |  | ||||||
|         self.mapper = PropertyMappingManager( |  | ||||||
|             self.provider.property_mappings.all().order_by("name").select_subclasses(), |  | ||||||
|             GoogleWorkspaceProviderMapping, |  | ||||||
|             ["provider", "connection"], |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def to_schema(self, obj: User, connection: GoogleWorkspaceProviderUser) -> dict: |  | ||||||
|         """Convert authentik user""" |  | ||||||
|         return delete_none_values(super().to_schema(obj, connection, primaryEmail=obj.email)) |  | ||||||
|  |  | ||||||
|     def delete(self, obj: User): |  | ||||||
|         """Delete user""" |  | ||||||
|         google_user = GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|             provider=self.provider, user=obj |  | ||||||
|         ).first() |  | ||||||
|         if not google_user: |  | ||||||
|             self.logger.debug("User does not exist in Google, skipping") |  | ||||||
|             return None |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             response = None |  | ||||||
|             if self.provider.user_delete_action == OutgoingSyncDeleteAction.DELETE: |  | ||||||
|                 response = self._request( |  | ||||||
|                     self.directory_service.users().delete(userKey=google_user.google_id) |  | ||||||
|                 ) |  | ||||||
|             elif self.provider.user_delete_action == OutgoingSyncDeleteAction.SUSPEND: |  | ||||||
|                 response = self._request( |  | ||||||
|                     self.directory_service.users().update( |  | ||||||
|                         userKey=google_user.google_id, body={"suspended": True} |  | ||||||
|                     ) |  | ||||||
|                 ) |  | ||||||
|             google_user.delete() |  | ||||||
|         return response |  | ||||||
|  |  | ||||||
|     def create(self, user: User): |  | ||||||
|         """Create user from scratch and create a connection object""" |  | ||||||
|         google_user = self.to_schema(user, None) |  | ||||||
|         self.check_email_valid( |  | ||||||
|             google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])] |  | ||||||
|         ) |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             try: |  | ||||||
|                 response = self._request(self.directory_service.users().insert(body=google_user)) |  | ||||||
|             except ObjectExistsSyncException: |  | ||||||
|                 # user already exists in google workspace, so we can connect them manually |  | ||||||
|                 return GoogleWorkspaceProviderUser.objects.create( |  | ||||||
|                     provider=self.provider, user=user, google_id=user.email, attributes={} |  | ||||||
|                 ) |  | ||||||
|             except TransientSyncException as exc: |  | ||||||
|                 raise exc |  | ||||||
|             else: |  | ||||||
|                 return GoogleWorkspaceProviderUser.objects.create( |  | ||||||
|                     provider=self.provider, |  | ||||||
|                     user=user, |  | ||||||
|                     google_id=response["primaryEmail"], |  | ||||||
|                     attributes=response, |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|     def update(self, user: User, connection: GoogleWorkspaceProviderUser): |  | ||||||
|         """Update existing user""" |  | ||||||
|         google_user = self.to_schema(user, connection) |  | ||||||
|         self.check_email_valid( |  | ||||||
|             google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])] |  | ||||||
|         ) |  | ||||||
|         response = self._request( |  | ||||||
|             self.directory_service.users().update(userKey=connection.google_id, body=google_user) |  | ||||||
|         ) |  | ||||||
|         connection.attributes = response |  | ||||||
|         connection.save() |  | ||||||
|  |  | ||||||
|     def discover(self): |  | ||||||
|         """Iterate through all users and connect them with authentik users if possible""" |  | ||||||
|         request = self.directory_service.users().list( |  | ||||||
|             customer="my_customer", maxResults=500, orderBy="email" |  | ||||||
|         ) |  | ||||||
|         while request: |  | ||||||
|             response = request.execute() |  | ||||||
|             for user in response.get("users", []): |  | ||||||
|                 self._discover_single_user(user) |  | ||||||
|             request = self.directory_service.users().list_next( |  | ||||||
|                 previous_request=request, previous_response=response |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def _discover_single_user(self, user: dict): |  | ||||||
|         """handle discovery of a single user""" |  | ||||||
|         email = user["primaryEmail"] |  | ||||||
|         matching_authentik_user = self.provider.get_object_qs(User).filter(email=email).first() |  | ||||||
|         if not matching_authentik_user: |  | ||||||
|             return |  | ||||||
|         GoogleWorkspaceProviderUser.objects.get_or_create( |  | ||||||
|             provider=self.provider, |  | ||||||
|             user=matching_authentik_user, |  | ||||||
|             google_id=email, |  | ||||||
|             attributes=user, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def update_single_attribute(self, connection: GoogleWorkspaceProviderUser): |  | ||||||
|         user = self.directory_service.users().get(connection.google_id) |  | ||||||
|         connection.attributes = user |  | ||||||
| @ -1,167 +0,0 @@ | |||||||
| # Generated by Django 5.0.4 on 2024-05-07 16:03 |  | ||||||
|  |  | ||||||
| import django.db.models.deletion |  | ||||||
| import uuid |  | ||||||
| from django.conf import settings |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     initial = True |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0035_alter_group_options_and_more"), |  | ||||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="GoogleWorkspaceProviderMapping", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "propertymapping_ptr", |  | ||||||
|                     models.OneToOneField( |  | ||||||
|                         auto_created=True, |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         parent_link=True, |  | ||||||
|                         primary_key=True, |  | ||||||
|                         serialize=False, |  | ||||||
|                         to="authentik_core.propertymapping", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Google Workspace Provider Mapping", |  | ||||||
|                 "verbose_name_plural": "Google Workspace Provider Mappings", |  | ||||||
|             }, |  | ||||||
|             bases=("authentik_core.propertymapping",), |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="GoogleWorkspaceProvider", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "provider_ptr", |  | ||||||
|                     models.OneToOneField( |  | ||||||
|                         auto_created=True, |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         parent_link=True, |  | ||||||
|                         primary_key=True, |  | ||||||
|                         serialize=False, |  | ||||||
|                         to="authentik_core.provider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("delegated_subject", models.EmailField(max_length=254)), |  | ||||||
|                 ("credentials", models.JSONField()), |  | ||||||
|                 ( |  | ||||||
|                     "scopes", |  | ||||||
|                     models.TextField( |  | ||||||
|                         default="https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.group,https://www.googleapis.com/auth/admin.directory.group.member,https://www.googleapis.com/auth/admin.directory.domain.readonly" |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("default_group_email_domain", models.TextField()), |  | ||||||
|                 ("exclude_users_service_account", models.BooleanField(default=False)), |  | ||||||
|                 ( |  | ||||||
|                     "user_delete_action", |  | ||||||
|                     models.TextField( |  | ||||||
|                         choices=[ |  | ||||||
|                             ("do_nothing", "Do Nothing"), |  | ||||||
|                             ("delete", "Delete"), |  | ||||||
|                             ("suspend", "Suspend"), |  | ||||||
|                         ], |  | ||||||
|                         default="delete", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "group_delete_action", |  | ||||||
|                     models.TextField( |  | ||||||
|                         choices=[ |  | ||||||
|                             ("do_nothing", "Do Nothing"), |  | ||||||
|                             ("delete", "Delete"), |  | ||||||
|                             ("suspend", "Suspend"), |  | ||||||
|                         ], |  | ||||||
|                         default="delete", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "filter_group", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         default=None, |  | ||||||
|                         null=True, |  | ||||||
|                         on_delete=django.db.models.deletion.SET_DEFAULT, |  | ||||||
|                         to="authentik_core.group", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "property_mappings_group", |  | ||||||
|                     models.ManyToManyField( |  | ||||||
|                         blank=True, |  | ||||||
|                         default=None, |  | ||||||
|                         help_text="Property mappings used for group creation/updating.", |  | ||||||
|                         to="authentik_core.propertymapping", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Google Workspace Provider", |  | ||||||
|                 "verbose_name_plural": "Google Workspace Providers", |  | ||||||
|             }, |  | ||||||
|             bases=("authentik_core.provider", models.Model), |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="GoogleWorkspaceProviderGroup", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "id", |  | ||||||
|                     models.UUIDField( |  | ||||||
|                         default=uuid.uuid4, editable=False, primary_key=True, serialize=False |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("google_id", models.TextField()), |  | ||||||
|                 ( |  | ||||||
|                     "group", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group" |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "provider", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         to="authentik_providers_google_workspace.googleworkspaceprovider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "unique_together": {("google_id", "group", "provider")}, |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="GoogleWorkspaceProviderUser", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "id", |  | ||||||
|                     models.UUIDField( |  | ||||||
|                         default=uuid.uuid4, editable=False, primary_key=True, serialize=False |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("google_id", models.TextField()), |  | ||||||
|                 ( |  | ||||||
|                     "provider", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         to="authentik_providers_google_workspace.googleworkspaceprovider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "user", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "unique_together": {("google_id", "user", "provider")}, |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,179 +0,0 @@ | |||||||
| # Generated by Django 5.0.6 on 2024-05-09 12:57 |  | ||||||
|  |  | ||||||
| import django.db.models.deletion |  | ||||||
| import uuid |  | ||||||
| from django.conf import settings |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     replaces = [ |  | ||||||
|         ("authentik_providers_google_workspace", "0001_initial"), |  | ||||||
|         ( |  | ||||||
|             "authentik_providers_google_workspace", |  | ||||||
|             "0002_alter_googleworkspaceprovidergroup_options_and_more", |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     initial = True |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0035_alter_group_options_and_more"), |  | ||||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="GoogleWorkspaceProviderMapping", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "propertymapping_ptr", |  | ||||||
|                     models.OneToOneField( |  | ||||||
|                         auto_created=True, |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         parent_link=True, |  | ||||||
|                         primary_key=True, |  | ||||||
|                         serialize=False, |  | ||||||
|                         to="authentik_core.propertymapping", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Google Workspace Provider Mapping", |  | ||||||
|                 "verbose_name_plural": "Google Workspace Provider Mappings", |  | ||||||
|             }, |  | ||||||
|             bases=("authentik_core.propertymapping",), |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="GoogleWorkspaceProvider", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "provider_ptr", |  | ||||||
|                     models.OneToOneField( |  | ||||||
|                         auto_created=True, |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         parent_link=True, |  | ||||||
|                         primary_key=True, |  | ||||||
|                         serialize=False, |  | ||||||
|                         to="authentik_core.provider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("delegated_subject", models.EmailField(max_length=254)), |  | ||||||
|                 ("credentials", models.JSONField()), |  | ||||||
|                 ( |  | ||||||
|                     "scopes", |  | ||||||
|                     models.TextField( |  | ||||||
|                         default="https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.group,https://www.googleapis.com/auth/admin.directory.group.member,https://www.googleapis.com/auth/admin.directory.domain.readonly" |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("default_group_email_domain", models.TextField()), |  | ||||||
|                 ("exclude_users_service_account", models.BooleanField(default=False)), |  | ||||||
|                 ( |  | ||||||
|                     "user_delete_action", |  | ||||||
|                     models.TextField( |  | ||||||
|                         choices=[ |  | ||||||
|                             ("do_nothing", "Do Nothing"), |  | ||||||
|                             ("delete", "Delete"), |  | ||||||
|                             ("suspend", "Suspend"), |  | ||||||
|                         ], |  | ||||||
|                         default="delete", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "group_delete_action", |  | ||||||
|                     models.TextField( |  | ||||||
|                         choices=[ |  | ||||||
|                             ("do_nothing", "Do Nothing"), |  | ||||||
|                             ("delete", "Delete"), |  | ||||||
|                             ("suspend", "Suspend"), |  | ||||||
|                         ], |  | ||||||
|                         default="delete", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "filter_group", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         default=None, |  | ||||||
|                         null=True, |  | ||||||
|                         on_delete=django.db.models.deletion.SET_DEFAULT, |  | ||||||
|                         to="authentik_core.group", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "property_mappings_group", |  | ||||||
|                     models.ManyToManyField( |  | ||||||
|                         blank=True, |  | ||||||
|                         default=None, |  | ||||||
|                         help_text="Property mappings used for group creation/updating.", |  | ||||||
|                         to="authentik_core.propertymapping", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Google Workspace Provider", |  | ||||||
|                 "verbose_name_plural": "Google Workspace Providers", |  | ||||||
|             }, |  | ||||||
|             bases=("authentik_core.provider", models.Model), |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="GoogleWorkspaceProviderGroup", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "id", |  | ||||||
|                     models.UUIDField( |  | ||||||
|                         default=uuid.uuid4, editable=False, primary_key=True, serialize=False |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("google_id", models.TextField()), |  | ||||||
|                 ( |  | ||||||
|                     "group", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group" |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "provider", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         to="authentik_providers_google_workspace.googleworkspaceprovider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "unique_together": {("google_id", "group", "provider")}, |  | ||||||
|                 "verbose_name": "Google Workspace Provider Group", |  | ||||||
|                 "verbose_name_plural": "Google Workspace Provider Groups", |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="GoogleWorkspaceProviderUser", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "id", |  | ||||||
|                     models.UUIDField( |  | ||||||
|                         default=uuid.uuid4, editable=False, primary_key=True, serialize=False |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("google_id", models.TextField()), |  | ||||||
|                 ( |  | ||||||
|                     "provider", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         to="authentik_providers_google_workspace.googleworkspaceprovider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "user", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "unique_together": {("google_id", "user", "provider")}, |  | ||||||
|                 "verbose_name": "Google Workspace Provider User", |  | ||||||
|                 "verbose_name_plural": "Google Workspace Provider Users", |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,27 +0,0 @@ | |||||||
| # Generated by Django 5.0.6 on 2024-05-08 14:35 |  | ||||||
|  |  | ||||||
| from django.db import migrations |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_providers_google_workspace", "0001_initial"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterModelOptions( |  | ||||||
|             name="googleworkspaceprovidergroup", |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Google Workspace Provider Group", |  | ||||||
|                 "verbose_name_plural": "Google Workspace Provider Groups", |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|         migrations.AlterModelOptions( |  | ||||||
|             name="googleworkspaceprovideruser", |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Google Workspace Provider User", |  | ||||||
|                 "verbose_name_plural": "Google Workspace Provider Users", |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,26 +0,0 @@ | |||||||
| # Generated by Django 5.0.6 on 2024-05-23 20:48 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ( |  | ||||||
|             "authentik_providers_google_workspace", |  | ||||||
|             "0001_squashed_0002_alter_googleworkspaceprovidergroup_options_and_more", |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="googleworkspaceprovidergroup", |  | ||||||
|             name="attributes", |  | ||||||
|             field=models.JSONField(default=dict), |  | ||||||
|         ), |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="googleworkspaceprovideruser", |  | ||||||
|             name="attributes", |  | ||||||
|             field=models.JSONField(default=dict), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,199 +0,0 @@ | |||||||
| """Google workspace sync provider""" |  | ||||||
|  |  | ||||||
| from typing import Any, Self |  | ||||||
| from uuid import uuid4 |  | ||||||
|  |  | ||||||
| from django.db import models |  | ||||||
| from django.db.models import QuerySet |  | ||||||
| from django.templatetags.static import static |  | ||||||
| from django.utils.translation import gettext_lazy as _ |  | ||||||
| from google.oauth2.service_account import Credentials |  | ||||||
| from rest_framework.serializers import Serializer |  | ||||||
|  |  | ||||||
| from authentik.core.models import ( |  | ||||||
|     BackchannelProvider, |  | ||||||
|     Group, |  | ||||||
|     PropertyMapping, |  | ||||||
|     User, |  | ||||||
|     UserTypes, |  | ||||||
| ) |  | ||||||
| from authentik.lib.models import SerializerModel |  | ||||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient |  | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_scopes() -> list[str]: |  | ||||||
|     return [ |  | ||||||
|         "https://www.googleapis.com/auth/admin.directory.user", |  | ||||||
|         "https://www.googleapis.com/auth/admin.directory.group", |  | ||||||
|         "https://www.googleapis.com/auth/admin.directory.group.member", |  | ||||||
|         "https://www.googleapis.com/auth/admin.directory.domain.readonly", |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderUser(SerializerModel): |  | ||||||
|     """Mapping of a user and provider to a Google user ID""" |  | ||||||
|  |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     google_id = models.TextField() |  | ||||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) |  | ||||||
|     provider = models.ForeignKey("GoogleWorkspaceProvider", on_delete=models.CASCADE) |  | ||||||
|     attributes = models.JSONField(default=dict) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.google_workspace.api.users import ( |  | ||||||
|             GoogleWorkspaceProviderUserSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return GoogleWorkspaceProviderUserSerializer |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Google Workspace Provider User") |  | ||||||
|         verbose_name_plural = _("Google Workspace Provider Users") |  | ||||||
|         unique_together = (("google_id", "user", "provider"),) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Google Workspace Provider User {self.user_id} to {self.provider_id}" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderGroup(SerializerModel): |  | ||||||
|     """Mapping of a group and provider to a Google group ID""" |  | ||||||
|  |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     google_id = models.TextField() |  | ||||||
|     group = models.ForeignKey(Group, on_delete=models.CASCADE) |  | ||||||
|     provider = models.ForeignKey("GoogleWorkspaceProvider", on_delete=models.CASCADE) |  | ||||||
|     attributes = models.JSONField(default=dict) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.google_workspace.api.groups import ( |  | ||||||
|             GoogleWorkspaceProviderGroupSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return GoogleWorkspaceProviderGroupSerializer |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Google Workspace Provider Group") |  | ||||||
|         verbose_name_plural = _("Google Workspace Provider Groups") |  | ||||||
|         unique_together = (("google_id", "group", "provider"),) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): |  | ||||||
|     """Sync users from authentik into Google Workspace.""" |  | ||||||
|  |  | ||||||
|     delegated_subject = models.EmailField() |  | ||||||
|     credentials = models.JSONField() |  | ||||||
|     scopes = models.TextField(default=",".join(default_scopes())) |  | ||||||
|  |  | ||||||
|     default_group_email_domain = models.TextField() |  | ||||||
|     exclude_users_service_account = models.BooleanField(default=False) |  | ||||||
|     user_delete_action = models.TextField( |  | ||||||
|         choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE |  | ||||||
|     ) |  | ||||||
|     group_delete_action = models.TextField( |  | ||||||
|         choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     filter_group = models.ForeignKey( |  | ||||||
|         "authentik_core.group", on_delete=models.SET_DEFAULT, default=None, null=True |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     property_mappings_group = models.ManyToManyField( |  | ||||||
|         PropertyMapping, |  | ||||||
|         default=None, |  | ||||||
|         blank=True, |  | ||||||
|         help_text=_("Property mappings used for group creation/updating."), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     def client_for_model( |  | ||||||
|         self, |  | ||||||
|         model: type[User | Group | GoogleWorkspaceProviderUser | GoogleWorkspaceProviderGroup], |  | ||||||
|     ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: |  | ||||||
|         if issubclass(model, User | GoogleWorkspaceProviderUser): |  | ||||||
|             from authentik.enterprise.providers.google_workspace.clients.users import ( |  | ||||||
|                 GoogleWorkspaceUserClient, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             return GoogleWorkspaceUserClient(self) |  | ||||||
|         if issubclass(model, Group | GoogleWorkspaceProviderGroup): |  | ||||||
|             from authentik.enterprise.providers.google_workspace.clients.groups import ( |  | ||||||
|                 GoogleWorkspaceGroupClient, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             return GoogleWorkspaceGroupClient(self) |  | ||||||
|         raise ValueError(f"Invalid model {model}") |  | ||||||
|  |  | ||||||
|     def get_object_qs(self, type: type[User | Group]) -> QuerySet[User | Group]: |  | ||||||
|         if type == User: |  | ||||||
|             # Get queryset of all users with consistent ordering |  | ||||||
|             # according to the provider's settings |  | ||||||
|             base = User.objects.all().exclude_anonymous() |  | ||||||
|             if self.exclude_users_service_account: |  | ||||||
|                 base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( |  | ||||||
|                     type=UserTypes.INTERNAL_SERVICE_ACCOUNT |  | ||||||
|                 ) |  | ||||||
|             if self.filter_group: |  | ||||||
|                 base = base.filter(ak_groups__in=[self.filter_group]) |  | ||||||
|             return base.order_by("pk") |  | ||||||
|         if type == Group: |  | ||||||
|             # Get queryset of all groups with consistent ordering |  | ||||||
|             return Group.objects.all().order_by("pk") |  | ||||||
|         raise ValueError(f"Invalid type {type}") |  | ||||||
|  |  | ||||||
|     def google_credentials(self): |  | ||||||
|         return { |  | ||||||
|             "credentials": Credentials.from_service_account_info( |  | ||||||
|                 self.credentials, scopes=self.scopes.split(",") |  | ||||||
|             ).with_subject(self.delegated_subject), |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def icon_url(self) -> str | None: |  | ||||||
|         return static("authentik/sources/google.svg") |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def component(self) -> str: |  | ||||||
|         return "ak-provider-google-workspace-form" |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.google_workspace.api.providers import ( |  | ||||||
|             GoogleWorkspaceProviderSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return GoogleWorkspaceProviderSerializer |  | ||||||
|  |  | ||||||
|     def __str__(self): |  | ||||||
|         return f"Google Workspace Provider {self.name}" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Google Workspace Provider") |  | ||||||
|         verbose_name_plural = _("Google Workspace Providers") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderMapping(PropertyMapping): |  | ||||||
|     """Map authentik data to outgoing Google requests""" |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def component(self) -> str: |  | ||||||
|         return "ak-property-mapping-google-workspace-form" |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.google_workspace.api.property_mappings import ( |  | ||||||
|             GoogleWorkspaceProviderMappingSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return GoogleWorkspaceProviderMappingSerializer |  | ||||||
|  |  | ||||||
|     def __str__(self): |  | ||||||
|         return f"Google Workspace Provider Mapping {self.name}" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Google Workspace Provider Mapping") |  | ||||||
|         verbose_name_plural = _("Google Workspace Provider Mappings") |  | ||||||
| @ -1,13 +0,0 @@ | |||||||
| """Google workspace provider task Settings""" |  | ||||||
|  |  | ||||||
| from celery.schedules import crontab |  | ||||||
|  |  | ||||||
| from authentik.lib.utils.time import fqdn_rand |  | ||||||
|  |  | ||||||
| CELERY_BEAT_SCHEDULE = { |  | ||||||
|     "providers_google_workspace_sync": { |  | ||||||
|         "task": "authentik.enterprise.providers.google_workspace.tasks.google_workspace_sync_all", |  | ||||||
|         "schedule": crontab(minute=fqdn_rand("google_workspace_sync_all"), hour="*/4"), |  | ||||||
|         "options": {"queue": "authentik_scheduled"}, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
| @ -1,16 +0,0 @@ | |||||||
| """Google provider signals""" |  | ||||||
|  |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider |  | ||||||
| from authentik.enterprise.providers.google_workspace.tasks import ( |  | ||||||
|     google_workspace_sync, |  | ||||||
|     google_workspace_sync_direct, |  | ||||||
|     google_workspace_sync_m2m, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.outgoing.signals import register_signals |  | ||||||
|  |  | ||||||
| register_signals( |  | ||||||
|     GoogleWorkspaceProvider, |  | ||||||
|     task_sync_single=google_workspace_sync, |  | ||||||
|     task_sync_direct=google_workspace_sync_direct, |  | ||||||
|     task_sync_m2m=google_workspace_sync_m2m, |  | ||||||
| ) |  | ||||||
| @ -1,37 +0,0 @@ | |||||||
| """Google Provider tasks""" |  | ||||||
|  |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider |  | ||||||
| from authentik.events.system_tasks import SystemTask |  | ||||||
| from authentik.lib.sync.outgoing.exceptions import TransientSyncException |  | ||||||
| from authentik.lib.sync.outgoing.tasks import SyncTasks |  | ||||||
| from authentik.root.celery import CELERY_APP |  | ||||||
|  |  | ||||||
| sync_tasks = SyncTasks(GoogleWorkspaceProvider) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True) |  | ||||||
| def google_workspace_sync_objects(*args, **kwargs): |  | ||||||
|     return sync_tasks.sync_objects(*args, **kwargs) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task( |  | ||||||
|     base=SystemTask, bind=True, autoretry_for=(TransientSyncException,), retry_backoff=True |  | ||||||
| ) |  | ||||||
| def google_workspace_sync(self, provider_pk: int, *args, **kwargs): |  | ||||||
|     """Run full sync for Google Workspace provider""" |  | ||||||
|     return sync_tasks.sync_single(self, provider_pk, google_workspace_sync_objects) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task() |  | ||||||
| def google_workspace_sync_all(): |  | ||||||
|     return sync_tasks.sync_all(google_workspace_sync) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True) |  | ||||||
| def google_workspace_sync_direct(*args, **kwargs): |  | ||||||
|     return sync_tasks.sync_signal_direct(*args, **kwargs) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True) |  | ||||||
| def google_workspace_sync_m2m(*args, **kwargs): |  | ||||||
|     return sync_tasks.sync_signal_m2m(*args, **kwargs) |  | ||||||
| @ -1,14 +0,0 @@ | |||||||
| { |  | ||||||
|     "kind": "admin#directory#domains", |  | ||||||
|     "etag": "\"a1kA7zE2sFLsHiFwgXN9G3effoc9grR2OwUu8_95xD4/uvC5HsKHylhnUtnRV6ZxINODtV0\"", |  | ||||||
|     "domains": [ |  | ||||||
|         { |  | ||||||
|             "kind": "admin#directory#domain", |  | ||||||
|             "etag": "\"a1kA7zE2sFLsHiFwgXN9G3effoc9grR2OwUu8_95xD4/V4koSPWBFIWuIpAmUamO96QhTLo\"", |  | ||||||
|             "domainName": "goauthentik.io", |  | ||||||
|             "isPrimary": true, |  | ||||||
|             "verified": true, |  | ||||||
|             "creationTime": "1543048869840" |  | ||||||
|         } |  | ||||||
|     ] |  | ||||||
| } |  | ||||||
| @ -1,334 +0,0 @@ | |||||||
| """Google Workspace Group tests""" |  | ||||||
|  |  | ||||||
| from unittest.mock import MagicMock, patch |  | ||||||
|  |  | ||||||
| from django.test import TestCase |  | ||||||
|  |  | ||||||
| from authentik.blueprints.tests import apply_blueprint |  | ||||||
| from authentik.core.models import Application, Group, User |  | ||||||
| from authentik.core.tests.utils import create_test_user |  | ||||||
| from authentik.enterprise.providers.google_workspace.clients.test_http import MockHTTP |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import ( |  | ||||||
|     GoogleWorkspaceProvider, |  | ||||||
|     GoogleWorkspaceProviderGroup, |  | ||||||
|     GoogleWorkspaceProviderMapping, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync |  | ||||||
| from authentik.events.models import Event, EventAction |  | ||||||
| from authentik.lib.generators import generate_id |  | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction |  | ||||||
| from authentik.lib.tests.utils import load_fixture |  | ||||||
| from authentik.tenants.models import Tenant |  | ||||||
|  |  | ||||||
| domains_list_v1_mock = load_fixture("fixtures/domains_list_v1.json") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceGroupTests(TestCase): |  | ||||||
|     """Google workspace Group tests""" |  | ||||||
|  |  | ||||||
|     @apply_blueprint("system/providers-google-workspace.yaml") |  | ||||||
|     def setUp(self) -> None: |  | ||||||
|         # Delete all groups and groups as the mocked HTTP responses only return one ID |  | ||||||
|         # which will cause errors with multiple groups |  | ||||||
|         Tenant.objects.update(avatars="none") |  | ||||||
|         User.objects.all().exclude_anonymous().delete() |  | ||||||
|         Group.objects.all().delete() |  | ||||||
|         self.provider: GoogleWorkspaceProvider = GoogleWorkspaceProvider.objects.create( |  | ||||||
|             name=generate_id(), |  | ||||||
|             credentials={}, |  | ||||||
|             delegated_subject="", |  | ||||||
|             exclude_users_service_account=True, |  | ||||||
|             default_group_email_domain="goauthentik.io", |  | ||||||
|         ) |  | ||||||
|         self.app: Application = Application.objects.create( |  | ||||||
|             name=generate_id(), |  | ||||||
|             slug=generate_id(), |  | ||||||
|         ) |  | ||||||
|         self.app.backchannel_providers.add(self.provider) |  | ||||||
|         self.provider.property_mappings.add( |  | ||||||
|             GoogleWorkspaceProviderMapping.objects.get( |  | ||||||
|                 managed="goauthentik.io/providers/google_workspace/user" |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.provider.property_mappings_group.add( |  | ||||||
|             GoogleWorkspaceProviderMapping.objects.get( |  | ||||||
|                 managed="goauthentik.io/providers/google_workspace/group" |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.api_key = generate_id() |  | ||||||
|  |  | ||||||
|     def test_group_create(self): |  | ||||||
|         """Test group creation""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"id": generate_id()}, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             group = Group.objects.create(name=uid) |  | ||||||
|             google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                 provider=self.provider, group=group |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_group) |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 2) |  | ||||||
|  |  | ||||||
|     def test_group_not_created(self): |  | ||||||
|         """Test without group property mappings, no group is created""" |  | ||||||
|         self.provider.property_mappings_group.clear() |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             group = Group.objects.create(name=uid) |  | ||||||
|             google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                 provider=self.provider, group=group |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNone(google_group) |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 1) |  | ||||||
|  |  | ||||||
|     def test_group_create_update(self): |  | ||||||
|         """Test group updating""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         ext_id = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"id": ext_id}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}?key={self.api_key}&alt=json", |  | ||||||
|             method="PUT", |  | ||||||
|             body={"id": ext_id}, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             group = Group.objects.create(name=uid) |  | ||||||
|             google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                 provider=self.provider, group=group |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_group) |  | ||||||
|  |  | ||||||
|             group.name = "new name" |  | ||||||
|             group.save() |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 4) |  | ||||||
|  |  | ||||||
|     def test_group_create_delete(self): |  | ||||||
|         """Test group deletion""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         ext_id = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"id": ext_id}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}?key={self.api_key}", |  | ||||||
|             method="DELETE", |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             group = Group.objects.create(name=uid) |  | ||||||
|             google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                 provider=self.provider, group=group |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_group) |  | ||||||
|  |  | ||||||
|             group.delete() |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 4) |  | ||||||
|  |  | ||||||
|     def test_group_create_member_add(self): |  | ||||||
|         """Test group creation""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         ext_id = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"id": ext_id}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", |  | ||||||
|             method="PUT", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             user = create_test_user(uid) |  | ||||||
|             group = Group.objects.create(name=uid) |  | ||||||
|             group.users.add(user) |  | ||||||
|             google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                 provider=self.provider, group=group |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_group) |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 8) |  | ||||||
|  |  | ||||||
|     def test_group_create_member_remove(self): |  | ||||||
|         """Test group creation""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         ext_id = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"id": ext_id}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", |  | ||||||
|             method="PUT", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members/{uid}%40goauthentik.io?key={self.api_key}", |  | ||||||
|             method="DELETE", |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             user = create_test_user(uid) |  | ||||||
|             group = Group.objects.create(name=uid) |  | ||||||
|             group.users.add(user) |  | ||||||
|             google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                 provider=self.provider, group=group |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_group) |  | ||||||
|             group.users.remove(user) |  | ||||||
|  |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 10) |  | ||||||
|  |  | ||||||
|     def test_group_create_delete_do_nothing(self): |  | ||||||
|         """Test group deletion (delete action = do nothing)""" |  | ||||||
|         self.provider.group_delete_action = OutgoingSyncDeleteAction.DO_NOTHING |  | ||||||
|         self.provider.save() |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"id": uid}, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             group = Group.objects.create(name=uid) |  | ||||||
|             google_group = GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                 provider=self.provider, group=group |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_group) |  | ||||||
|  |  | ||||||
|             group.delete() |  | ||||||
|             self.assertEqual(len(http.requests()), 3) |  | ||||||
|             self.assertFalse( |  | ||||||
|                 GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                     provider=self.provider, group__name=uid |  | ||||||
|                 ).exists() |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def test_sync_task(self): |  | ||||||
|         """Test group discovery""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json", |  | ||||||
|             method="GET", |  | ||||||
|             body={"users": []}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json", |  | ||||||
|             method="GET", |  | ||||||
|             body={"groups": [{"id": uid, "name": uid}]}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups/{uid}?key={self.api_key}&alt=json", |  | ||||||
|             method="PUT", |  | ||||||
|             body={"id": uid}, |  | ||||||
|         ) |  | ||||||
|         self.app.backchannel_providers.remove(self.provider) |  | ||||||
|         different_group = Group.objects.create( |  | ||||||
|             name=uid, |  | ||||||
|         ) |  | ||||||
|         self.app.backchannel_providers.add(self.provider) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             google_workspace_sync.delay(self.provider.pk).get() |  | ||||||
|             self.assertTrue( |  | ||||||
|                 GoogleWorkspaceProviderGroup.objects.filter( |  | ||||||
|                     group=different_group, provider=self.provider |  | ||||||
|                 ).exists() |  | ||||||
|             ) |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 5) |  | ||||||
| @ -1,312 +0,0 @@ | |||||||
| """Google Workspace User tests""" |  | ||||||
|  |  | ||||||
| from json import loads |  | ||||||
| from unittest.mock import MagicMock, patch |  | ||||||
|  |  | ||||||
| from django.test import TestCase |  | ||||||
|  |  | ||||||
| from authentik.blueprints.tests import apply_blueprint |  | ||||||
| from authentik.core.models import Application, Group, User |  | ||||||
| from authentik.enterprise.providers.google_workspace.clients.test_http import MockHTTP |  | ||||||
| from authentik.enterprise.providers.google_workspace.models import ( |  | ||||||
|     GoogleWorkspaceProvider, |  | ||||||
|     GoogleWorkspaceProviderMapping, |  | ||||||
|     GoogleWorkspaceProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync |  | ||||||
| from authentik.events.models import Event, EventAction |  | ||||||
| from authentik.lib.generators import generate_id |  | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction |  | ||||||
| from authentik.lib.tests.utils import load_fixture |  | ||||||
| from authentik.tenants.models import Tenant |  | ||||||
|  |  | ||||||
| domains_list_v1_mock = load_fixture("fixtures/domains_list_v1.json") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceUserTests(TestCase): |  | ||||||
|     """Google workspace User tests""" |  | ||||||
|  |  | ||||||
|     @apply_blueprint("system/providers-google-workspace.yaml") |  | ||||||
|     def setUp(self) -> None: |  | ||||||
|         # Delete all users and groups as the mocked HTTP responses only return one ID |  | ||||||
|         # which will cause errors with multiple users |  | ||||||
|         Tenant.objects.update(avatars="none") |  | ||||||
|         User.objects.all().exclude_anonymous().delete() |  | ||||||
|         Group.objects.all().delete() |  | ||||||
|         self.provider: GoogleWorkspaceProvider = GoogleWorkspaceProvider.objects.create( |  | ||||||
|             name=generate_id(), |  | ||||||
|             credentials={}, |  | ||||||
|             delegated_subject="", |  | ||||||
|             exclude_users_service_account=True, |  | ||||||
|             default_group_email_domain="goauthentik.io", |  | ||||||
|         ) |  | ||||||
|         self.app: Application = Application.objects.create( |  | ||||||
|             name=generate_id(), |  | ||||||
|             slug=generate_id(), |  | ||||||
|         ) |  | ||||||
|         self.app.backchannel_providers.add(self.provider) |  | ||||||
|         self.provider.property_mappings.add( |  | ||||||
|             GoogleWorkspaceProviderMapping.objects.get( |  | ||||||
|                 managed="goauthentik.io/providers/google_workspace/user" |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.provider.property_mappings_group.add( |  | ||||||
|             GoogleWorkspaceProviderMapping.objects.get( |  | ||||||
|                 managed="goauthentik.io/providers/google_workspace/group" |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.api_key = generate_id() |  | ||||||
|  |  | ||||||
|     def test_user_create(self): |  | ||||||
|         """Test user creation""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             user = User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|             google_user = GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                 provider=self.provider, user=user |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_user) |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 2) |  | ||||||
|  |  | ||||||
|     def test_user_not_created(self): |  | ||||||
|         """Test without property mappings, no group is created""" |  | ||||||
|         self.provider.property_mappings.clear() |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             user = User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|             google_user = GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                 provider=self.provider, user=user |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNone(google_user) |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 1) |  | ||||||
|  |  | ||||||
|     def test_user_create_update(self): |  | ||||||
|         """Test user updating""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", |  | ||||||
|             method="PUT", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             user = User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|             google_user = GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                 provider=self.provider, user=user |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_user) |  | ||||||
|  |  | ||||||
|             user.name = "new name" |  | ||||||
|             user.save() |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 4) |  | ||||||
|  |  | ||||||
|     def test_user_create_delete(self): |  | ||||||
|         """Test user deletion""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}", |  | ||||||
|             method="DELETE", |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             user = User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|             google_user = GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                 provider=self.provider, user=user |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_user) |  | ||||||
|  |  | ||||||
|             user.delete() |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 4) |  | ||||||
|  |  | ||||||
|     def test_user_create_delete_suspend(self): |  | ||||||
|         """Test user deletion (delete action = Suspend)""" |  | ||||||
|         self.provider.user_delete_action = OutgoingSyncDeleteAction.SUSPEND |  | ||||||
|         self.provider.save() |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", |  | ||||||
|             method="PUT", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             user = User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|             google_user = GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                 provider=self.provider, user=user |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_user) |  | ||||||
|  |  | ||||||
|             user.delete() |  | ||||||
|             self.assertEqual(len(http.requests()), 4) |  | ||||||
|             _, _, body, _ = http.requests()[3] |  | ||||||
|             self.assertEqual( |  | ||||||
|                 loads(body), |  | ||||||
|                 { |  | ||||||
|                     "suspended": True, |  | ||||||
|                 }, |  | ||||||
|             ) |  | ||||||
|             self.assertFalse( |  | ||||||
|                 GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                     provider=self.provider, user__username=uid |  | ||||||
|                 ).exists() |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def test_user_create_delete_do_nothing(self): |  | ||||||
|         """Test user deletion (delete action = do nothing)""" |  | ||||||
|         self.provider.user_delete_action = OutgoingSyncDeleteAction.DO_NOTHING |  | ||||||
|         self.provider.save() |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", |  | ||||||
|             method="POST", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             user = User.objects.create( |  | ||||||
|                 username=uid, |  | ||||||
|                 name=f"{uid} {uid}", |  | ||||||
|                 email=f"{uid}@goauthentik.io", |  | ||||||
|             ) |  | ||||||
|             google_user = GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                 provider=self.provider, user=user |  | ||||||
|             ).first() |  | ||||||
|             self.assertIsNotNone(google_user) |  | ||||||
|  |  | ||||||
|             user.delete() |  | ||||||
|             self.assertEqual(len(http.requests()), 3) |  | ||||||
|             self.assertFalse( |  | ||||||
|                 GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                     provider=self.provider, user__username=uid |  | ||||||
|                 ).exists() |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def test_sync_task(self): |  | ||||||
|         """Test user discovery""" |  | ||||||
|         uid = generate_id() |  | ||||||
|         http = MockHTTP() |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", |  | ||||||
|             domains_list_v1_mock, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json", |  | ||||||
|             method="GET", |  | ||||||
|             body={"users": [{"primaryEmail": f"{uid}@goauthentik.io"}]}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/groups?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json", |  | ||||||
|             method="GET", |  | ||||||
|             body={"groups": []}, |  | ||||||
|         ) |  | ||||||
|         http.add_response( |  | ||||||
|             f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", |  | ||||||
|             method="PUT", |  | ||||||
|             body={"primaryEmail": f"{uid}@goauthentik.io"}, |  | ||||||
|         ) |  | ||||||
|         self.app.backchannel_providers.remove(self.provider) |  | ||||||
|         different_user = User.objects.create( |  | ||||||
|             username=uid, |  | ||||||
|             email=f"{uid}@goauthentik.io", |  | ||||||
|         ) |  | ||||||
|         self.app.backchannel_providers.add(self.provider) |  | ||||||
|         with patch( |  | ||||||
|             "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", |  | ||||||
|             MagicMock(return_value={"developerKey": self.api_key, "http": http}), |  | ||||||
|         ): |  | ||||||
|             google_workspace_sync.delay(self.provider.pk).get() |  | ||||||
|             self.assertTrue( |  | ||||||
|                 GoogleWorkspaceProviderUser.objects.filter( |  | ||||||
|                     user=different_user, provider=self.provider |  | ||||||
|                 ).exists() |  | ||||||
|             ) |  | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |  | ||||||
|             self.assertEqual(len(http.requests()), 5) |  | ||||||
| @ -1,21 +0,0 @@ | |||||||
| """google provider urls""" |  | ||||||
|  |  | ||||||
| from authentik.enterprise.providers.google_workspace.api.groups import ( |  | ||||||
|     GoogleWorkspaceProviderGroupViewSet, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.google_workspace.api.property_mappings import ( |  | ||||||
|     GoogleWorkspaceProviderMappingViewSet, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.google_workspace.api.providers import ( |  | ||||||
|     GoogleWorkspaceProviderViewSet, |  | ||||||
| ) |  | ||||||
| from authentik.enterprise.providers.google_workspace.api.users import ( |  | ||||||
|     GoogleWorkspaceProviderUserViewSet, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| api_urlpatterns = [ |  | ||||||
|     ("providers/google_workspace", GoogleWorkspaceProviderViewSet), |  | ||||||
|     ("providers/google_workspace_users", GoogleWorkspaceProviderUserViewSet), |  | ||||||
|     ("providers/google_workspace_groups", GoogleWorkspaceProviderGroupViewSet), |  | ||||||
|     ("propertymappings/provider/google_workspace", GoogleWorkspaceProviderMappingViewSet), |  | ||||||
| ] |  | ||||||
| @ -1,47 +0,0 @@ | |||||||
| """MicrosoftEntraProviderGroup API Views""" |  | ||||||
|  |  | ||||||
| from rest_framework import mixins |  | ||||||
| from rest_framework.viewsets import GenericViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.users import UserGroupSerializer |  | ||||||
| from authentik.core.api.utils import ModelSerializer |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderGroup |  | ||||||
| from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderGroupSerializer(ModelSerializer): |  | ||||||
|     """MicrosoftEntraProviderGroup Serializer""" |  | ||||||
|  |  | ||||||
|     group_obj = UserGroupSerializer(source="group", read_only=True) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|  |  | ||||||
|         model = MicrosoftEntraProviderGroup |  | ||||||
|         fields = [ |  | ||||||
|             "id", |  | ||||||
|             "microsoft_id", |  | ||||||
|             "group", |  | ||||||
|             "group_obj", |  | ||||||
|             "provider", |  | ||||||
|             "attributes", |  | ||||||
|         ] |  | ||||||
|         extra_kwargs = {"attributes": {"read_only": True}} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderGroupViewSet( |  | ||||||
|     mixins.CreateModelMixin, |  | ||||||
|     OutgoingSyncConnectionCreateMixin, |  | ||||||
|     mixins.RetrieveModelMixin, |  | ||||||
|     mixins.DestroyModelMixin, |  | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |  | ||||||
|     GenericViewSet, |  | ||||||
| ): |  | ||||||
|     """MicrosoftEntraProviderGroup Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = MicrosoftEntraProviderGroup.objects.all().select_related("group") |  | ||||||
|     serializer_class = MicrosoftEntraProviderGroupSerializer |  | ||||||
|     filterset_fields = ["provider__id", "group__name", "group__group_uuid"] |  | ||||||
|     search_fields = ["provider__name", "group__name"] |  | ||||||
|     ordering = ["group__name"] |  | ||||||
| @ -1,39 +0,0 @@ | |||||||
| """microsoft Property mappings API Views""" |  | ||||||
|  |  | ||||||
| from django_filters.filters import AllValuesMultipleFilter |  | ||||||
| from django_filters.filterset import FilterSet |  | ||||||
| from drf_spectacular.types import OpenApiTypes |  | ||||||
| from drf_spectacular.utils import extend_schema_field |  | ||||||
| from rest_framework.viewsets import ModelViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.property_mappings import PropertyMappingSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderMapping |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderMappingSerializer(PropertyMappingSerializer): |  | ||||||
|     """MicrosoftEntraProviderMapping Serializer""" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = MicrosoftEntraProviderMapping |  | ||||||
|         fields = PropertyMappingSerializer.Meta.fields |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderMappingFilter(FilterSet): |  | ||||||
|     """Filter for MicrosoftEntraProviderMapping""" |  | ||||||
|  |  | ||||||
|     managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = MicrosoftEntraProviderMapping |  | ||||||
|         fields = "__all__" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderMappingViewSet(UsedByMixin, ModelViewSet): |  | ||||||
|     """MicrosoftEntraProviderMapping Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = MicrosoftEntraProviderMapping.objects.all() |  | ||||||
|     serializer_class = MicrosoftEntraProviderMappingSerializer |  | ||||||
|     filterset_class = MicrosoftEntraProviderMappingFilter |  | ||||||
|     search_fields = ["name"] |  | ||||||
|     ordering = ["name"] |  | ||||||
| @ -1,52 +0,0 @@ | |||||||
| """Microsoft Provider API Views""" |  | ||||||
|  |  | ||||||
| from rest_framework.viewsets import ModelViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.providers import ProviderSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.enterprise.api import EnterpriseRequiredMixin |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync |  | ||||||
| from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): |  | ||||||
|     """MicrosoftEntraProvider Serializer""" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = MicrosoftEntraProvider |  | ||||||
|         fields = [ |  | ||||||
|             "pk", |  | ||||||
|             "name", |  | ||||||
|             "property_mappings", |  | ||||||
|             "property_mappings_group", |  | ||||||
|             "component", |  | ||||||
|             "assigned_backchannel_application_slug", |  | ||||||
|             "assigned_backchannel_application_name", |  | ||||||
|             "verbose_name", |  | ||||||
|             "verbose_name_plural", |  | ||||||
|             "meta_model_name", |  | ||||||
|             "client_id", |  | ||||||
|             "client_secret", |  | ||||||
|             "tenant_id", |  | ||||||
|             "exclude_users_service_account", |  | ||||||
|             "filter_group", |  | ||||||
|             "user_delete_action", |  | ||||||
|             "group_delete_action", |  | ||||||
|         ] |  | ||||||
|         extra_kwargs = {} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet): |  | ||||||
|     """MicrosoftEntraProvider Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = MicrosoftEntraProvider.objects.all() |  | ||||||
|     serializer_class = MicrosoftEntraProviderSerializer |  | ||||||
|     filterset_fields = [ |  | ||||||
|         "name", |  | ||||||
|         "exclude_users_service_account", |  | ||||||
|         "filter_group", |  | ||||||
|     ] |  | ||||||
|     search_fields = ["name"] |  | ||||||
|     ordering = ["name"] |  | ||||||
|     sync_single_task = microsoft_entra_sync |  | ||||||
| @ -1,47 +0,0 @@ | |||||||
| """MicrosoftEntraProviderUser API Views""" |  | ||||||
|  |  | ||||||
| from rest_framework import mixins |  | ||||||
| from rest_framework.viewsets import GenericViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.groups import GroupMemberSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import ModelSerializer |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderUser |  | ||||||
| from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderUserSerializer(ModelSerializer): |  | ||||||
|     """MicrosoftEntraProviderUser Serializer""" |  | ||||||
|  |  | ||||||
|     user_obj = GroupMemberSerializer(source="user", read_only=True) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|  |  | ||||||
|         model = MicrosoftEntraProviderUser |  | ||||||
|         fields = [ |  | ||||||
|             "id", |  | ||||||
|             "microsoft_id", |  | ||||||
|             "user", |  | ||||||
|             "user_obj", |  | ||||||
|             "provider", |  | ||||||
|             "attributes", |  | ||||||
|         ] |  | ||||||
|         extra_kwargs = {"attributes": {"read_only": True}} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderUserViewSet( |  | ||||||
|     OutgoingSyncConnectionCreateMixin, |  | ||||||
|     mixins.CreateModelMixin, |  | ||||||
|     mixins.RetrieveModelMixin, |  | ||||||
|     mixins.DestroyModelMixin, |  | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |  | ||||||
|     GenericViewSet, |  | ||||||
| ): |  | ||||||
|     """MicrosoftEntraProviderUser Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = MicrosoftEntraProviderUser.objects.all().select_related("user") |  | ||||||
|     serializer_class = MicrosoftEntraProviderUserSerializer |  | ||||||
|     filterset_fields = ["provider__id", "user__username", "user__id"] |  | ||||||
|     search_fields = ["provider__name", "user__username"] |  | ||||||
|     ordering = ["user__username"] |  | ||||||
| @ -1,9 +0,0 @@ | |||||||
| from authentik.enterprise.apps import EnterpriseConfig |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikEnterpriseProviderMicrosoftEntraConfig(EnterpriseConfig): |  | ||||||
|  |  | ||||||
|     name = "authentik.enterprise.providers.microsoft_entra" |  | ||||||
|     label = "authentik_providers_microsoft_entra" |  | ||||||
|     verbose_name = "authentik Enterprise.Providers.Microsoft Entra" |  | ||||||
|     default = True |  | ||||||
| @ -1,110 +0,0 @@ | |||||||
| from asyncio import run |  | ||||||
| from collections.abc import Coroutine |  | ||||||
| from dataclasses import asdict |  | ||||||
| from typing import Any |  | ||||||
|  |  | ||||||
| from azure.core.exceptions import ( |  | ||||||
|     ClientAuthenticationError, |  | ||||||
|     ServiceRequestError, |  | ||||||
|     ServiceResponseError, |  | ||||||
| ) |  | ||||||
| from azure.identity.aio import ClientSecretCredential |  | ||||||
| from django.db.models import Model |  | ||||||
| from django.http import HttpResponseBadRequest, HttpResponseNotFound |  | ||||||
| from kiota_abstractions.api_error import APIError |  | ||||||
| from kiota_authentication_azure.azure_identity_authentication_provider import ( |  | ||||||
|     AzureIdentityAuthenticationProvider, |  | ||||||
| ) |  | ||||||
| from kiota_http.kiota_client_factory import KiotaClientFactory |  | ||||||
| from msgraph.generated.models.entity import Entity |  | ||||||
| from msgraph.generated.models.o_data_errors.o_data_error import ODataError |  | ||||||
| from msgraph.graph_request_adapter import GraphRequestAdapter, options |  | ||||||
| from msgraph.graph_service_client import GraphServiceClient |  | ||||||
| from msgraph_core import GraphClientFactory |  | ||||||
|  |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider |  | ||||||
| from authentik.events.utils import sanitize_item |  | ||||||
| from authentik.lib.sync.outgoing import HTTP_CONFLICT |  | ||||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient |  | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( |  | ||||||
|     BadRequestSyncException, |  | ||||||
|     NotFoundSyncException, |  | ||||||
|     ObjectExistsSyncException, |  | ||||||
|     StopSync, |  | ||||||
|     TransientSyncException, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_request_adapter( |  | ||||||
|     credentials: ClientSecretCredential, scopes: list[str] | None = None |  | ||||||
| ) -> GraphRequestAdapter: |  | ||||||
|     if scopes: |  | ||||||
|         auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials, scopes=scopes) |  | ||||||
|     else: |  | ||||||
|         auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials) |  | ||||||
|  |  | ||||||
|     return GraphRequestAdapter( |  | ||||||
|         auth_provider=auth_provider, |  | ||||||
|         client=GraphClientFactory.create_with_default_middleware( |  | ||||||
|             options=options, client=KiotaClientFactory.get_default_client() |  | ||||||
|         ), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict]( |  | ||||||
|     BaseOutgoingSyncClient[TModel, TConnection, TSchema, MicrosoftEntraProvider] |  | ||||||
| ): |  | ||||||
|     """Base client for syncing to microsoft entra""" |  | ||||||
|  |  | ||||||
|     domains: list |  | ||||||
|  |  | ||||||
|     def __init__(self, provider: MicrosoftEntraProvider) -> None: |  | ||||||
|         super().__init__(provider) |  | ||||||
|         self.credentials = provider.microsoft_credentials() |  | ||||||
|         self.__prefetch_domains() |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def client(self): |  | ||||||
|         return GraphServiceClient(request_adapter=get_request_adapter(**self.credentials)) |  | ||||||
|  |  | ||||||
|     def _request[T](self, request: Coroutine[Any, Any, T]) -> T: |  | ||||||
|         try: |  | ||||||
|             return run(request) |  | ||||||
|         except ClientAuthenticationError as exc: |  | ||||||
|             raise StopSync(exc, None, None) from exc |  | ||||||
|         except ODataError as exc: |  | ||||||
|             raise StopSync(exc, None, None) from exc |  | ||||||
|         except (ServiceRequestError, ServiceResponseError) as exc: |  | ||||||
|             raise TransientSyncException("Failed to sent request") from exc |  | ||||||
|         except APIError as exc: |  | ||||||
|             if exc.response_status_code == HttpResponseNotFound.status_code: |  | ||||||
|                 raise NotFoundSyncException("Object not found") from exc |  | ||||||
|             if exc.response_status_code == HttpResponseBadRequest.status_code: |  | ||||||
|                 raise BadRequestSyncException("Bad request", exc.response_headers) from exc |  | ||||||
|             if exc.response_status_code == HTTP_CONFLICT: |  | ||||||
|                 raise ObjectExistsSyncException("Object exists", exc.response_headers) from exc |  | ||||||
|             raise exc |  | ||||||
|  |  | ||||||
|     def __prefetch_domains(self): |  | ||||||
|         self.domains = [] |  | ||||||
|         organizations = self._request(self.client.organization.get()) |  | ||||||
|         next_link = True |  | ||||||
|         while next_link: |  | ||||||
|             for org in organizations.value: |  | ||||||
|                 self.domains.extend([x.name for x in org.verified_domains]) |  | ||||||
|             next_link = organizations.odata_next_link |  | ||||||
|             if not next_link: |  | ||||||
|                 break |  | ||||||
|             organizations = self._request(self.client.organization.with_url(next_link).get()) |  | ||||||
|  |  | ||||||
|     def check_email_valid(self, *emails: str): |  | ||||||
|         for email in emails: |  | ||||||
|             if not any(email.endswith(f"@{domain_name}") for domain_name in self.domains): |  | ||||||
|                 raise BadRequestSyncException(f"Invalid email domain: {email}") |  | ||||||
|  |  | ||||||
|     def entity_as_dict(self, entity: Entity) -> dict: |  | ||||||
|         """Create a dictionary of a model instance, making sure to remove (known) things |  | ||||||
|         we can't JSON serialize""" |  | ||||||
|         raw_data = asdict(entity) |  | ||||||
|         raw_data.pop("backing_store", None) |  | ||||||
|         return sanitize_item(raw_data) |  | ||||||
| @ -1,232 +0,0 @@ | |||||||
| from deepmerge import always_merger |  | ||||||
| from django.db import transaction |  | ||||||
| from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder |  | ||||||
| from msgraph.generated.models.group import Group as MSGroup |  | ||||||
| from msgraph.generated.models.reference_create import ReferenceCreate |  | ||||||
|  |  | ||||||
| from authentik.core.models import Group |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import ( |  | ||||||
|     MicrosoftEntraProvider, |  | ||||||
|     MicrosoftEntraProviderGroup, |  | ||||||
|     MicrosoftEntraProviderMapping, |  | ||||||
|     MicrosoftEntraProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.mapper import PropertyMappingManager |  | ||||||
| from authentik.lib.sync.outgoing.base import Direction |  | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( |  | ||||||
|     NotFoundSyncException, |  | ||||||
|     ObjectExistsSyncException, |  | ||||||
|     StopSync, |  | ||||||
|     TransientSyncException, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraGroupClient( |  | ||||||
|     MicrosoftEntraSyncClient[Group, MicrosoftEntraProviderGroup, MSGroup] |  | ||||||
| ): |  | ||||||
|     """Microsoft client for groups""" |  | ||||||
|  |  | ||||||
|     connection_type = MicrosoftEntraProviderGroup |  | ||||||
|     connection_type_query = "group" |  | ||||||
|     can_discover = True |  | ||||||
|  |  | ||||||
|     def __init__(self, provider: MicrosoftEntraProvider) -> None: |  | ||||||
|         super().__init__(provider) |  | ||||||
|         self.mapper = PropertyMappingManager( |  | ||||||
|             self.provider.property_mappings_group.all().order_by("name").select_subclasses(), |  | ||||||
|             MicrosoftEntraProviderMapping, |  | ||||||
|             ["group", "provider", "connection"], |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def to_schema(self, obj: Group, connection: MicrosoftEntraProviderGroup) -> MSGroup: |  | ||||||
|         """Convert authentik group""" |  | ||||||
|         raw_microsoft_group = super().to_schema(obj, connection) |  | ||||||
|         try: |  | ||||||
|             return MSGroup(**raw_microsoft_group) |  | ||||||
|         except TypeError as exc: |  | ||||||
|             raise StopSync(exc, obj) from exc |  | ||||||
|  |  | ||||||
|     def delete(self, obj: Group): |  | ||||||
|         """Delete group""" |  | ||||||
|         microsoft_group = MicrosoftEntraProviderGroup.objects.filter( |  | ||||||
|             provider=self.provider, group=obj |  | ||||||
|         ).first() |  | ||||||
|         if not microsoft_group: |  | ||||||
|             self.logger.debug("Group does not exist in Microsoft, skipping") |  | ||||||
|             return None |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             if self.provider.group_delete_action == OutgoingSyncDeleteAction.DELETE: |  | ||||||
|                 self._request(self.client.groups.by_group_id(microsoft_group.microsoft_id).delete()) |  | ||||||
|             microsoft_group.delete() |  | ||||||
|  |  | ||||||
|     def create(self, group: Group): |  | ||||||
|         """Create group from scratch and create a connection object""" |  | ||||||
|         microsoft_group = self.to_schema(group, None) |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             try: |  | ||||||
|                 response = self._request(self.client.groups.post(microsoft_group)) |  | ||||||
|             except ObjectExistsSyncException: |  | ||||||
|                 # group already exists in microsoft entra, so we can connect them manually |  | ||||||
|                 # for groups we need to fetch the group from microsoft as we connect on |  | ||||||
|                 # ID and not group email |  | ||||||
|                 query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters( |  | ||||||
|                     filter=f"displayName eq '{microsoft_group.display_name}'", |  | ||||||
|                 ) |  | ||||||
|                 request_configuration = ( |  | ||||||
|                     GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration( |  | ||||||
|                         query_parameters=query_params, |  | ||||||
|                     ) |  | ||||||
|                 ) |  | ||||||
|                 group_data = self._request(self.client.groups.get(request_configuration)) |  | ||||||
|                 if group_data.odata_count < 1 or len(group_data.value) < 1: |  | ||||||
|                     self.logger.warning( |  | ||||||
|                         "Group which could not be created also does not exist", group=group |  | ||||||
|                     ) |  | ||||||
|                     return |  | ||||||
|                 ms_group = group_data.value[0] |  | ||||||
|                 return MicrosoftEntraProviderGroup.objects.create( |  | ||||||
|                     provider=self.provider, |  | ||||||
|                     group=group, |  | ||||||
|                     microsoft_id=ms_group.id, |  | ||||||
|                     attributes=self.entity_as_dict(ms_group), |  | ||||||
|                 ) |  | ||||||
|             else: |  | ||||||
|                 return MicrosoftEntraProviderGroup.objects.create( |  | ||||||
|                     provider=self.provider, |  | ||||||
|                     group=group, |  | ||||||
|                     microsoft_id=response.id, |  | ||||||
|                     attributes=self.entity_as_dict(response), |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|     def update(self, group: Group, connection: MicrosoftEntraProviderGroup): |  | ||||||
|         """Update existing group""" |  | ||||||
|         microsoft_group = self.to_schema(group, connection) |  | ||||||
|         microsoft_group.id = connection.microsoft_id |  | ||||||
|         try: |  | ||||||
|             response = self._request( |  | ||||||
|                 self.client.groups.by_group_id(connection.microsoft_id).patch(microsoft_group) |  | ||||||
|             ) |  | ||||||
|             if response: |  | ||||||
|                 always_merger.merge(connection.attributes, self.entity_as_dict(response)) |  | ||||||
|                 connection.save() |  | ||||||
|         except NotFoundSyncException: |  | ||||||
|             # Resource missing is handled by self.write, which will re-create the group |  | ||||||
|             raise |  | ||||||
|  |  | ||||||
|     def write(self, obj: Group): |  | ||||||
|         microsoft_group, created = super().write(obj) |  | ||||||
|         self.create_sync_members(obj, microsoft_group) |  | ||||||
|         return microsoft_group, created |  | ||||||
|  |  | ||||||
|     def create_sync_members(self, obj: Group, microsoft_group: MicrosoftEntraProviderGroup): |  | ||||||
|         """Sync all members after a group was created""" |  | ||||||
|         users = list(obj.users.order_by("id").values_list("id", flat=True)) |  | ||||||
|         connections = MicrosoftEntraProviderUser.objects.filter( |  | ||||||
|             provider=self.provider, user__pk__in=users |  | ||||||
|         ).values_list("microsoft_id", flat=True) |  | ||||||
|         self._patch(microsoft_group.microsoft_id, Direction.add, connections) |  | ||||||
|  |  | ||||||
|     def update_group(self, group: Group, action: Direction, users_set: set[int]): |  | ||||||
|         """Update a groups members""" |  | ||||||
|         if action == Direction.add: |  | ||||||
|             return self._patch_add_users(group, users_set) |  | ||||||
|         if action == Direction.remove: |  | ||||||
|             return self._patch_remove_users(group, users_set) |  | ||||||
|  |  | ||||||
|     def _patch(self, microsoft_group_id: str, direction: Direction, members: list[str]): |  | ||||||
|         for user in members: |  | ||||||
|             try: |  | ||||||
|                 if direction == Direction.add: |  | ||||||
|                     request_body = ReferenceCreate( |  | ||||||
|                         odata_id=f"https://graph.microsoft.com/v1.0/directoryObjects/{user}", |  | ||||||
|                     ) |  | ||||||
|                     self._request( |  | ||||||
|                         self.client.groups.by_group_id(microsoft_group_id).members.ref.post( |  | ||||||
|                             request_body |  | ||||||
|                         ) |  | ||||||
|                     ) |  | ||||||
|                 if direction == Direction.remove: |  | ||||||
|                     self._request( |  | ||||||
|                         self.client.groups.by_group_id(microsoft_group_id) |  | ||||||
|                         .members.by_directory_object_id(user) |  | ||||||
|                         .ref.delete() |  | ||||||
|                     ) |  | ||||||
|             except ObjectExistsSyncException: |  | ||||||
|                 pass |  | ||||||
|             except TransientSyncException: |  | ||||||
|                 raise |  | ||||||
|  |  | ||||||
|     def _patch_add_users(self, group: Group, users_set: set[int]): |  | ||||||
|         """Add users in users_set to group""" |  | ||||||
|         if len(users_set) < 1: |  | ||||||
|             return |  | ||||||
|         microsoft_group = MicrosoftEntraProviderGroup.objects.filter( |  | ||||||
|             provider=self.provider, group=group |  | ||||||
|         ).first() |  | ||||||
|         if not microsoft_group: |  | ||||||
|             self.logger.warning( |  | ||||||
|                 "could not sync group membership, group does not exist", group=group |  | ||||||
|             ) |  | ||||||
|             return |  | ||||||
|         user_ids = list( |  | ||||||
|             MicrosoftEntraProviderUser.objects.filter( |  | ||||||
|                 user__pk__in=users_set, provider=self.provider |  | ||||||
|             ).values_list("microsoft_id", flat=True) |  | ||||||
|         ) |  | ||||||
|         if len(user_ids) < 1: |  | ||||||
|             return |  | ||||||
|         self._patch(microsoft_group.microsoft_id, Direction.add, user_ids) |  | ||||||
|  |  | ||||||
|     def _patch_remove_users(self, group: Group, users_set: set[int]): |  | ||||||
|         """Remove users in users_set from group""" |  | ||||||
|         if len(users_set) < 1: |  | ||||||
|             return |  | ||||||
|         microsoft_group = MicrosoftEntraProviderGroup.objects.filter( |  | ||||||
|             provider=self.provider, group=group |  | ||||||
|         ).first() |  | ||||||
|         if not microsoft_group: |  | ||||||
|             self.logger.warning( |  | ||||||
|                 "could not sync group membership, group does not exist", group=group |  | ||||||
|             ) |  | ||||||
|             return |  | ||||||
|         user_ids = list( |  | ||||||
|             MicrosoftEntraProviderUser.objects.filter( |  | ||||||
|                 user__pk__in=users_set, provider=self.provider |  | ||||||
|             ).values_list("microsoft_id", flat=True) |  | ||||||
|         ) |  | ||||||
|         if len(user_ids) < 1: |  | ||||||
|             return |  | ||||||
|         self._patch(microsoft_group.microsoft_id, Direction.remove, user_ids) |  | ||||||
|  |  | ||||||
|     def discover(self): |  | ||||||
|         """Iterate through all groups and connect them with authentik groups if possible""" |  | ||||||
|         groups = self._request(self.client.groups.get()) |  | ||||||
|         next_link = True |  | ||||||
|         while next_link: |  | ||||||
|             for group in groups.value: |  | ||||||
|                 self._discover_single_group(group) |  | ||||||
|             next_link = groups.odata_next_link |  | ||||||
|             if not next_link: |  | ||||||
|                 break |  | ||||||
|             groups = self._request(self.client.groups.with_url(next_link).get()) |  | ||||||
|  |  | ||||||
|     def _discover_single_group(self, group: MSGroup): |  | ||||||
|         """handle discovery of a single group""" |  | ||||||
|         microsoft_name = group.unique_name |  | ||||||
|         matching_authentik_group = ( |  | ||||||
|             self.provider.get_object_qs(Group).filter(name=microsoft_name).first() |  | ||||||
|         ) |  | ||||||
|         if not matching_authentik_group: |  | ||||||
|             return |  | ||||||
|         MicrosoftEntraProviderGroup.objects.get_or_create( |  | ||||||
|             provider=self.provider, |  | ||||||
|             group=matching_authentik_group, |  | ||||||
|             microsoft_id=group.id, |  | ||||||
|             attributes=self.entity_as_dict(group), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def update_single_attribute(self, connection: MicrosoftEntraProviderGroup): |  | ||||||
|         data = self._request(self.client.groups.by_group_id(connection.microsoft_id).get()) |  | ||||||
|         connection.attributes = self.entity_as_dict(data) |  | ||||||
| @ -1,178 +0,0 @@ | |||||||
| from deepmerge import always_merger |  | ||||||
| from django.db import transaction |  | ||||||
| from msgraph.generated.models.user import User as MSUser |  | ||||||
| from msgraph.generated.users.users_request_builder import UsersRequestBuilder |  | ||||||
|  |  | ||||||
| from authentik.core.models import User |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient |  | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import ( |  | ||||||
|     MicrosoftEntraProvider, |  | ||||||
|     MicrosoftEntraProviderMapping, |  | ||||||
|     MicrosoftEntraProviderUser, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.mapper import PropertyMappingManager |  | ||||||
| from authentik.lib.sync.outgoing.exceptions import ( |  | ||||||
|     ObjectExistsSyncException, |  | ||||||
|     StopSync, |  | ||||||
|     TransientSyncException, |  | ||||||
| ) |  | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction |  | ||||||
| from authentik.policies.utils import delete_none_values |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProviderUser, MSUser]): |  | ||||||
|     """Sync authentik users into microsoft entra""" |  | ||||||
|  |  | ||||||
|     connection_type = MicrosoftEntraProviderUser |  | ||||||
|     connection_type_query = "user" |  | ||||||
|     can_discover = True |  | ||||||
|  |  | ||||||
|     def __init__(self, provider: MicrosoftEntraProvider) -> None: |  | ||||||
|         super().__init__(provider) |  | ||||||
|         self.mapper = PropertyMappingManager( |  | ||||||
|             self.provider.property_mappings.all().order_by("name").select_subclasses(), |  | ||||||
|             MicrosoftEntraProviderMapping, |  | ||||||
|             ["provider", "connection"], |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def to_schema(self, obj: User, connection: MicrosoftEntraProviderUser) -> MSUser: |  | ||||||
|         """Convert authentik user""" |  | ||||||
|         raw_microsoft_user = super().to_schema(obj, connection) |  | ||||||
|         try: |  | ||||||
|             return MSUser(**delete_none_values(raw_microsoft_user)) |  | ||||||
|         except TypeError as exc: |  | ||||||
|             raise StopSync(exc, obj) from exc |  | ||||||
|  |  | ||||||
|     def delete(self, obj: User): |  | ||||||
|         """Delete user""" |  | ||||||
|         microsoft_user = MicrosoftEntraProviderUser.objects.filter( |  | ||||||
|             provider=self.provider, user=obj |  | ||||||
|         ).first() |  | ||||||
|         if not microsoft_user: |  | ||||||
|             self.logger.debug("User does not exist in Microsoft, skipping") |  | ||||||
|             return None |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             response = None |  | ||||||
|             if self.provider.user_delete_action == OutgoingSyncDeleteAction.DELETE: |  | ||||||
|                 response = self._request( |  | ||||||
|                     self.client.users.by_user_id(microsoft_user.microsoft_id).delete() |  | ||||||
|                 ) |  | ||||||
|             elif self.provider.user_delete_action == OutgoingSyncDeleteAction.SUSPEND: |  | ||||||
|                 response = self._request( |  | ||||||
|                     self.client.users.by_user_id(microsoft_user.microsoft_id).patch( |  | ||||||
|                         MSUser(account_enabled=False) |  | ||||||
|                     ) |  | ||||||
|                 ) |  | ||||||
|             microsoft_user.delete() |  | ||||||
|         return response |  | ||||||
|  |  | ||||||
|     def get_select_fields(self) -> list[str]: |  | ||||||
|         """All fields that should be selected when we fetch user data.""" |  | ||||||
|         # TODO: Make this customizable in the future |  | ||||||
|         return [ |  | ||||||
|             # Default fields |  | ||||||
|             "businessPhones", |  | ||||||
|             "displayName", |  | ||||||
|             "givenName", |  | ||||||
|             "jobTitle", |  | ||||||
|             "mail", |  | ||||||
|             "mobilePhone", |  | ||||||
|             "officeLocation", |  | ||||||
|             "preferredLanguage", |  | ||||||
|             "surname", |  | ||||||
|             "userPrincipalName", |  | ||||||
|             "id", |  | ||||||
|             # Required for logging into M365 using authentik |  | ||||||
|             "onPremisesImmutableId", |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|     def create(self, user: User): |  | ||||||
|         """Create user from scratch and create a connection object""" |  | ||||||
|         microsoft_user = self.to_schema(user, None) |  | ||||||
|         self.check_email_valid(microsoft_user.user_principal_name) |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             try: |  | ||||||
|                 response = self._request(self.client.users.post(microsoft_user)) |  | ||||||
|             except ObjectExistsSyncException: |  | ||||||
|                 # user already exists in microsoft entra, so we can connect them manually |  | ||||||
|                 request_configuration = ( |  | ||||||
|                     UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( |  | ||||||
|                         query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( |  | ||||||
|                             filter=f"mail eq '{microsoft_user.mail}'", |  | ||||||
|                             select=self.get_select_fields(), |  | ||||||
|                         ), |  | ||||||
|                     ) |  | ||||||
|                 ) |  | ||||||
|                 user_data = self._request(self.client.users.get(request_configuration)) |  | ||||||
|                 if user_data.odata_count < 1 or len(user_data.value) < 1: |  | ||||||
|                     self.logger.warning( |  | ||||||
|                         "User which could not be created also does not exist", user=user |  | ||||||
|                     ) |  | ||||||
|                     return |  | ||||||
|                 ms_user = user_data.value[0] |  | ||||||
|                 return MicrosoftEntraProviderUser.objects.create( |  | ||||||
|                     provider=self.provider, |  | ||||||
|                     user=user, |  | ||||||
|                     microsoft_id=ms_user.id, |  | ||||||
|                     attributes=self.entity_as_dict(ms_user), |  | ||||||
|                 ) |  | ||||||
|             except TransientSyncException as exc: |  | ||||||
|                 raise exc |  | ||||||
|             else: |  | ||||||
|                 return MicrosoftEntraProviderUser.objects.create( |  | ||||||
|                     provider=self.provider, |  | ||||||
|                     user=user, |  | ||||||
|                     microsoft_id=response.id, |  | ||||||
|                     attributes=self.entity_as_dict(response), |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|     def update(self, user: User, connection: MicrosoftEntraProviderUser): |  | ||||||
|         """Update existing user""" |  | ||||||
|         microsoft_user = self.to_schema(user, connection) |  | ||||||
|         self.check_email_valid(microsoft_user.user_principal_name) |  | ||||||
|         response = self._request( |  | ||||||
|             self.client.users.by_user_id(connection.microsoft_id).patch(microsoft_user) |  | ||||||
|         ) |  | ||||||
|         if response: |  | ||||||
|             always_merger.merge(connection.attributes, self.entity_as_dict(response)) |  | ||||||
|             connection.save() |  | ||||||
|  |  | ||||||
|     def discover(self): |  | ||||||
|         """Iterate through all users and connect them with authentik users if possible""" |  | ||||||
|         request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( |  | ||||||
|             query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( |  | ||||||
|                 select=self.get_select_fields(), |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
|         users = self._request(self.client.users.get(request_configuration)) |  | ||||||
|         next_link = True |  | ||||||
|         while next_link: |  | ||||||
|             for user in users.value: |  | ||||||
|                 self._discover_single_user(user) |  | ||||||
|             next_link = users.odata_next_link |  | ||||||
|             if not next_link: |  | ||||||
|                 break |  | ||||||
|             users = self._request(self.client.users.with_url(next_link).get()) |  | ||||||
|  |  | ||||||
|     def _discover_single_user(self, user: MSUser): |  | ||||||
|         """handle discovery of a single user""" |  | ||||||
|         matching_authentik_user = self.provider.get_object_qs(User).filter(email=user.mail).first() |  | ||||||
|         if not matching_authentik_user: |  | ||||||
|             return |  | ||||||
|         MicrosoftEntraProviderUser.objects.get_or_create( |  | ||||||
|             provider=self.provider, |  | ||||||
|             user=matching_authentik_user, |  | ||||||
|             microsoft_id=user.id, |  | ||||||
|             attributes=self.entity_as_dict(user), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def update_single_attribute(self, connection: MicrosoftEntraProviderUser): |  | ||||||
|         request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( |  | ||||||
|             query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( |  | ||||||
|                 select=self.get_select_fields(), |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
|         data = self._request( |  | ||||||
|             self.client.users.by_user_id(connection.microsoft_id).get(request_configuration) |  | ||||||
|         ) |  | ||||||
|         connection.attributes = self.entity_as_dict(data) |  | ||||||
| @ -1,165 +0,0 @@ | |||||||
| # Generated by Django 5.0.6 on 2024-05-08 14:35 |  | ||||||
|  |  | ||||||
| import django.db.models.deletion |  | ||||||
| import uuid |  | ||||||
| from django.conf import settings |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     initial = True |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0035_alter_group_options_and_more"), |  | ||||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="MicrosoftEntraProviderMapping", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "propertymapping_ptr", |  | ||||||
|                     models.OneToOneField( |  | ||||||
|                         auto_created=True, |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         parent_link=True, |  | ||||||
|                         primary_key=True, |  | ||||||
|                         serialize=False, |  | ||||||
|                         to="authentik_core.propertymapping", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Microsoft Entra Provider Mapping", |  | ||||||
|                 "verbose_name_plural": "Microsoft Entra Provider Mappings", |  | ||||||
|             }, |  | ||||||
|             bases=("authentik_core.propertymapping",), |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="MicrosoftEntraProvider", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "provider_ptr", |  | ||||||
|                     models.OneToOneField( |  | ||||||
|                         auto_created=True, |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         parent_link=True, |  | ||||||
|                         primary_key=True, |  | ||||||
|                         serialize=False, |  | ||||||
|                         to="authentik_core.provider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("client_id", models.TextField()), |  | ||||||
|                 ("client_secret", models.TextField()), |  | ||||||
|                 ("tenant_id", models.TextField()), |  | ||||||
|                 ("exclude_users_service_account", models.BooleanField(default=False)), |  | ||||||
|                 ( |  | ||||||
|                     "user_delete_action", |  | ||||||
|                     models.TextField( |  | ||||||
|                         choices=[ |  | ||||||
|                             ("do_nothing", "Do Nothing"), |  | ||||||
|                             ("delete", "Delete"), |  | ||||||
|                             ("suspend", "Suspend"), |  | ||||||
|                         ], |  | ||||||
|                         default="delete", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "group_delete_action", |  | ||||||
|                     models.TextField( |  | ||||||
|                         choices=[ |  | ||||||
|                             ("do_nothing", "Do Nothing"), |  | ||||||
|                             ("delete", "Delete"), |  | ||||||
|                             ("suspend", "Suspend"), |  | ||||||
|                         ], |  | ||||||
|                         default="delete", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "filter_group", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         default=None, |  | ||||||
|                         null=True, |  | ||||||
|                         on_delete=django.db.models.deletion.SET_DEFAULT, |  | ||||||
|                         to="authentik_core.group", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "property_mappings_group", |  | ||||||
|                     models.ManyToManyField( |  | ||||||
|                         blank=True, |  | ||||||
|                         default=None, |  | ||||||
|                         help_text="Property mappings used for group creation/updating.", |  | ||||||
|                         to="authentik_core.propertymapping", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Microsoft Entra Provider", |  | ||||||
|                 "verbose_name_plural": "Microsoft Entra Providers", |  | ||||||
|             }, |  | ||||||
|             bases=("authentik_core.provider", models.Model), |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="MicrosoftEntraProviderGroup", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "id", |  | ||||||
|                     models.UUIDField( |  | ||||||
|                         default=uuid.uuid4, editable=False, primary_key=True, serialize=False |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("microsoft_id", models.TextField()), |  | ||||||
|                 ( |  | ||||||
|                     "group", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group" |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "provider", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         to="authentik_providers_microsoft_entra.microsoftentraprovider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Microsoft Entra Provider Group", |  | ||||||
|                 "verbose_name_plural": "Microsoft Entra Provider Groups", |  | ||||||
|                 "unique_together": {("microsoft_id", "group", "provider")}, |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="MicrosoftEntraProviderUser", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "id", |  | ||||||
|                     models.UUIDField( |  | ||||||
|                         default=uuid.uuid4, editable=False, primary_key=True, serialize=False |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("microsoft_id", models.TextField()), |  | ||||||
|                 ( |  | ||||||
|                     "provider", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         to="authentik_providers_microsoft_entra.microsoftentraprovider", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ( |  | ||||||
|                     "user", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Microsoft Entra Provider User", |  | ||||||
|                 "verbose_name_plural": "Microsoft Entra Provider User", |  | ||||||
|                 "unique_together": {("microsoft_id", "user", "provider")}, |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,23 +0,0 @@ | |||||||
| # Generated by Django 5.0.6 on 2024-05-23 20:48 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_providers_microsoft_entra", "0001_initial"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="microsoftentraprovidergroup", |  | ||||||
|             name="attributes", |  | ||||||
|             field=models.JSONField(default=dict), |  | ||||||
|         ), |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="microsoftentraprovideruser", |  | ||||||
|             name="attributes", |  | ||||||
|             field=models.JSONField(default=dict), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,188 +0,0 @@ | |||||||
| """Microsoft Entra sync provider""" |  | ||||||
|  |  | ||||||
| from typing import Any, Self |  | ||||||
| from uuid import uuid4 |  | ||||||
|  |  | ||||||
| from azure.identity.aio import ClientSecretCredential |  | ||||||
| from django.db import models |  | ||||||
| from django.db.models import QuerySet |  | ||||||
| from django.templatetags.static import static |  | ||||||
| from django.utils.translation import gettext_lazy as _ |  | ||||||
| from rest_framework.serializers import Serializer |  | ||||||
|  |  | ||||||
| from authentik.core.models import ( |  | ||||||
|     BackchannelProvider, |  | ||||||
|     Group, |  | ||||||
|     PropertyMapping, |  | ||||||
|     User, |  | ||||||
|     UserTypes, |  | ||||||
| ) |  | ||||||
| from authentik.lib.models import SerializerModel |  | ||||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient |  | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderUser(SerializerModel): |  | ||||||
|     """Mapping of a user and provider to a Microsoft user ID""" |  | ||||||
|  |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     microsoft_id = models.TextField() |  | ||||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) |  | ||||||
|     provider = models.ForeignKey("MicrosoftEntraProvider", on_delete=models.CASCADE) |  | ||||||
|     attributes = models.JSONField(default=dict) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.microsoft_entra.api.users import ( |  | ||||||
|             MicrosoftEntraProviderUserSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return MicrosoftEntraProviderUserSerializer |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Microsoft Entra Provider User") |  | ||||||
|         verbose_name_plural = _("Microsoft Entra Provider User") |  | ||||||
|         unique_together = (("microsoft_id", "user", "provider"),) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderGroup(SerializerModel): |  | ||||||
|     """Mapping of a group and provider to a Microsoft group ID""" |  | ||||||
|  |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     microsoft_id = models.TextField() |  | ||||||
|     group = models.ForeignKey(Group, on_delete=models.CASCADE) |  | ||||||
|     provider = models.ForeignKey("MicrosoftEntraProvider", on_delete=models.CASCADE) |  | ||||||
|     attributes = models.JSONField(default=dict) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.microsoft_entra.api.groups import ( |  | ||||||
|             MicrosoftEntraProviderGroupSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return MicrosoftEntraProviderGroupSerializer |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Microsoft Entra Provider Group") |  | ||||||
|         verbose_name_plural = _("Microsoft Entra Provider Groups") |  | ||||||
|         unique_together = (("microsoft_id", "group", "provider"),) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Microsoft Entra Provider Group {self.group_id} to {self.provider_id}" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): |  | ||||||
|     """Sync users from authentik into Microsoft Entra.""" |  | ||||||
|  |  | ||||||
|     client_id = models.TextField() |  | ||||||
|     client_secret = models.TextField() |  | ||||||
|     tenant_id = models.TextField() |  | ||||||
|  |  | ||||||
|     exclude_users_service_account = models.BooleanField(default=False) |  | ||||||
|     user_delete_action = models.TextField( |  | ||||||
|         choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE |  | ||||||
|     ) |  | ||||||
|     group_delete_action = models.TextField( |  | ||||||
|         choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE |  | ||||||
|     ) |  | ||||||
|     filter_group = models.ForeignKey( |  | ||||||
|         "authentik_core.group", on_delete=models.SET_DEFAULT, default=None, null=True |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     property_mappings_group = models.ManyToManyField( |  | ||||||
|         PropertyMapping, |  | ||||||
|         default=None, |  | ||||||
|         blank=True, |  | ||||||
|         help_text=_("Property mappings used for group creation/updating."), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     def client_for_model( |  | ||||||
|         self, |  | ||||||
|         model: type[User | Group | MicrosoftEntraProviderUser | MicrosoftEntraProviderGroup], |  | ||||||
|     ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: |  | ||||||
|         if issubclass(model, User | MicrosoftEntraProviderUser): |  | ||||||
|             from authentik.enterprise.providers.microsoft_entra.clients.users import ( |  | ||||||
|                 MicrosoftEntraUserClient, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             return MicrosoftEntraUserClient(self) |  | ||||||
|         if issubclass(model, Group | MicrosoftEntraProviderGroup): |  | ||||||
|             from authentik.enterprise.providers.microsoft_entra.clients.groups import ( |  | ||||||
|                 MicrosoftEntraGroupClient, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             return MicrosoftEntraGroupClient(self) |  | ||||||
|         raise ValueError(f"Invalid model {model}") |  | ||||||
|  |  | ||||||
|     def get_object_qs(self, type: type[User | Group]) -> QuerySet[User | Group]: |  | ||||||
|         if type == User: |  | ||||||
|             # Get queryset of all users with consistent ordering |  | ||||||
|             # according to the provider's settings |  | ||||||
|             base = User.objects.all().exclude_anonymous() |  | ||||||
|             if self.exclude_users_service_account: |  | ||||||
|                 base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( |  | ||||||
|                     type=UserTypes.INTERNAL_SERVICE_ACCOUNT |  | ||||||
|                 ) |  | ||||||
|             if self.filter_group: |  | ||||||
|                 base = base.filter(ak_groups__in=[self.filter_group]) |  | ||||||
|             return base.order_by("pk") |  | ||||||
|         if type == Group: |  | ||||||
|             # Get queryset of all groups with consistent ordering |  | ||||||
|             return Group.objects.all().order_by("pk") |  | ||||||
|         raise ValueError(f"Invalid type {type}") |  | ||||||
|  |  | ||||||
|     def microsoft_credentials(self): |  | ||||||
|         return { |  | ||||||
|             "credentials": ClientSecretCredential( |  | ||||||
|                 self.tenant_id, self.client_id, self.client_secret |  | ||||||
|             ) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def icon_url(self) -> str | None: |  | ||||||
|         return static("authentik/sources/azuread.svg") |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def component(self) -> str: |  | ||||||
|         return "ak-provider-microsoft-entra-form" |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.microsoft_entra.api.providers import ( |  | ||||||
|             MicrosoftEntraProviderSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return MicrosoftEntraProviderSerializer |  | ||||||
|  |  | ||||||
|     def __str__(self): |  | ||||||
|         return f"Microsoft Entra Provider {self.name}" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Microsoft Entra Provider") |  | ||||||
|         verbose_name_plural = _("Microsoft Entra Providers") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderMapping(PropertyMapping): |  | ||||||
|     """Map authentik data to outgoing Microsoft requests""" |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def component(self) -> str: |  | ||||||
|         return "ak-property-mapping-microsoft-entra-form" |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.microsoft_entra.api.property_mappings import ( |  | ||||||
|             MicrosoftEntraProviderMappingSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return MicrosoftEntraProviderMappingSerializer |  | ||||||
|  |  | ||||||
|     def __str__(self): |  | ||||||
|         return f"Microsoft Entra Provider Mapping {self.name}" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Microsoft Entra Provider Mapping") |  | ||||||
|         verbose_name_plural = _("Microsoft Entra Provider Mappings") |  | ||||||
| @ -1,13 +0,0 @@ | |||||||
| """Microsoft Entra provider task Settings""" |  | ||||||
|  |  | ||||||
| from celery.schedules import crontab |  | ||||||
|  |  | ||||||
| from authentik.lib.utils.time import fqdn_rand |  | ||||||
|  |  | ||||||
| CELERY_BEAT_SCHEDULE = { |  | ||||||
|     "providers_microsoft_entra_sync": { |  | ||||||
|         "task": "authentik.enterprise.providers.microsoft_entra.tasks.microsoft_entra_sync_all", |  | ||||||
|         "schedule": crontab(minute=fqdn_rand("microsoft_entra_sync_all"), hour="*/4"), |  | ||||||
|         "options": {"queue": "authentik_scheduled"}, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	