Compare commits
	
		
			205 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 619203c177 | |||
| 4ae476e58d | |||
| e444d0d640 | |||
| 3869965b4c | |||
| d4e1b95991 | |||
| 2b730dec54 | |||
| 2aacb311bc | |||
| 40055ef01b | |||
| 75608dce5c | |||
| b0f7083879 | |||
| 62bf79ce32 | |||
| 7a16c9cb14 | |||
| d29d161ac6 | |||
| 653631ac77 | |||
| cde303e780 | |||
| aa359a032c | |||
| 6491065aab | |||
| 79eec5a3a0 | |||
| cd5e091937 | |||
| 7ed8952803 | |||
| c1f302fb7c | |||
| cb31e52d0e | |||
| 782764ac73 | |||
| d0c56325ef | |||
| 73d57d6f82 | |||
| 2716a26887 | |||
| 0452537e8b | |||
| d1a1bfbbc5 | |||
| a69fcbca9a | |||
| 1ac4dacc3b | |||
| bcf7e162a4 | |||
| f44956bd61 | |||
| cb37e5c10e | |||
| 73bb778d62 | |||
| b612a82e16 | |||
| 83991c743e | |||
| 09f43ca43b | |||
| 1c91835a26 | |||
| c032914092 | |||
| 3634bf4629 | |||
| 45f99fbaf0 | |||
| e31a3307b5 | |||
| d28fcca344 | |||
| c296e1214c | |||
| d676cf6e3f | |||
| 39d87841d0 | |||
| fcd879034c | |||
| b285814e24 | |||
| 1a6ea72c09 | |||
| c251b87f8c | |||
| 21a9aa229a | |||
| 5f6565ee27 | |||
| afad55a357 | |||
| f25d76fa43 | |||
| 10b45d954e | |||
| 339eaf37f2 | |||
| f723fdd551 | |||
| 8359f0bfb3 | |||
| ee610a906a | |||
| 828eeb5ebb | |||
| c9c177d8f9 | |||
| c19afa4f16 | |||
| 941bc61b31 | |||
| 282b364606 | |||
| ad4bc4083d | |||
| ebe282eb1a | |||
| 830c26ca25 | |||
| ed3b4a3d4a | |||
| 975c4ddc04 | |||
| 7e2896298a | |||
| cba9cf8361 | |||
| bf12580f64 | |||
| 75ef4ce596 | |||
| c2f3ce11b0 | |||
| 3c256fecc6 | |||
| 0285b84133 | |||
| 99a371a02c | |||
| c7e6eb8896 | |||
| 674bd9e05c | |||
| b79901df87 | |||
| b248f450dd | |||
| 05db9e5c40 | |||
| 234a5e2b66 | |||
| aea1736f70 | |||
| 9f4a4449f5 | |||
| b6b55e2336 | |||
| 8f2805e05b | |||
| 4f3583cd7e | |||
| 617e90dca3 | |||
| f7408626a8 | |||
| 4dcb15af46 | |||
| 89beb7a9f7 | |||
| 28eeb4798e | |||
| 79b92e764e | |||
| 919336a519 | |||
| 27e04589c1 | |||
| ba44fbdac8 | |||
| 0e093a8917 | |||
| d0bfb99859 | |||
| 93bdea3769 | |||
| e681654af7 | |||
| cab7593dca | |||
| cf92f9aefc | |||
| 8d72b3498d | |||
| 42ab858c50 | |||
| a1abae9ab1 | |||
| 8f36b49061 | |||
| 64b4e851ce | |||
| 40a62ac1e5 | |||
| 5df60e4d87 | |||
| 50ebc8522d | |||
| eddca478dc | |||
| 99a7fca08e | |||
| a7e3602908 | |||
| 74169860cf | |||
| 52bb774f73 | |||
| f26fcaf825 | |||
| b8e92e2f11 | |||
| 08adfc94d6 | |||
| 236fafb735 | |||
| 5ad9ddee3c | |||
| 24d220ff49 | |||
| 3364c195b7 | |||
| 50aa87d141 | |||
| 72b375023d | |||
| 77ba186818 | |||
| 2fe6de0505 | |||
| bf9e969b53 | |||
| 184f119b16 | |||
| ebc06f1abe | |||
| 0f8880ab0a | |||
| ee56da5092 | |||
| 2152004502 | |||
| 45d0b80d02 | |||
| 96065eb942 | |||
| ac944fee8b | |||
| 1d0e5fc353 | |||
| 1f97420207 | |||
| ae07f13a87 | |||
| 0aec504170 | |||
| 3b4c9bcc57 | |||
| 5182a6741e | |||
| da7635ae5c | |||
| a92a0fb60a | |||
| cb10c1753b | |||
| ae654bd4c8 | |||
| 28192655ec | |||
| 9582294eb8 | |||
| 0172430d7d | |||
| 1454b65933 | |||
| 432a7792e2 | |||
| 54069618b4 | |||
| 81feb313df | |||
| e6b275add3 | |||
| 27016a5527 | |||
| 4c29d517f0 | |||
| 180d27cc37 | |||
| 5a8b356dc7 | |||
| 3195640776 | |||
| f463296d47 | |||
| adf4b23c01 | |||
| d900a2b6a9 | |||
| 95a2fddfa8 | |||
| 8f7d21b692 | |||
| 3f84abec2f | |||
| b5c857aff4 | |||
| f8dee09107 | |||
| 84a800583c | |||
| 88de94f014 | |||
| 25549ec339 | |||
| fe4923bff6 | |||
| bb1a0b6bd2 | |||
| 879b5ead71 | |||
| 1670ec9167 | |||
| ac52667327 | |||
| 0d7c5c2108 | |||
| 73e3d19384 | |||
| f6e0f0282d | |||
| 3f42067a8f | |||
| ed6f5b98df | |||
| dd290e264c | |||
| c85484fc00 | |||
| 663dffd8be | |||
| c15d0c3d17 | |||
| bf09a54f35 | |||
| 930dd51663 | |||
| 12a523c7aa | |||
| ea9a6d57dd | |||
| 91958e1232 | |||
| 8925afb089 | |||
| ccafe7be4f | |||
| 8279690a8f | |||
| 763d3ae76a | |||
| b775e7f4d3 | |||
| 3d8d93ece5 | |||
| 06af306e8a | |||
| 9257f3c919 | |||
| 2fe7f4cf04 | |||
| 04399bc8bb | |||
| fcbcfbc3c0 | |||
| 3e4ce62dfe | |||
| d8292151e6 | |||
| 3d01a59b34 | |||
| 5df15c4105 | |||
| 75d695105d | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2021.9.1-rc2 | current_version = 2021.9.8 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/codespell-words.txt
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.github/codespell-words.txt
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | keypair | ||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -61,7 +61,7 @@ jobs: | |||||||
|           npm install |           npm install | ||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-web |         run: make gen-web | ||||||
|       - name: prettier |       - name: lit-analyse | ||||||
|         run: | |         run: | | ||||||
|           cd web |           cd web | ||||||
|           npm run lit-analyse |           npm run lit-analyse | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -33,14 +33,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik:2021.9.1-rc2, |             beryju/authentik:2021.9.8, | ||||||
|             beryju/authentik:latest, |             beryju/authentik:latest, | ||||||
|             ghcr.io/goauthentik/server:2021.9.1-rc2, |             ghcr.io/goauthentik/server:2021.9.8, | ||||||
|             ghcr.io/goauthentik/server:latest |             ghcr.io/goauthentik/server:latest | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|       - name: Building Docker Image (stable) |       - name: Building Docker Image (stable) | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.9.1-rc2', 'rc') }} |         if: ${{ github.event_name == 'release' && !contains('2021.9.8', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           docker pull beryju/authentik:latest |           docker pull beryju/authentik:latest | ||||||
|           docker tag beryju/authentik:latest beryju/authentik:stable |           docker tag beryju/authentik:latest beryju/authentik:stable | ||||||
| @ -75,14 +75,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-proxy:2021.9.1-rc2, |             beryju/authentik-proxy:2021.9.8, | ||||||
|             beryju/authentik-proxy:latest, |             beryju/authentik-proxy:latest, | ||||||
|             ghcr.io/goauthentik/proxy:2021.9.1-rc2, |             ghcr.io/goauthentik/proxy:2021.9.8, | ||||||
|             ghcr.io/goauthentik/proxy:latest |             ghcr.io/goauthentik/proxy:latest | ||||||
|           file: proxy.Dockerfile |           file: proxy.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|       - name: Building Docker Image (stable) |       - name: Building Docker Image (stable) | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.9.1-rc2', 'rc') }} |         if: ${{ github.event_name == 'release' && !contains('2021.9.8', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           docker pull beryju/authentik-proxy:latest |           docker pull beryju/authentik-proxy:latest | ||||||
|           docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable |           docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable | ||||||
| @ -117,14 +117,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-ldap:2021.9.1-rc2, |             beryju/authentik-ldap:2021.9.8, | ||||||
|             beryju/authentik-ldap:latest, |             beryju/authentik-ldap:latest, | ||||||
|             ghcr.io/goauthentik/ldap:2021.9.1-rc2, |             ghcr.io/goauthentik/ldap:2021.9.8, | ||||||
|             ghcr.io/goauthentik/ldap:latest |             ghcr.io/goauthentik/ldap:latest | ||||||
|           file: ldap.Dockerfile |           file: ldap.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|       - name: Building Docker Image (stable) |       - name: Building Docker Image (stable) | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.9.1-rc2', 'rc') }} |         if: ${{ github.event_name == 'release' && !contains('2021.9.8', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           docker pull beryju/authentik-ldap:latest |           docker pull beryju/authentik-ldap:latest | ||||||
|           docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable |           docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable | ||||||
| @ -175,7 +175,7 @@ jobs: | |||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           version: authentik@2021.9.1-rc2 |           version: authentik@2021.9.8 | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|           sourcemaps: './web/dist' |           sourcemaps: './web/dist' | ||||||
|           url_prefix: '~/static/dist' |           url_prefix: '~/static/dist' | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -27,7 +27,7 @@ jobs: | |||||||
|           docker-compose run -u root server test |           docker-compose run -u root server test | ||||||
|       - name: Extract version number |       - name: Extract version number | ||||||
|         id: get_version |         id: get_version | ||||||
|         uses: actions/github-script@v4.1 |         uses: actions/github-script@v5 | ||||||
|         with: |         with: | ||||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} |           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|           script: | |           script: | | ||||||
|  | |||||||
| @ -117,7 +117,7 @@ This section guides you through submitting a bug report for authentik. Following | |||||||
|  |  | ||||||
| Whenever authentik encounters an error, it will be logged as an Event with the type `system_exception`. This event type has a button to directly open a pre-filled GitHub issue form. | Whenever authentik encounters an error, it will be logged as an Event with the type `system_exception`. This event type has a button to directly open a pre-filled GitHub issue form. | ||||||
|  |  | ||||||
| This form will have the full stack trace of the error that ocurred and shouldn't contain any sensitive data. | This form will have the full stack trace of the error that occurred and shouldn't contain any sensitive data. | ||||||
|  |  | ||||||
| ### Suggesting Enhancements | ### Suggesting Enhancements | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,5 +1,5 @@ | |||||||
| # Stage 1: Lock python dependencies | # Stage 1: Lock python dependencies | ||||||
| FROM python:3.9-slim-buster as locker | FROM docker.io/python:3.9-slim-buster as locker | ||||||
|  |  | ||||||
| COPY ./Pipfile /app/ | COPY ./Pipfile /app/ | ||||||
| COPY ./Pipfile.lock /app/ | COPY ./Pipfile.lock /app/ | ||||||
| @ -11,7 +11,7 @@ RUN pip install pipenv && \ | |||||||
|     pipenv lock -r --dev-only > requirements-dev.txt |     pipenv lock -r --dev-only > requirements-dev.txt | ||||||
|  |  | ||||||
| # Stage 2: Build website | # Stage 2: Build website | ||||||
| FROM node as website-builder | FROM docker.io/node as website-builder | ||||||
|  |  | ||||||
| COPY ./website /static/ | COPY ./website /static/ | ||||||
|  |  | ||||||
| @ -19,7 +19,7 @@ ENV NODE_ENV=production | |||||||
| RUN cd /static && npm i && npm run build-docs-only | RUN cd /static && npm i && npm run build-docs-only | ||||||
|  |  | ||||||
| # Stage 3: Build webui | # Stage 3: Build webui | ||||||
| FROM node as web-builder | FROM docker.io/node as web-builder | ||||||
|  |  | ||||||
| COPY ./web /static/ | COPY ./web /static/ | ||||||
|  |  | ||||||
| @ -27,7 +27,7 @@ ENV NODE_ENV=production | |||||||
| RUN cd /static && npm i && npm run build | RUN cd /static && npm i && npm run build | ||||||
|  |  | ||||||
| # Stage 4: Build go proxy | # Stage 4: Build go proxy | ||||||
| FROM golang:1.17.1 AS builder | FROM docker.io/golang:1.17.1 AS builder | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
|  |  | ||||||
| @ -47,7 +47,7 @@ COPY ./go.sum /work/go.sum | |||||||
| RUN go build -o /work/authentik ./cmd/server/main.go | RUN go build -o /work/authentik ./cmd/server/main.go | ||||||
|  |  | ||||||
| # Stage 5: Run | # Stage 5: Run | ||||||
| FROM python:3.9-slim-buster | FROM docker.io/python:3.9-slim-buster | ||||||
|  |  | ||||||
| WORKDIR / | WORKDIR / | ||||||
| COPY --from=locker /app/requirements.txt / | COPY --from=locker /app/requirements.txt / | ||||||
| @ -80,8 +80,12 @@ COPY ./lifecycle/ /lifecycle | |||||||
| COPY --from=builder /work/authentik /authentik-proxy | COPY --from=builder /work/authentik /authentik-proxy | ||||||
|  |  | ||||||
| USER authentik | USER authentik | ||||||
|  |  | ||||||
| ENV TMPDIR /dev/shm/ | ENV TMPDIR /dev/shm/ | ||||||
| ENV PYTHONUNBUFFERED 1 | ENV PYTHONUNBUFFERED 1 | ||||||
| ENV prometheus_multiproc_dir /dev/shm/ | ENV prometheus_multiproc_dir /dev/shm/ | ||||||
| ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle" | ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle" | ||||||
|  |  | ||||||
|  | HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ] | ||||||
|  |  | ||||||
| ENTRYPOINT [ "/lifecycle/ak" ] | ENTRYPOINT [ "/lifecycle/ak" ] | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Makefile
									
									
									
									
									
								
							| @ -20,6 +20,7 @@ test: | |||||||
| lint-fix: | lint-fix: | ||||||
| 	isort authentik tests lifecycle | 	isort authentik tests lifecycle | ||||||
| 	black authentik tests lifecycle | 	black authentik tests lifecycle | ||||||
|  | 	codespell -I .github/codespell-words.txt -S 'web/src/locales/**' -w authentik internal cmd web/src website/src | ||||||
|  |  | ||||||
| lint: | lint: | ||||||
| 	pyright authentik tests lifecycle | 	pyright authentik tests lifecycle | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @ -48,6 +48,7 @@ duo-client = "*" | |||||||
| ua-parser = "*" | ua-parser = "*" | ||||||
| deepmerge = "*" | deepmerge = "*" | ||||||
| colorama = "*" | colorama = "*" | ||||||
|  | codespell = "*" | ||||||
|  |  | ||||||
| [dev-packages] | [dev-packages] | ||||||
| bandit = "*" | bandit = "*" | ||||||
|  | |||||||
							
								
								
									
										323
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										323
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "19d5324fd1a4af125ed57a683030ca14ee2d3648117748e4b32656875484728e" |             "sha256": "babb6061c555f8f239f00210b2a0356763bdaaca2f3d704cf3444891b84db84d" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": {}, |         "requires": {}, | ||||||
| @ -120,27 +120,27 @@ | |||||||
|         }, |         }, | ||||||
|         "boto3": { |         "boto3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:63b9846c26e0905f4e9e39d6b59f152330c53a926d693439161c43dcf9779365", |                 "sha256:7b45b224442c479de4bc6e6e9cb0557b642fc7a77edc8702e393ccaa2e0aa128", | ||||||
|                 "sha256:a9232185d8e7e2fd2b166c0ebee5d7b1f787fdb3093f33bbf5aa932c08f0ccac" |                 "sha256:c388da7dc1a596755f39de990a72e05cee558d098e81de63de55bd9598cc5134" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.18.42" |             "version": "==1.18.48" | ||||||
|         }, |         }, | ||||||
|         "botocore": { |         "botocore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0952d1200968365b440045efe8e45bbae38cf603fee12bcfc3d7b5f963cbfa18", |                 "sha256:17a10dd33334e7e3aaa4e12f66317284f96bb53267e20bc877a187c442681772", | ||||||
|                 "sha256:6de4fec4ee10987e4dea96f289553c2f45109fcaafcb74a5baee1221926e1306" |                 "sha256:2089f9fa36a59d8c02435c49d58ccc7b3ceb9c0c054ea4f71631c3c3a1c5245e" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==1.21.42" |             "version": "==1.21.51" | ||||||
|         }, |         }, | ||||||
|         "cachetools": { |         "cachetools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001", |                 "sha256:0a3d3556c2c3befdbba2f93b78792c199c66201c999e97947ea0b7437758246b", | ||||||
|                 "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff" |                 "sha256:6a6fa6802188ab7e77bab2db001d676e854499552b0037d999d5b9f211db5250" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version ~= '3.5'", |             "markers": "python_version ~= '3.5'", | ||||||
|             "version": "==4.2.2" |             "version": "==4.2.3" | ||||||
|         }, |         }, | ||||||
|         "cbor2": { |         "cbor2": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -252,11 +252,11 @@ | |||||||
|         }, |         }, | ||||||
|         "charset-normalizer": { |         "charset-normalizer": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:7098e7e862f6370a2a8d1a6398cd359815c45d12626267652c3f13dec58e2367", |                 "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6", | ||||||
|                 "sha256:fa471a601dfea0f492e4f4fca035cd82155e65dc45c9b83bf4322dfab63755dd" |                 "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3'", |             "markers": "python_version >= '3'", | ||||||
|             "version": "==2.0.5" |             "version": "==2.0.6" | ||||||
|         }, |         }, | ||||||
|         "click": { |         "click": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -268,9 +268,11 @@ | |||||||
|         }, |         }, | ||||||
|         "click-didyoumean": { |         "click-didyoumean": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:112229485c9704ff51362fe34b2d4f0b12fc71cc20f6d2b3afabed4b8bfa6aeb" |                 "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667", | ||||||
|  |                 "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.0.3" |             "markers": "python_version < '4' and python_full_version >= '3.6.2'", | ||||||
|  |             "version": "==0.3.0" | ||||||
|         }, |         }, | ||||||
|         "click-plugins": { |         "click-plugins": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -286,6 +288,14 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.2.0" |             "version": "==0.2.0" | ||||||
|         }, |         }, | ||||||
|  |         "codespell": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:19d3fe5644fef3425777e66f225a8c82d39059dcfe9edb3349a8a2cf48383ee5", | ||||||
|  |                 "sha256:b864c7d917316316ac24272ee992d7937c3519be4569209c5b60035ac5d569b5" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==2.1.0" | ||||||
|  |         }, | ||||||
|         "colorama": { |         "colorama": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", |                 "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", | ||||||
| @ -303,25 +313,28 @@ | |||||||
|         }, |         }, | ||||||
|         "cryptography": { |         "cryptography": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0a7dcbcd3f1913f664aca35d47c1331fce738d44ec34b7be8b9d332151b0b01e", |                 "sha256:07bb7fbfb5de0980590ddfc7f13081520def06dc9ed214000ad4372fb4e3c7f6", | ||||||
|                 "sha256:1eb7bb0df6f6f583dd8e054689def236255161ebbcf62b226454ab9ec663746b", |                 "sha256:18d90f4711bf63e2fb21e8c8e51ed8189438e6b35a6d996201ebd98a26abbbe6", | ||||||
|                 "sha256:21ca464b3a4b8d8e86ba0ee5045e103a1fcfac3b39319727bc0fc58c09c6aff7", |                 "sha256:1ed82abf16df40a60942a8c211251ae72858b25b7421ce2497c2eb7a1cee817c", | ||||||
|                 "sha256:34dae04a0dce5730d8eb7894eab617d8a70d0c97da76b905de9efb7128ad7085", |                 "sha256:22a38e96118a4ce3b97509443feace1d1011d0571fae81fc3ad35f25ba3ea999", | ||||||
|                 "sha256:3520667fda779eb788ea00080124875be18f2d8f0848ec00733c0ec3bb8219fc", |                 "sha256:2d69645f535f4b2c722cfb07a8eab916265545b3475fdb34e0be2f4ee8b0b15e", | ||||||
|                 "sha256:3fa3a7ccf96e826affdf1a0a9432be74dc73423125c8f96a909e3835a5ef194a", |                 "sha256:4a2d0e0acc20ede0f06ef7aa58546eee96d2592c00f450c9acb89c5879b61992", | ||||||
|                 "sha256:5b0fbfae7ff7febdb74b574055c7466da334a5371f253732d7e2e7525d570498", |                 "sha256:54b2605e5475944e2213258e0ab8696f4f357a31371e538ef21e8d61c843c28d", | ||||||
|                 "sha256:8695456444f277af73a4877db9fc979849cd3ee74c198d04fc0776ebc3db52b9", |                 "sha256:7075b304cd567694dc692ffc9747f3e9cb393cc4aa4fb7b9f3abd6f5c4e43588", | ||||||
|                 "sha256:94cc5ed4ceaefcbe5bf38c8fba6a21fc1d365bb8fb826ea1688e3370b2e24a1c", |                 "sha256:7b7ceeff114c31f285528ba8b390d3e9cfa2da17b56f11d366769a807f17cbaa", | ||||||
|                 "sha256:94fff993ee9bc1b2440d3b7243d488c6a3d9724cc2b09cdb297f6a886d040ef7", |                 "sha256:7eba2cebca600a7806b893cb1d541a6e910afa87e97acf2021a22b32da1df52d", | ||||||
|                 "sha256:9965c46c674ba8cc572bc09a03f4c649292ee73e1b683adb1ce81e82e9a6a0fb", |                 "sha256:928185a6d1ccdb816e883f56ebe92e975a262d31cc536429041921f8cb5a62fd", | ||||||
|                 "sha256:a00cf305f07b26c351d8d4e1af84ad7501eca8a342dedf24a7acb0e7b7406e14", |                 "sha256:9933f28f70d0517686bd7de36166dda42094eac49415459d9bdf5e7df3e0086d", | ||||||
|                 "sha256:a305600e7a6b7b855cd798e00278161b681ad6e9b7eca94c721d5f588ab212af", |                 "sha256:a688ebcd08250eab5bb5bca318cc05a8c66de5e4171a65ca51db6bd753ff8953", | ||||||
|                 "sha256:cd65b60cfe004790c795cc35f272e41a3df4631e2fb6b35aa7ac6ef2859d554e", |                 "sha256:abb5a361d2585bb95012a19ed9b2c8f412c5d723a9836418fab7aaa0243e67d2", | ||||||
|                 "sha256:d2a6e5ef66503da51d2110edf6c403dc6b494cc0082f85db12f54e9c5d4c3ec5", |                 "sha256:c10c797ac89c746e488d2ee92bd4abd593615694ee17b2500578b63cad6b93a8", | ||||||
|                 "sha256:d9ec0e67a14f9d1d48dd87a2531009a9b251c02ea42851c060b25c782516ff06", |                 "sha256:ced40344e811d6abba00295ced98c01aecf0c2de39481792d87af4fa58b7b4d6", | ||||||
|                 "sha256:f44d141b8c4ea5eb4dbc9b3ad992d45580c1d22bf5e24363f2fbf50c2d7ae8a7" |                 "sha256:d57e0cdc1b44b6cdf8af1d01807db06886f10177469312fbde8f44ccbb284bc9", | ||||||
|  |                 "sha256:d99915d6ab265c22873f1b4d6ea5ef462ef797b4140be4c9d8b179915e0985c6", | ||||||
|  |                 "sha256:eb80e8a1f91e4b7ef8b33041591e6d89b2b8e122d787e87eeb2b08da71bb16ad", | ||||||
|  |                 "sha256:ebeddd119f526bcf323a89f853afb12e225902a24d29b55fe18dd6fcb2838a76" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.4.8" |             "version": "==35.0.0" | ||||||
|         }, |         }, | ||||||
|         "dacite": { |         "dacite": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -369,11 +382,11 @@ | |||||||
|         }, |         }, | ||||||
|         "django-filter": { |         "django-filter": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06", |                 "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e", | ||||||
|                 "sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1" |                 "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==2.4.0" |             "version": "==21.1" | ||||||
|         }, |         }, | ||||||
|         "django-guardian": { |         "django-guardian": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -449,11 +462,11 @@ | |||||||
|         }, |         }, | ||||||
|         "drf-spectacular": { |         "drf-spectacular": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:47ef6ec8ff48ac8aede6ec12450a55fee381cf84de969ef1724dcde5a93de6b8", |                 "sha256:65df818226477cdfa629947ea52bc0cc13eb40550b192eeccec64a6b782651fd", | ||||||
|                 "sha256:d746b936cb4cddec380ea95bf91de6a6721777dfc42e0eea53b83c61a625e94e" |                 "sha256:f71205da3645d770545abeaf48e8a15afd6ee9a76e57c03df4592e51be1059bf" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==0.18.2" |             "version": "==0.19.0" | ||||||
|         }, |         }, | ||||||
|         "duo-client": { |         "duo-client": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -480,19 +493,19 @@ | |||||||
|         }, |         }, | ||||||
|         "geoip2": { |         "geoip2": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:906a1dbf15a179a1af3522970e8420ab15bb3e0afc526942cc179e12146d9c1d", |                 "sha256:f150bed3190d543712a17467208388d31bd8ddb49b2226fba53db8aaedb8ba89", | ||||||
|                 "sha256:b97b44031fdc463e84eb1316b4f19edd978cb1d78703465fcb1e36dc5a822ba6" |                 "sha256:f9172cdfb2a5f9225ace5e30dd7426413ad28798a5f474cd1538780686bd6a87" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==4.2.0" |             "version": "==4.4.0" | ||||||
|         }, |         }, | ||||||
|         "google-auth": { |         "google-auth": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:7ae5eda089d393ca01658b550df24913cbbbdd34e9e6dedc1cea747485ae0c04", |                 "sha256:2a92b485afed5292946b324e91fcbe03db277ee4cb64c998c6cfa66d4af01dee", | ||||||
|                 "sha256:bde03220ed56e4e147dec92339c90ce95159dce657e2cccd0ac1fe82f6a96284" |                 "sha256:6dc8173abd50f25b6e62fc5b42802c96fc7cd9deb9bfeeb10a79f5606225cdf4" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==2.1.0" |             "version": "==2.2.1" | ||||||
|         }, |         }, | ||||||
|         "gunicorn": { |         "gunicorn": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -616,10 +629,10 @@ | |||||||
|         }, |         }, | ||||||
|         "jsonschema": { |         "jsonschema": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", |                 "sha256:bc51325b929171791c42ebc1c70b9713eb134d3bb8ebd5474c8b659b15be6d86", | ||||||
|                 "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a" |                 "sha256:c773028c649441ab980015b5b622f4cd5134cf563daaf0235ca4b73cc3734f20" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.2.0" |             "version": "==4.0.0" | ||||||
|         }, |         }, | ||||||
|         "kombu": { |         "kombu": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -704,10 +717,10 @@ | |||||||
|         }, |         }, | ||||||
|         "maxminddb": { |         "maxminddb": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" |                 "sha256:e37707ec4fab115804670e0fb7aedb4b57075a8b6f80052bdc648d3c005184e5" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==2.0.3" |             "version": "==2.2.0" | ||||||
|         }, |         }, | ||||||
|         "msgpack": { |         "msgpack": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -898,39 +911,39 @@ | |||||||
|         }, |         }, | ||||||
|         "pycryptodome": { |         "pycryptodome": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:09c1555a3fa450e7eaca41ea11cd00afe7c91fef52353488e65663777d8524e0", |                 "sha256:04e14c732c3693d2830839feed5129286ce47ffa8bfe90e4ae042c773e51c677", | ||||||
|                 "sha256:12222a5edc9ca4a29de15fbd5339099c4c26c56e13c2ceddf0b920794f26165d", |                 "sha256:11d3164fb49fdee000fde05baecce103c0c698168ef1a18d9c7429dd66f0f5bb", | ||||||
|                 "sha256:1723ebee5561628ce96748501cdaa7afaa67329d753933296321f0be55358dce", |                 "sha256:217dcc0c92503f7dd4b3d3b7d974331a4419f97f555c99a845c3b366fed7056b", | ||||||
|                 "sha256:1c5e1ca507de2ad93474be5cfe2bfa76b7cf039a1a32fc196f40935944871a06", |                 "sha256:24c1b7705d19d8ae3e7255431efd2e526006855df62620118dd7b5374c6372f6", | ||||||
|                 "sha256:2603c98ae04aac675fefcf71a6c87dc4bb74a75e9071ae3923bbc91a59f08d35", |                 "sha256:309529d2526f3fb47102aeef376b3459110a6af7efb162e860b32e3a17a46f06", | ||||||
|                 "sha256:2dea65df54349cdfa43d6b2e8edb83f5f8d6861e5cf7b1fbc3e34c5694c85e27", |                 "sha256:3a153658d97258ca20bf18f7fe31c09cc7c558b6f8974a6ec74e19f6c634bd64", | ||||||
|                 "sha256:31c1df17b3dc5f39600a4057d7db53ac372f492c955b9b75dd439f5d8b460129", |                 "sha256:3f9fb499e267039262569d08658132c9cd8b136bf1d8c56b72f70ed05551e526", | ||||||
|                 "sha256:38661348ecb71476037f1e1f553159b80d256c00f6c0b00502acac891f7116d9", |                 "sha256:3faa6ebd35c61718f3f8862569c1f38450c24f3ededb213e1a64806f02f584bc", | ||||||
|                 "sha256:3e2e3a06580c5f190df843cdb90ea28d61099cf4924334d5297a995de68e4673", |                 "sha256:40083b0d7f277452c7f2dd4841801f058cc12a74c219ee4110d65774c6a58bef", | ||||||
|                 "sha256:3f840c49d38986f6e17dbc0673d37947c88bc9d2d9dba1c01b979b36f8447db1", |                 "sha256:49e54f2245befb0193848c8c8031d8d1358ed4af5a1ae8d0a3ba669a5cdd3a72", | ||||||
|                 "sha256:501ab36aae360e31d0ec370cf5ce8ace6cb4112060d099b993bc02b36ac83fb6", |                 "sha256:4e8fc4c48365ce8a542fe48bf1360da05bb2851df12f64fc94d751705e7cdbe7", | ||||||
|                 "sha256:60386d1d4cfaad299803b45a5bc2089696eaf6cdd56f9fc17479a6f89595cfc8", |                 "sha256:54d4e4d45f349d8c4e2f31c2734637ff62a844af391b833f789da88e43a8f338", | ||||||
|                 "sha256:6260e24d41149268122dd39d4ebd5941e9d107f49463f7e071fd397e29923b0c", |                 "sha256:66301e4c42dee43ee2da256625d3fe81ef98cc9924c2bd535008cc3ad8ded77b", | ||||||
|                 "sha256:6bbf7fee7b7948b29d7e71fcacf48bac0c57fb41332007061a933f2d996f9713", |                 "sha256:6b45fcace5a5d9c57ba87cf804b161adc62aa826295ce7f7acbcbdc0df74ed37", | ||||||
|                 "sha256:6d2df5223b12437e644ce0a3be7809471ffa71de44ccd28b02180401982594a6", |                 "sha256:7efec2418e9746ec48e264eea431f8e422d931f71c57b1c96ee202b117f58fa9", | ||||||
|                 "sha256:758949ca62690b1540dfb24ad773c6da9cd0e425189e83e39c038bbd52b8e438", |                 "sha256:851e6d4930b160417235955322db44adbdb19589918670d63f4acd5d92959ac0", | ||||||
|                 "sha256:77997519d8eb8a4adcd9a47b9cec18f9b323e296986528186c0e9a7a15d6a07e", |                 "sha256:8e82524e7c354033508891405574d12e612cc4fdd3b55d2c238fc1a3e300b606", | ||||||
|                 "sha256:7fd519b89585abf57bf47d90166903ec7b43af4fe23c92273ea09e6336af5c07", |                 "sha256:8ec154ec445412df31acf0096e7f715e30e167c8f2318b8f5b1ab7c28f4c82f7", | ||||||
|                 "sha256:98213ac2b18dc1969a47bc65a79a8fca02a414249d0c8635abb081c7f38c91b6", |                 "sha256:91ba4215a1f37d0f371fe43bc88c5ff49c274849f3868321c889313787de7672", | ||||||
|                 "sha256:99b2f3fc51d308286071d0953f92055504a6ffe829a832a9fc7a04318a7683dd", |                 "sha256:97e7df67a4da2e3f60612bbfd6c3f243a63a15d8f4797dd275e1d7b44a65cb12", | ||||||
|                 "sha256:9b6f711b25e01931f1c61ce0115245a23cdc8b80bf8539ac0363bdcf27d649b6", |                 "sha256:9a2312440057bf29b9582f72f14d79692044e63bfbc4b4bbea8559355f44f3dd", | ||||||
|                 "sha256:a3105a0eb63eacf98c2ecb0eb4aa03f77f40fbac2bdde22020bb8a536b226bb8", |                 "sha256:a7471646d8cd1a58bb696d667dcb3853e5c9b341b68dcf3c3cc0893d0f98ca5f", | ||||||
|                 "sha256:a8eb8b6ea09ec1c2535bf39914377bc8abcab2c7d30fa9225eb4fe412024e427", |                 "sha256:ac3012c36633564b2b5539bb7c6d9175f31d2ce74844e9abe654c428f02d0fd8", | ||||||
|                 "sha256:a92d5c414e8ee1249e850789052608f582416e82422502dc0ac8c577808a9067", |                 "sha256:b1daf251395af7336ddde6a0015ba5e632c18fe646ba930ef87402537358e3b4", | ||||||
|                 "sha256:d3d6958d53ad307df5e8469cc44474a75393a434addf20ecd451f38a72fe29b8", |                 "sha256:b217b4525e60e1af552d62bec01b4685095436d4de5ecde0f05d75b2f95ba6d4", | ||||||
|                 "sha256:e0a4d5933a88a2c98bbe19c0c722f5483dc628d7a38338ac2cb64a7dbd34064b", |                 "sha256:c61ea053bd5d4c12a063d7e704fbe1c45abb5d2510dab55bd95d166ba661604f", | ||||||
|                 "sha256:e3bf558c6aeb49afa9f0c06cee7fb5947ee5a1ff3bd794b653d39926b49077fa", |                 "sha256:c6469d1453f5864e3321a172b0aa671b938d753cbf2376b99fa2ab8841539bb8", | ||||||
|                 "sha256:e61e363d9a5d7916f3a4ce984a929514c0df3daf3b1b2eb5e6edbb131ee771cf", |                 "sha256:cefe6b267b8e5c3c72e11adec35a9c7285b62e8ea141b63e87055e9a9e5f2f8c", | ||||||
|                 "sha256:f977cdf725b20f6b8229b0c87acb98c7717e742ef9f46b113985303ae12a99da", |                 "sha256:d713dc0910e5ded07852a05e9b75f1dd9d3a31895eebee0668f612779b2a748c", | ||||||
|                 "sha256:fc7489a50323a0df02378bc2fff86eb69d94cc5639914346c736be981c6a02e7" |                 "sha256:db15fa07d2a4c00beeb5e9acdfdbc1c79f9ccfbdc1a8f36c82c4aa44951b33c9" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==3.10.1" |             "version": "==3.10.4" | ||||||
|         }, |         }, | ||||||
|         "pyjwt": { |         "pyjwt": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -942,10 +955,10 @@ | |||||||
|         }, |         }, | ||||||
|         "pyopenssl": { |         "pyopenssl": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51", |                 "sha256:5e2d8c5e46d0d865ae933bef5230090bdaf5506281e9eec60fa250ee80600cb3", | ||||||
|                 "sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b" |                 "sha256:8935bd4920ab9abfebb07c41a4f58296407ed77f04bd1a92914044b848ba1ed6" | ||||||
|             ], |             ], | ||||||
|             "version": "==20.0.1" |             "version": "==21.0.0" | ||||||
|         }, |         }, | ||||||
|         "pyparsing": { |         "pyparsing": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1082,11 +1095,11 @@ | |||||||
|         }, |         }, | ||||||
|         "sentry-sdk": { |         "sentry-sdk": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c", |                 "sha256:b9844751e40710e84a457c5bc29b21c383ccb2b63d76eeaad72f7f1c808c8828", | ||||||
|                 "sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52" |                 "sha256:c091cc7115ff25fe3a0e410dbecd7a996f81a3f6137d2272daef32d6c3cfa6dc" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.3.1" |             "version": "==1.4.3" | ||||||
|         }, |         }, | ||||||
|         "service-identity": { |         "service-identity": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1176,11 +1189,11 @@ | |||||||
|                 "secure" |                 "secure" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", |                 "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", | ||||||
|                 "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" |                 "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.26.6" |             "version": "==1.26.7" | ||||||
|         }, |         }, | ||||||
|         "uvicorn": { |         "uvicorn": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
| @ -1410,11 +1423,11 @@ | |||||||
|         }, |         }, | ||||||
|         "astroid": { |         "astroid": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:3b680ce0419b8a771aba6190139a3998d14b413852506d99aff8dc2bf65ee67c", |                 "sha256:dcc06f6165f415220013801642bd6c9808a02967070919c4b746c6864c205471", | ||||||
|                 "sha256:dc1e8b28427d6bbef6b8842b18765ab58f558c42bb80540bd7648c98412af25e" |                 "sha256:fe81f80c0b35264acb5653302ffbd935d394f1775c5e4487df745bf9c2442708" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version ~= '3.6'", |             "markers": "python_version ~= '3.6'", | ||||||
|             "version": "==2.7.3" |             "version": "==2.8.0" | ||||||
|         }, |         }, | ||||||
|         "attrs": { |         "attrs": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1457,11 +1470,11 @@ | |||||||
|         }, |         }, | ||||||
|         "charset-normalizer": { |         "charset-normalizer": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:7098e7e862f6370a2a8d1a6398cd359815c45d12626267652c3f13dec58e2367", |                 "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6", | ||||||
|                 "sha256:fa471a601dfea0f492e4f4fca035cd82155e65dc45c9b83bf4322dfab63755dd" |                 "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3'", |             "markers": "python_version >= '3'", | ||||||
|             "version": "==2.0.5" |             "version": "==2.0.6" | ||||||
|         }, |         }, | ||||||
|         "click": { |         "click": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1547,11 +1560,11 @@ | |||||||
|         }, |         }, | ||||||
|         "gitpython": { |         "gitpython": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b838a895977b45ab6f0cc926a9045c8d1c44e2b653c1fcc39fe91f42c6e8f05b", |                 "sha256:dc0a7f2f697657acc8d7f89033e8b1ea94dd90356b2983bca89dc8d2ab3cc647", | ||||||
|                 "sha256:fce760879cd2aebd2991b3542876dc5c4a909b30c9d69dfc488e504a8db37ee8" |                 "sha256:df83fdf5e684fef7c6ee2c02fc68a5ceb7e7e759d08b694088d0cacb4eba59e5" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.7'", | ||||||
|             "version": "==3.1.18" |             "version": "==3.1.24" | ||||||
|         }, |         }, | ||||||
|         "idna": { |         "idna": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1642,11 +1655,11 @@ | |||||||
|         }, |         }, | ||||||
|         "platformdirs": { |         "platformdirs": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f", |                 "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2", | ||||||
|                 "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648" |                 "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==2.3.0" |             "version": "==2.4.0" | ||||||
|         }, |         }, | ||||||
|         "pluggy": { |         "pluggy": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1666,11 +1679,11 @@ | |||||||
|         }, |         }, | ||||||
|         "pylint": { |         "pylint": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:6758cce3ddbab60c52b57dcc07f0c5d779e5daf0cf50f6faacbef1d3ea62d2a1", |                 "sha256:0f358e221c45cbd4dad2a1e4b883e75d28acdcccd29d40c76eb72b307269b126", | ||||||
|                 "sha256:e178e96b6ba171f8ef51fbce9ca30931e6acbea4a155074d80cc081596c9e852" |                 "sha256:2c9843fff1a88ca0ad98a256806c82c5a8f86086e7ccbdb93297d86c3f90c436" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==2.10.2" |             "version": "==2.11.1" | ||||||
|         }, |         }, | ||||||
|         "pylint-django": { |         "pylint-django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1748,49 +1761,49 @@ | |||||||
|         }, |         }, | ||||||
|         "regex": { |         "regex": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468", |                 "sha256:0628ed7d6334e8f896f882a5c1240de8c4d9b0dd7c7fb8e9f4692f5684b7d656", | ||||||
|                 "sha256:08d74bfaa4c7731b8dac0a992c63673a2782758f7cfad34cf9c1b9184f911354", |                 "sha256:09eb62654030f39f3ba46bc6726bea464069c29d00a9709e28c9ee9623a8da4a", | ||||||
|                 "sha256:0fc1f8f06977c2d4f5e3d3f0d4a08089be783973fc6b6e278bde01f0544ff308", |                 "sha256:0bba1f6df4eafe79db2ecf38835c2626dbd47911e0516f6962c806f83e7a99ae", | ||||||
|                 "sha256:121f4b3185feaade3f85f70294aef3f777199e9b5c0c0245c774ae884b110a2d", |                 "sha256:10a7a9cbe30bd90b7d9a1b4749ef20e13a3528e4215a2852be35784b6bd070f0", | ||||||
|                 "sha256:1413b5022ed6ac0d504ba425ef02549a57d0f4276de58e3ab7e82437892704fc", |                 "sha256:17310b181902e0bb42b29c700e2c2346b8d81f26e900b1328f642e225c88bce1", | ||||||
|                 "sha256:1743345e30917e8c574f273f51679c294effba6ad372db1967852f12c76759d8", |                 "sha256:1e8d1898d4fb817120a5f684363b30108d7b0b46c7261264b100d14ec90a70e7", | ||||||
|                 "sha256:28fc475f560d8f67cc8767b94db4c9440210f6958495aeae70fac8faec631797", |                 "sha256:2054dea683f1bda3a804fcfdb0c1c74821acb968093d0be16233873190d459e3", | ||||||
|                 "sha256:31a99a4796bf5aefc8351e98507b09e1b09115574f7c9dbb9cf2111f7220d2e2", |                 "sha256:29385c4dbb3f8b3a55ce13de6a97a3d21bd00de66acd7cdfc0b49cb2f08c906c", | ||||||
|                 "sha256:328a1fad67445550b982caa2a2a850da5989fd6595e858f02d04636e7f8b0b13", |                 "sha256:295bc8a13554a25ad31e44c4bedabd3c3e28bba027e4feeb9bb157647a2344a7", | ||||||
|                 "sha256:473858730ef6d6ff7f7d5f19452184cd0caa062a20047f6d6f3e135a4648865d", |                 "sha256:2cdb3789736f91d0b3333ac54d12a7e4f9efbc98f53cb905d3496259a893a8b3", | ||||||
|                 "sha256:4cde065ab33bcaab774d84096fae266d9301d1a2f5519d7bd58fc55274afbf7a", |                 "sha256:3baf3eaa41044d4ced2463fd5d23bf7bd4b03d68739c6c99a59ce1f95599a673", | ||||||
|                 "sha256:5f6a808044faae658f546dd5f525e921de9fa409de7a5570865467f03a626fc0", |                 "sha256:4e61100200fa6ab7c99b61476f9f9653962ae71b931391d0264acfb4d9527d9c", | ||||||
|                 "sha256:610b690b406653c84b7cb6091facb3033500ee81089867ee7d59e675f9ca2b73", |                 "sha256:6266fde576e12357b25096351aac2b4b880b0066263e7bc7a9a1b4307991bb0e", | ||||||
|                 "sha256:66256b6391c057305e5ae9209941ef63c33a476b73772ca967d4a2df70520ec1", |                 "sha256:650c4f1fc4273f4e783e1d8e8b51a3e2311c2488ba0fcae6425b1e2c248a189d", | ||||||
|                 "sha256:6eebf512aa90751d5ef6a7c2ac9d60113f32e86e5687326a50d7686e309f66ed", |                 "sha256:658e3477676009083422042c4bac2bdad77b696e932a3de001c42cc046f8eda2", | ||||||
|                 "sha256:79aef6b5cd41feff359acaf98e040844613ff5298d0d19c455b3d9ae0bc8c35a", |                 "sha256:6adc1bd68f81968c9d249aab8c09cdc2cbe384bf2d2cb7f190f56875000cdc72", | ||||||
|                 "sha256:808ee5834e06f57978da3e003ad9d6292de69d2bf6263662a1a8ae30788e080b", |                 "sha256:6c4d83d21d23dd854ffbc8154cf293f4e43ba630aa9bd2539c899343d7f59da3", | ||||||
|                 "sha256:8e44769068d33e0ea6ccdf4b84d80c5afffe5207aa4d1881a629cf0ef3ec398f", |                 "sha256:6f74b6d8f59f3cfb8237e25c532b11f794b96f5c89a6f4a25857d85f84fbef11", | ||||||
|                 "sha256:999ad08220467b6ad4bd3dd34e65329dd5d0df9b31e47106105e407954965256", |                 "sha256:7783d89bd5413d183a38761fbc68279b984b9afcfbb39fa89d91f63763fbfb90", | ||||||
|                 "sha256:9b006628fe43aa69259ec04ca258d88ed19b64791693df59c422b607b6ece8bb", |                 "sha256:7e3536f305f42ad6d31fc86636c54c7dafce8d634e56fef790fbacb59d499dd5", | ||||||
|                 "sha256:9d05ad5367c90814099000442b2125535e9d77581855b9bee8780f1b41f2b1a2", |                 "sha256:821e10b73e0898544807a0692a276e539e5bafe0a055506a6882814b6a02c3ec", | ||||||
|                 "sha256:a577a21de2ef8059b58f79ff76a4da81c45a75fe0bfb09bc8b7bb4293fa18983", |                 "sha256:835962f432bce92dc9bf22903d46c50003c8d11b1dc64084c8fae63bca98564a", | ||||||
|                 "sha256:a617593aeacc7a691cc4af4a4410031654f2909053bd8c8e7db837f179a630eb", |                 "sha256:85c61bee5957e2d7be390392feac7e1d7abd3a49cbaed0c8cee1541b784c8561", | ||||||
|                 "sha256:abb48494d88e8a82601af905143e0de838c776c1241d92021e9256d5515b3645", |                 "sha256:86f9931eb92e521809d4b64ec8514f18faa8e11e97d6c2d1afa1bcf6c20a8eab", | ||||||
|                 "sha256:ac88856a8cbccfc14f1b2d0b829af354cc1743cb375e7f04251ae73b2af6adf8", |                 "sha256:8a5c2250c0a74428fd5507ae8853706fdde0f23bfb62ee1ec9418eeacf216078", | ||||||
|                 "sha256:b4c220a1fe0d2c622493b0a1fd48f8f991998fb447d3cd368033a4b86cf1127a", |                 "sha256:8aec4b4da165c4a64ea80443c16e49e3b15df0f56c124ac5f2f8708a65a0eddc", | ||||||
|                 "sha256:b844fb09bd9936ed158ff9df0ab601e2045b316b17aa8b931857365ea8586906", |                 "sha256:8c268e78d175798cd71d29114b0a1f1391c7d011995267d3b62319ec1a4ecaa1", | ||||||
|                 "sha256:bdc178caebd0f338d57ae445ef8e9b737ddf8fbc3ea187603f65aec5b041248f", |                 "sha256:8d80087320632457aefc73f686f66139801959bf5b066b4419b92be85be3543c", | ||||||
|                 "sha256:c206587c83e795d417ed3adc8453a791f6d36b67c81416676cad053b4104152c", |                 "sha256:95e89a8558c8c48626dcffdf9c8abac26b7c251d352688e7ab9baf351e1c7da6", | ||||||
|                 "sha256:c61dcc1cf9fd165127a2853e2c31eb4fb961a4f26b394ac9fe5669c7a6592892", |                 "sha256:9c371dd326289d85906c27ec2bc1dcdedd9d0be12b543d16e37bad35754bde48", | ||||||
|                 "sha256:c7cb4c512d2d3b0870e00fbbac2f291d4b4bf2634d59a31176a87afe2777c6f0", |                 "sha256:9c7cb25adba814d5f419733fe565f3289d6fa629ab9e0b78f6dff5fa94ab0456", | ||||||
|                 "sha256:d4a332404baa6665b54e5d283b4262f41f2103c255897084ec8f5487ce7b9e8e", |                 "sha256:a731552729ee8ae9c546fb1c651c97bf5f759018fdd40d0e9b4d129e1e3a44c8", | ||||||
|                 "sha256:d5111d4c843d80202e62b4fdbb4920db1dcee4f9366d6b03294f45ed7b18b42e", |                 "sha256:aea4006b73b555fc5bdb650a8b92cf486d678afa168cf9b38402bb60bf0f9c18", | ||||||
|                 "sha256:e1e8406b895aba6caa63d9fd1b6b1700d7e4825f78ccb1e5260551d168db38ed", |                 "sha256:b0e3f59d3c772f2c3baaef2db425e6fc4149d35a052d874bb95ccfca10a1b9f4", | ||||||
|                 "sha256:e8690ed94481f219a7a967c118abaf71ccc440f69acd583cab721b90eeedb77c", |                 "sha256:b15dc34273aefe522df25096d5d087abc626e388a28a28ac75a4404bb7668736", | ||||||
|                 "sha256:ed283ab3a01d8b53de3a05bfdf4473ae24e43caee7dcb5584e86f3f3e5ab4374", |                 "sha256:c000635fd78400a558bd7a3c2981bb2a430005ebaa909d31e6e300719739a949", | ||||||
|                 "sha256:ed4b50355b066796dacdd1cf538f2ce57275d001838f9b132fab80b75e8c84dd", |                 "sha256:c31f35a984caffb75f00a86852951a337540b44e4a22171354fb760cefa09346", | ||||||
|                 "sha256:ee329d0387b5b41a5dddbb6243a21cb7896587a651bebb957e2d2bb8b63c0791", |                 "sha256:c50a6379763c733562b1fee877372234d271e5c78cd13ade5f25978aa06744db", | ||||||
|                 "sha256:f3bf1bc02bc421047bfec3343729c4bbbea42605bcfd6d6bfe2c07ade8b12d2a", |                 "sha256:c94722bf403b8da744b7d0bb87e1f2529383003ceec92e754f768ef9323f69ad", | ||||||
|                 "sha256:f585cbbeecb35f35609edccb95efd95a3e35824cd7752b586503f7e6087303f1", |                 "sha256:dcbbc9cfa147d55a577d285fd479b43103188855074552708df7acc31a476dd9", | ||||||
|                 "sha256:f60667673ff9c249709160529ab39667d1ae9fd38634e006bec95611f632e759" |                 "sha256:fb9f5844db480e2ef9fce3a72e71122dd010ab7b2920f777966ba25f7eb63819" | ||||||
|             ], |             ], | ||||||
|             "version": "==2021.8.28" |             "version": "==2021.9.24" | ||||||
|         }, |         }, | ||||||
|         "requests": { |         "requests": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1848,16 +1861,24 @@ | |||||||
|             "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", |             "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==0.10.2" |             "version": "==0.10.2" | ||||||
|         }, |         }, | ||||||
|  |         "typing-extensions": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e", | ||||||
|  |                 "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7", | ||||||
|  |                 "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34" | ||||||
|  |             ], | ||||||
|  |             "version": "==3.10.0.2" | ||||||
|  |         }, | ||||||
|         "urllib3": { |         "urllib3": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
|                 "secure" |                 "secure" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", |                 "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", | ||||||
|                 "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" |                 "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.26.6" |             "version": "==1.26.7" | ||||||
|         }, |         }, | ||||||
|         "wrapt": { |         "wrapt": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|  | |||||||
| @ -1,3 +1,3 @@ | |||||||
| """authentik""" | """authentik""" | ||||||
| __version__ = "2021.9.1-rc2" | __version__ = "2021.9.8" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  | |||||||
| @ -84,7 +84,7 @@ class SystemSerializer(PassiveSerializer): | |||||||
|         return now() |         return now() | ||||||
|  |  | ||||||
|     def get_embedded_outpost_host(self, request: Request) -> str: |     def get_embedded_outpost_host(self, request: Request) -> str: | ||||||
|         """Get the FQDN configured on the embeddded outpost""" |         """Get the FQDN configured on the embedded outpost""" | ||||||
|         outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) |         outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) | ||||||
|         if not outposts.exists(): |         if not outposts.exists(): | ||||||
|             return "" |             return "" | ||||||
|  | |||||||
| @ -8,3 +8,8 @@ class AuthentikAdminConfig(AppConfig): | |||||||
|     name = "authentik.admin" |     name = "authentik.admin" | ||||||
|     label = "authentik_admin" |     label = "authentik_admin" | ||||||
|     verbose_name = "authentik Admin" |     verbose_name = "authentik Admin" | ||||||
|  |  | ||||||
|  |     def ready(self): | ||||||
|  |         from authentik.admin.tasks import clear_update_notifications | ||||||
|  |  | ||||||
|  |         clear_update_notifications.delay() | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ from requests import RequestException | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import ENV_GIT_HASH_KEY, __version__ | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction, Notification | ||||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.http import get_http_session | from authentik.lib.utils.http import get_http_session | ||||||
| @ -35,6 +35,18 @@ def _set_prom_info(): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @CELERY_APP.task() | ||||||
|  | def clear_update_notifications(): | ||||||
|  |     """Clear update notifications on startup if the notification was for the version | ||||||
|  |     we're running now.""" | ||||||
|  |     for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE): | ||||||
|  |         if "new_version" not in notification.event.context: | ||||||
|  |             continue | ||||||
|  |         notification_version = notification.event.context["new_version"] | ||||||
|  |         if notification_version == __version__: | ||||||
|  |             notification.delete() | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) | @CELERY_APP.task(bind=True, base=MonitoredTask) | ||||||
| def update_latest_version(self: MonitoredTask): | def update_latest_version(self: MonitoredTask): | ||||||
|     """Update latest version info""" |     """Update latest version info""" | ||||||
|  | |||||||
| @ -40,7 +40,6 @@ def bearer_auth(raw_header: bytes) -> Optional[User]: | |||||||
|         raise AuthenticationFailed("Malformed header") |         raise AuthenticationFailed("Malformed header") | ||||||
|     tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API) |     tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API) | ||||||
|     if not tokens.exists(): |     if not tokens.exists(): | ||||||
|         LOGGER.info("Authenticating via secret_key") |  | ||||||
|         user = token_secret_key(password) |         user = token_secret_key(password) | ||||||
|         if not user: |         if not user: | ||||||
|             raise AuthenticationFailed("Token invalid/expired") |             raise AuthenticationFailed("Token invalid/expired") | ||||||
| @ -58,6 +57,7 @@ def token_secret_key(value: str) -> Optional[User]: | |||||||
|     outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) |     outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) | ||||||
|     if not outposts: |     if not outposts: | ||||||
|         return None |         return None | ||||||
|  |     LOGGER.info("Authenticating via secret_key") | ||||||
|     outpost = outposts.first() |     outpost = outposts.first() | ||||||
|     return outpost.user |     return outpost.user | ||||||
|  |  | ||||||
|  | |||||||
| @ -33,3 +33,12 @@ class OwnerPermissions(BasePermission): | |||||||
|         if owner != request.user: |         if owner != request.user: | ||||||
|             return False |             return False | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OwnerSuperuserPermissions(OwnerPermissions): | ||||||
|  |     """Similar to OwnerPermissions, except always allow access for superusers""" | ||||||
|  |  | ||||||
|  |     def has_object_permission(self, request: Request, view, obj: Model) -> bool: | ||||||
|  |         if request.user.is_superuser: | ||||||
|  |             return True | ||||||
|  |         return super().has_object_permission(request, view, obj) | ||||||
|  | |||||||
| @ -5,6 +5,9 @@ from typing import Callable, Optional | |||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None): | def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None): | ||||||
| @ -18,10 +21,12 @@ def permission_required(perm: Optional[str] = None, other_perms: Optional[list[s | |||||||
|             if perm: |             if perm: | ||||||
|                 obj = self.get_object() |                 obj = self.get_object() | ||||||
|                 if not request.user.has_perm(perm, obj): |                 if not request.user.has_perm(perm, obj): | ||||||
|  |                     LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj) | ||||||
|                     return self.permission_denied(request) |                     return self.permission_denied(request) | ||||||
|             if other_perms: |             if other_perms: | ||||||
|                 for other_perm in other_perms: |                 for other_perm in other_perms: | ||||||
|                     if not request.user.has_perm(other_perm): |                     if not request.user.has_perm(other_perm): | ||||||
|  |                         LOGGER.debug("denying access for other", user=request.user, perm=perm) | ||||||
|                         return self.permission_denied(request) |                         return self.permission_denied(request) | ||||||
|             return func(self, request, *args, **kwargs) |             return func(self, request, *args, **kwargs) | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ from drf_spectacular.types import OpenApiTypes | |||||||
|  |  | ||||||
|  |  | ||||||
| def build_standard_type(obj, **kwargs): | def build_standard_type(obj, **kwargs): | ||||||
|     """Build a basic type with optional add ons.""" |     """Build a basic type with optional add owns.""" | ||||||
|     schema = build_basic_type(obj) |     schema = build_basic_type(obj) | ||||||
|     schema.update(kwargs) |     schema.update(kwargs) | ||||||
|     return schema |     return schema | ||||||
|  | |||||||
| @ -63,7 +63,7 @@ class ConfigView(APIView): | |||||||
|  |  | ||||||
|     @extend_schema(responses={200: ConfigSerializer(many=False)}) |     @extend_schema(responses={200: ConfigSerializer(many=False)}) | ||||||
|     def get(self, request: Request) -> Response: |     def get(self, request: Request) -> Response: | ||||||
|         """Retrive public configuration options""" |         """Retrieve public configuration options""" | ||||||
|         config = ConfigSerializer( |         config = ConfigSerializer( | ||||||
|             { |             { | ||||||
|                 "error_reporting_enabled": CONFIG.y("error_reporting.enabled"), |                 "error_reporting_enabled": CONFIG.y("error_reporting.enabled"), | ||||||
|  | |||||||
| @ -10,10 +10,13 @@ from rest_framework.permissions import AllowAny | |||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.throttling import AnonRateThrottle | from rest_framework.throttling import AnonRateThrottle | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.tasks import sentry_proxy | from authentik.api.tasks import sentry_proxy | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class PlainTextParser(BaseParser): | class PlainTextParser(BaseParser): | ||||||
|     """Plain text parser.""" |     """Plain text parser.""" | ||||||
| @ -45,6 +48,7 @@ class SentryTunnelView(APIView): | |||||||
|         """Sentry tunnel, to prevent ad blockers from blocking sentry""" |         """Sentry tunnel, to prevent ad blockers from blocking sentry""" | ||||||
|         # Only allow usage of this endpoint when error reporting is enabled |         # Only allow usage of this endpoint when error reporting is enabled | ||||||
|         if not CONFIG.y_bool("error_reporting.enabled", False): |         if not CONFIG.y_bool("error_reporting.enabled", False): | ||||||
|  |             LOGGER.debug("error reporting disabled") | ||||||
|             return HttpResponse(status=400) |             return HttpResponse(status=400) | ||||||
|         # Body is 2 json objects separated by \n |         # Body is 2 json objects separated by \n | ||||||
|         full_body = request.body |         full_body = request.body | ||||||
| @ -55,6 +59,7 @@ class SentryTunnelView(APIView): | |||||||
|         # Check that the DSN is what we expect |         # Check that the DSN is what we expect | ||||||
|         dsn = header.get("dsn", "") |         dsn = header.get("dsn", "") | ||||||
|         if dsn != settings.SENTRY_DSN: |         if dsn != settings.SENTRY_DSN: | ||||||
|  |             LOGGER.debug("Invalid dsn", have=dsn, expected=settings.SENTRY_DSN) | ||||||
|             return HttpResponse(status=400) |             return HttpResponse(status=400) | ||||||
|         sentry_proxy.delay(full_body.decode()) |         sentry_proxy.delay(full_body.decode()) | ||||||
|         return HttpResponse(status=204) |         return HttpResponse(status=204) | ||||||
|  | |||||||
| @ -99,6 +99,7 @@ from authentik.stages.user_write.api import UserWriteStageViewSet | |||||||
| from authentik.tenants.api import TenantViewSet | from authentik.tenants.api import TenantViewSet | ||||||
|  |  | ||||||
| router = routers.DefaultRouter() | router = routers.DefaultRouter() | ||||||
|  | router.include_format_suffixes = False | ||||||
|  |  | ||||||
| router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") | router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") | ||||||
| router.register("admin/apps", AppsViewSet, basename="apps") | router.register("admin/apps", AppsViewSet, basename="apps") | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ 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 OwnerPermissions | 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.models import AuthenticatedSession | from authentik.core.models import AuthenticatedSession | ||||||
| from authentik.events.geo import GEOIP_READER, GeoIPDict | from authentik.events.geo import GEOIP_READER, GeoIPDict | ||||||
| @ -103,7 +103,7 @@ class AuthenticatedSessionViewSet( | |||||||
|     search_fields = ["user__username", "last_ip", "last_user_agent"] |     search_fields = ["user__username", "last_ip", "last_user_agent"] | ||||||
|     filterset_fields = ["user__username", "last_ip", "last_user_agent"] |     filterset_fields = ["user__username", "last_ip", "last_user_agent"] | ||||||
|     ordering = ["user__username"] |     ordering = ["user__username"] | ||||||
|     permission_classes = [OwnerPermissions] |     permission_classes = [OwnerSuperuserPermissions] | ||||||
|     filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] |     filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|  | |||||||
| @ -2,15 +2,19 @@ | |||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.http.response import Http404 | from django.http.response import Http404 | ||||||
|  | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
|  | 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.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.api.authorization import OwnerSuperuserPermissions | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserSerializer | from authentik.core.api.users import UserSerializer | ||||||
| @ -78,14 +82,25 @@ class TokenViewSet(UsedByMixin, ModelViewSet): | |||||||
|         "description", |         "description", | ||||||
|         "expires", |         "expires", | ||||||
|         "expiring", |         "expiring", | ||||||
|  |         "managed", | ||||||
|     ] |     ] | ||||||
|     ordering = ["expires"] |     ordering = ["identifier", "expires"] | ||||||
|  |     permission_classes = [OwnerSuperuserPermissions] | ||||||
|  |     filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] | ||||||
|  |  | ||||||
|  |     def get_queryset(self): | ||||||
|  |         user = self.request.user if self.request else get_anonymous_user() | ||||||
|  |         if user.is_superuser: | ||||||
|  |             return super().get_queryset() | ||||||
|  |         return super().get_queryset().filter(user=user.pk) | ||||||
|  |  | ||||||
|     def perform_create(self, serializer: TokenSerializer): |     def perform_create(self, serializer: TokenSerializer): | ||||||
|         serializer.save( |         if not self.request.user.is_superuser: | ||||||
|             user=self.request.user, |             return serializer.save( | ||||||
|             expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True), |                 user=self.request.user, | ||||||
|         ) |                 expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True), | ||||||
|  |             ) | ||||||
|  |         return super().perform_create(serializer) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_token_key") |     @permission_required("authentik_core.view_token_key") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ from django.db.transaction import atomic | |||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.http import urlencode | from django.utils.http import urlencode | ||||||
|  | from django.utils.text import slugify | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django_filters.filters import BooleanFilter, CharFilter, ModelMultipleChoiceFilter | from django_filters.filters import BooleanFilter, CharFilter, ModelMultipleChoiceFilter | ||||||
| @ -89,6 +90,9 @@ class UserSerializer(ModelSerializer): | |||||||
|             "attributes", |             "attributes", | ||||||
|             "uid", |             "uid", | ||||||
|         ] |         ] | ||||||
|  |         extra_kwargs = { | ||||||
|  |             "name": {"allow_blank": True}, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserSelfSerializer(ModelSerializer): | class UserSelfSerializer(ModelSerializer): | ||||||
| @ -97,9 +101,25 @@ class UserSelfSerializer(ModelSerializer): | |||||||
|  |  | ||||||
|     is_superuser = BooleanField(read_only=True) |     is_superuser = BooleanField(read_only=True) | ||||||
|     avatar = CharField(read_only=True) |     avatar = CharField(read_only=True) | ||||||
|     groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups") |     groups = SerializerMethodField() | ||||||
|     uid = CharField(read_only=True) |     uid = CharField(read_only=True) | ||||||
|  |  | ||||||
|  |     @extend_schema_field( | ||||||
|  |         ListSerializer( | ||||||
|  |             child=inline_serializer( | ||||||
|  |                 "UserSelfGroups", | ||||||
|  |                 {"name": CharField(read_only=True), "pk": CharField(read_only=True)}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  |     def get_groups(self, user: User): | ||||||
|  |         """Return only the group names a user is member of""" | ||||||
|  |         for group in user.ak_groups.all(): | ||||||
|  |             yield { | ||||||
|  |                 "name": group.name, | ||||||
|  |                 "pk": group.pk, | ||||||
|  |             } | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = User |         model = User | ||||||
| @ -116,6 +136,7 @@ class UserSelfSerializer(ModelSerializer): | |||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "is_active": {"read_only": True}, |             "is_active": {"read_only": True}, | ||||||
|  |             "name": {"allow_blank": True}, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -207,6 +228,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|     """User Viewset""" |     """User Viewset""" | ||||||
|  |  | ||||||
|     queryset = User.objects.none() |     queryset = User.objects.none() | ||||||
|  |     ordering = ["username"] | ||||||
|     serializer_class = UserSerializer |     serializer_class = UserSerializer | ||||||
|     search_fields = ["username", "name", "is_active", "email"] |     search_fields = ["username", "name", "is_active", "email"] | ||||||
|     filterset_class = UsersFilter |     filterset_class = UsersFilter | ||||||
| @ -273,7 +295,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|                     ) |                     ) | ||||||
|                     group.users.add(user) |                     group.users.add(user) | ||||||
|                 token = Token.objects.create( |                 token = Token.objects.create( | ||||||
|                     identifier=f"service-account-{username}-password", |                     identifier=slugify(f"service-account-{username}-password"), | ||||||
|                     intent=TokenIntents.INTENT_APP_PASSWORD, |                     intent=TokenIntents.INTENT_APP_PASSWORD, | ||||||
|                     user=user, |                     user=user, | ||||||
|                     expires=now() + timedelta(days=360), |                     expires=now() + timedelta(days=360), | ||||||
| @ -307,7 +329,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         """Allow users to change information on their own profile""" |         """Allow users to change information on their own profile""" | ||||||
|         data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data) |         data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data) | ||||||
|         if not data.is_valid(): |         if not data.is_valid(): | ||||||
|             return Response(data.errors) |             return Response(data.errors, status=400) | ||||||
|         new_user = data.save() |         new_user = data.save() | ||||||
|         # If we're impersonating, we need to update that user object |         # If we're impersonating, we need to update that user object | ||||||
|         # since it caches the full object |         # since it caches the full object | ||||||
|  | |||||||
| @ -26,7 +26,7 @@ class Migration(migrations.Migration): | |||||||
|                     ), |                     ), | ||||||
|                     ( |                     ( | ||||||
|                         "username_link", |                         "username_link", | ||||||
|                         "Link to a user with identical username address. Can have security implications when a username is used with another source.", |                         "Link to a user with identical username. Can have security implications when a username is used with another source.", | ||||||
|                     ), |                     ), | ||||||
|                     ( |                     ( | ||||||
|                         "username_deny", |                         "username_deny", | ||||||
|  | |||||||
| @ -283,7 +283,7 @@ class SourceUserMatchingModes(models.TextChoices): | |||||||
|     ) |     ) | ||||||
|     USERNAME_LINK = "username_link", _( |     USERNAME_LINK = "username_link", _( | ||||||
|         ( |         ( | ||||||
|             "Link to a user with identical username address. Can have security implications " |             "Link to a user with identical username. Can have security implications " | ||||||
|             "when a username is used with another source." |             "when a username is used with another source." | ||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| """Test token API""" | """Test token API""" | ||||||
|  | from json import loads | ||||||
|  |  | ||||||
| from django.urls.base import reverse | from django.urls.base import reverse | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
| @ -13,7 +15,8 @@ class TestTokenAPI(APITestCase): | |||||||
|  |  | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         self.user = User.objects.get(username="akadmin") |         self.user = User.objects.create(username="testuser") | ||||||
|  |         self.admin = User.objects.get(username="akadmin") | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|  |  | ||||||
|     def test_token_create(self): |     def test_token_create(self): | ||||||
| @ -55,3 +58,29 @@ class TestTokenAPI(APITestCase): | |||||||
|         clean_expired_models.delay().get() |         clean_expired_models.delay().get() | ||||||
|         token.refresh_from_db() |         token.refresh_from_db() | ||||||
|         self.assertNotEqual(key, token.key) |         self.assertNotEqual(key, token.key) | ||||||
|  |  | ||||||
|  |     def test_list(self): | ||||||
|  |         """Test Token List (Test normal authentication)""" | ||||||
|  |         token_should: Token = Token.objects.create( | ||||||
|  |             identifier="test", expiring=False, user=self.user | ||||||
|  |         ) | ||||||
|  |         Token.objects.create(identifier="test-2", expiring=False, user=get_anonymous_user()) | ||||||
|  |         response = self.client.get(reverse(("authentik_api:token-list"))) | ||||||
|  |         body = loads(response.content) | ||||||
|  |         self.assertEqual(len(body["results"]), 1) | ||||||
|  |         self.assertEqual(body["results"][0]["identifier"], token_should.identifier) | ||||||
|  |  | ||||||
|  |     def test_list_admin(self): | ||||||
|  |         """Test Token List (Test with admin auth)""" | ||||||
|  |         self.client.force_login(self.admin) | ||||||
|  |         token_should: Token = Token.objects.create( | ||||||
|  |             identifier="test", expiring=False, user=self.user | ||||||
|  |         ) | ||||||
|  |         token_should_not: Token = Token.objects.create( | ||||||
|  |             identifier="test-2", expiring=False, user=get_anonymous_user() | ||||||
|  |         ) | ||||||
|  |         response = self.client.get(reverse(("authentik_api:token-list"))) | ||||||
|  |         body = loads(response.content) | ||||||
|  |         self.assertEqual(len(body["results"]), 2) | ||||||
|  |         self.assertEqual(body["results"][0]["identifier"], token_should.identifier) | ||||||
|  |         self.assertEqual(body["results"][1]["identifier"], token_should_not.identifier) | ||||||
|  | |||||||
| @ -1,7 +1,10 @@ | |||||||
| """NotificationTransport API Views""" | """NotificationTransport API Views""" | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
|  | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | from rest_framework.fields import CharField, ListField, 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 | ||||||
| @ -29,6 +32,14 @@ class NotificationTransportSerializer(ModelSerializer): | |||||||
|         """Return selected mode with a UI Label""" |         """Return selected mode with a UI Label""" | ||||||
|         return TransportMode(instance.mode).label |         return TransportMode(instance.mode).label | ||||||
|  |  | ||||||
|  |     def validate(self, attrs: dict[Any, str]) -> dict[Any, str]: | ||||||
|  |         """Ensure the required fields are set.""" | ||||||
|  |         mode = attrs.get("mode") | ||||||
|  |         if mode in [TransportMode.WEBHOOK, TransportMode.WEBHOOK_SLACK]: | ||||||
|  |             if "webhook_url" not in attrs or attrs.get("webhook_url", "") == "": | ||||||
|  |                 raise ValidationError("Webhook URL may not be empty.") | ||||||
|  |         return attrs | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = NotificationTransport |         model = NotificationTransport | ||||||
|  | |||||||
| @ -0,0 +1,19 @@ | |||||||
|  | # Generated by Django 3.2.7 on 2021-10-04 15:31 | ||||||
|  |  | ||||||
|  | import django.core.validators | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_events", "0018_auto_20210911_2217"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="notificationtransport", | ||||||
|  |             name="webhook_url", | ||||||
|  |             field=models.TextField(blank=True, validators=[django.core.validators.URLValidator()]), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional, Type, Union | |||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
|  | from django.core.validators import URLValidator | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| @ -223,7 +224,7 @@ class NotificationTransport(models.Model): | |||||||
|     name = models.TextField(unique=True) |     name = models.TextField(unique=True) | ||||||
|     mode = models.TextField(choices=TransportMode.choices) |     mode = models.TextField(choices=TransportMode.choices) | ||||||
|  |  | ||||||
|     webhook_url = models.TextField(blank=True) |     webhook_url = models.TextField(blank=True, validators=[URLValidator()]) | ||||||
|     webhook_mapping = models.ForeignKey( |     webhook_mapping = models.ForeignKey( | ||||||
|         "NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None |         "NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ from dataclasses import dataclass, field | |||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from timeit import default_timer | from timeit import default_timer | ||||||
| from traceback import format_tb |  | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from celery import Task | from celery import Task | ||||||
| @ -42,8 +41,7 @@ class TaskResult: | |||||||
|  |  | ||||||
|     def with_error(self, exc: Exception) -> "TaskResult": |     def with_error(self, exc: Exception) -> "TaskResult": | ||||||
|         """Since errors might not always be pickle-able, set the traceback""" |         """Since errors might not always be pickle-able, set the traceback""" | ||||||
|         self.messages.extend(format_tb(exc.__traceback__)) |         self.messages.extend(exception_to_string(exc).splitlines()) | ||||||
|         self.messages.append(str(exc)) |  | ||||||
|         return self |         return self | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,7 +4,13 @@ from django.urls import reverse | |||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.events.models import Event, EventAction, Notification, NotificationSeverity | from authentik.events.models import ( | ||||||
|  |     Event, | ||||||
|  |     EventAction, | ||||||
|  |     Notification, | ||||||
|  |     NotificationSeverity, | ||||||
|  |     TransportMode, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestEventsAPI(APITestCase): | class TestEventsAPI(APITestCase): | ||||||
| @ -41,3 +47,23 @@ class TestEventsAPI(APITestCase): | |||||||
|         ) |         ) | ||||||
|         notification.refresh_from_db() |         notification.refresh_from_db() | ||||||
|         self.assertTrue(notification.seen) |         self.assertTrue(notification.seen) | ||||||
|  |  | ||||||
|  |     def test_transport(self): | ||||||
|  |         """Test transport API""" | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:notificationtransport-list"), | ||||||
|  |             data={ | ||||||
|  |                 "name": "foo-with", | ||||||
|  |                 "mode": TransportMode.WEBHOOK, | ||||||
|  |                 "webhook_url": "http://foo.com", | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 201) | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:notificationtransport-list"), | ||||||
|  |             data={ | ||||||
|  |                 "name": "foo-without", | ||||||
|  |                 "mode": TransportMode.WEBHOOK, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  | |||||||
| @ -77,7 +77,7 @@ def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]: | |||||||
|     final_dict = {} |     final_dict = {} | ||||||
|     for key, value in source.items(): |     for key, value in source.items(): | ||||||
|         if is_dataclass(value): |         if is_dataclass(value): | ||||||
|             # Because asdict calls `copy.deepcopy(obj)` on everything thats not tuple/dict, |             # Because asdict calls `copy.deepcopy(obj)` on everything that's not tuple/dict, | ||||||
|             # and deepcopy doesn't work with HttpRequests (neither django nor rest_framework). |             # and deepcopy doesn't work with HttpRequests (neither django nor rest_framework). | ||||||
|             # Currently, the only dataclass that actually holds an http request is a PolicyRequest |             # Currently, the only dataclass that actually holds an http request is a PolicyRequest | ||||||
|             if isinstance(value, PolicyRequest): |             if isinstance(value, PolicyRequest): | ||||||
|  | |||||||
| @ -108,6 +108,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|     queryset = Flow.objects.all() |     queryset = Flow.objects.all() | ||||||
|     serializer_class = FlowSerializer |     serializer_class = FlowSerializer | ||||||
|     lookup_field = "slug" |     lookup_field = "slug" | ||||||
|  |     ordering = ["slug", "name"] | ||||||
|     search_fields = ["name", "slug", "designation", "title"] |     search_fields = ["name", "slug", "designation", "title"] | ||||||
|     filterset_fields = ["flow_uuid", "name", "slug", "designation"] |     filterset_fields = ["flow_uuid", "name", "slug", "designation"] | ||||||
|  |  | ||||||
|  | |||||||
| @ -57,11 +57,11 @@ class FlowPlan: | |||||||
|     markers: list[StageMarker] = field(default_factory=list) |     markers: list[StageMarker] = field(default_factory=list) | ||||||
|  |  | ||||||
|     def append_stage(self, stage: Stage, marker: Optional[StageMarker] = None): |     def append_stage(self, stage: Stage, marker: Optional[StageMarker] = None): | ||||||
|         """Append `stage` to all stages, optionall with stage marker""" |         """Append `stage` to all stages, optionally with stage marker""" | ||||||
|         return self.append(FlowStageBinding(stage=stage), marker) |         return self.append(FlowStageBinding(stage=stage), marker) | ||||||
|  |  | ||||||
|     def append(self, binding: FlowStageBinding, marker: Optional[StageMarker] = None): |     def append(self, binding: FlowStageBinding, marker: Optional[StageMarker] = None): | ||||||
|         """Append `stage` to all stages, optionall with stage marker""" |         """Append `stage` to all stages, optionally with stage marker""" | ||||||
|         self.bindings.append(binding) |         self.bindings.append(binding) | ||||||
|         self.markers.append(marker or StageMarker()) |         self.markers.append(marker or StageMarker()) | ||||||
|  |  | ||||||
|  | |||||||
| @ -438,7 +438,7 @@ class TestFlowExecutor(APITestCase): | |||||||
|  |  | ||||||
|         # third request, this should trigger the re-evaluate |         # third request, this should trigger the re-evaluate | ||||||
|         # A get request will evaluate the policies and this will return stage 4 |         # A get request will evaluate the policies and this will return stage 4 | ||||||
|         # but it won't save it, hence we cant' check the plan |         # but it won't save it, hence we can't check the plan | ||||||
|         response = self.client.get(exec_url) |         response = self.client.get(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ from authentik.lib.sentry import SentryIgnoredException | |||||||
|  |  | ||||||
|  |  | ||||||
| def get_attrs(obj: SerializerModel) -> dict[str, Any]: | def get_attrs(obj: SerializerModel) -> dict[str, Any]: | ||||||
|     """Get object's attributes via their serializer, and covert it to a normal dict""" |     """Get object's attributes via their serializer, and convert it to a normal dict""" | ||||||
|     data = dict(obj.serializer(obj).data) |     data = dict(obj.serializer(obj).data) | ||||||
|     to_remove = ( |     to_remove = ( | ||||||
|         "policies", |         "policies", | ||||||
|  | |||||||
| @ -14,12 +14,7 @@ from django.utils.decorators import method_decorator | |||||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||||
| from django.views.generic import View | from django.views.generic import View | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import ( | from drf_spectacular.utils import OpenApiParameter, PolymorphicProxySerializer, extend_schema | ||||||
|     OpenApiParameter, |  | ||||||
|     OpenApiResponse, |  | ||||||
|     PolymorphicProxySerializer, |  | ||||||
|     extend_schema, |  | ||||||
| ) |  | ||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
| from sentry_sdk import capture_exception | from sentry_sdk import capture_exception | ||||||
| @ -131,12 +126,12 @@ class FlowExecutorView(APIView): | |||||||
|  |  | ||||||
|     # pylint: disable=unused-argument, too-many-return-statements |     # pylint: disable=unused-argument, too-many-return-statements | ||||||
|     def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: |     def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: | ||||||
|         # Early check if theres an active Plan for the current session |         # Early check if there's an active Plan for the current session | ||||||
|         if SESSION_KEY_PLAN in self.request.session: |         if SESSION_KEY_PLAN in self.request.session: | ||||||
|             self.plan = self.request.session[SESSION_KEY_PLAN] |             self.plan = self.request.session[SESSION_KEY_PLAN] | ||||||
|             if self.plan.flow_pk != self.flow.pk.hex: |             if self.plan.flow_pk != self.flow.pk.hex: | ||||||
|                 self._logger.warning( |                 self._logger.warning( | ||||||
|                     "f(exec): Found existing plan for other flow, deleteing plan", |                     "f(exec): Found existing plan for other flow, deleting plan", | ||||||
|                 ) |                 ) | ||||||
|                 # Existing plan is deleted from session and instance |                 # Existing plan is deleted from session and instance | ||||||
|                 self.plan = None |                 self.plan = None | ||||||
| @ -213,9 +208,6 @@ class FlowExecutorView(APIView): | |||||||
|                 serializers=challenge_types(), |                 serializers=challenge_types(), | ||||||
|                 resource_type_field_name="component", |                 resource_type_field_name="component", | ||||||
|             ), |             ), | ||||||
|             404: OpenApiResponse( |  | ||||||
|                 description="No Token found" |  | ||||||
|             ),  # This error can be raised by the email stage |  | ||||||
|         }, |         }, | ||||||
|         request=OpenApiTypes.NONE, |         request=OpenApiTypes.NONE, | ||||||
|         parameters=[ |         parameters=[ | ||||||
| @ -441,7 +433,7 @@ class ToDefaultFlow(View): | |||||||
|             plan: FlowPlan = self.request.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.request.session[SESSION_KEY_PLAN] | ||||||
|             if plan.flow_pk != flow.pk.hex: |             if plan.flow_pk != flow.pk.hex: | ||||||
|                 LOGGER.warning( |                 LOGGER.warning( | ||||||
|                     "f(def): Found existing plan for other flow, deleteing plan", |                     "f(def): Found existing plan for other flow, deleting plan", | ||||||
|                     flow_slug=flow.slug, |                     flow_slug=flow.slug, | ||||||
|                 ) |                 ) | ||||||
|                 del self.request.session[SESSION_KEY_PLAN] |                 del self.request.session[SESSION_KEY_PLAN] | ||||||
|  | |||||||
| @ -93,6 +93,7 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | |||||||
|     if "exc_info" in hint: |     if "exc_info" in hint: | ||||||
|         _, exc_value, _ = hint["exc_info"] |         _, exc_value, _ = hint["exc_info"] | ||||||
|         if isinstance(exc_value, ignored_classes): |         if isinstance(exc_value, ignored_classes): | ||||||
|  |             LOGGER.debug("dropping exception", exception=exc_value) | ||||||
|             return None |             return None | ||||||
|     if "logger" in event: |     if "logger" in event: | ||||||
|         if event["logger"] in [ |         if event["logger"] in [ | ||||||
|  | |||||||
| @ -32,7 +32,7 @@ class TestConfig(TestCase): | |||||||
|         config = ConfigLoader() |         config = ConfigLoader() | ||||||
|         environ["foo"] = "bar" |         environ["foo"] = "bar" | ||||||
|         self.assertEqual(config.parse_uri("env://foo"), "bar") |         self.assertEqual(config.parse_uri("env://foo"), "bar") | ||||||
|         self.assertEqual(config.parse_uri("env://fo?bar"), "bar") |         self.assertEqual(config.parse_uri("env://foo?bar"), "bar") | ||||||
|  |  | ||||||
|     def test_uri_file(self): |     def test_uri_file(self): | ||||||
|         """Test URI parsing (file load)""" |         """Test URI parsing (file load)""" | ||||||
|  | |||||||
| @ -27,7 +27,7 @@ class TestHTTP(TestCase): | |||||||
|         token = Token.objects.create( |         token = Token.objects.create( | ||||||
|             identifier="test", user=self.user, intent=TokenIntents.INTENT_API |             identifier="test", user=self.user, intent=TokenIntents.INTENT_API | ||||||
|         ) |         ) | ||||||
|         # Invalid, non-existant token |         # Invalid, non-existent token | ||||||
|         request = self.factory.get( |         request = self.factory.get( | ||||||
|             "/", |             "/", | ||||||
|             **{ |             **{ | ||||||
| @ -36,7 +36,7 @@ class TestHTTP(TestCase): | |||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(get_client_ip(request), "127.0.0.1") |         self.assertEqual(get_client_ip(request), "127.0.0.1") | ||||||
|         # Invalid, user doesn't have permisions |         # Invalid, user doesn't have permissions | ||||||
|         request = self.factory.get( |         request = self.factory.get( | ||||||
|             "/", |             "/", | ||||||
|             **{ |             **{ | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| """authentik lib reflection utilities""" | """authentik lib reflection utilities""" | ||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  | from typing import Union | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
|  |  | ||||||
| @ -19,12 +20,12 @@ def all_subclasses(cls, sort=True): | |||||||
|     return classes |     return classes | ||||||
|  |  | ||||||
|  |  | ||||||
| def class_to_path(cls): | def class_to_path(cls: type) -> str: | ||||||
|     """Turn Class (Class or instance) into module path""" |     """Turn Class (Class or instance) into module path""" | ||||||
|     return f"{cls.__module__}.{cls.__name__}" |     return f"{cls.__module__}.{cls.__name__}" | ||||||
|  |  | ||||||
|  |  | ||||||
| def path_to_class(path): | def path_to_class(path: Union[str, None]) -> Union[type, None]: | ||||||
|     """Import module and return class""" |     """Import module and return class""" | ||||||
|     if not path: |     if not path: | ||||||
|         return None |         return None | ||||||
|  | |||||||
| @ -104,7 +104,7 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|                 expected=self.outpost.config.kubernetes_replicas, |                 expected=self.outpost.config.kubernetes_replicas, | ||||||
|             ).inc() |             ).inc() | ||||||
|             LOGGER.debug( |             LOGGER.debug( | ||||||
|                 "added outpost instace to cache", |                 "added outpost instance to cache", | ||||||
|                 outpost=self.outpost, |                 outpost=self.outpost, | ||||||
|                 instance_uuid=self.last_uid, |                 instance_uuid=self.last_uid, | ||||||
|             ) |             ) | ||||||
|  | |||||||
| @ -38,6 +38,7 @@ class DockerController(BaseController): | |||||||
|             "AUTHENTIK_HOST": self.outpost.config.authentik_host.lower(), |             "AUTHENTIK_HOST": self.outpost.config.authentik_host.lower(), | ||||||
|             "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure).lower(), |             "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure).lower(), | ||||||
|             "AUTHENTIK_TOKEN": self.outpost.token.key, |             "AUTHENTIK_TOKEN": self.outpost.token.key, | ||||||
|  |             "AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     def _comp_env(self, container: Container) -> bool: |     def _comp_env(self, container: Container) -> bool: | ||||||
| @ -75,6 +76,9 @@ class DockerController(BaseController): | |||||||
|         #   {'HostIp': '0.0.0.0', 'HostPort': '389'}, |         #   {'HostIp': '0.0.0.0', 'HostPort': '389'}, | ||||||
|         #   {'HostIp': '::', 'HostPort': '389'} |         #   {'HostIp': '::', 'HostPort': '389'} | ||||||
|         # ]} |         # ]} | ||||||
|  |         # If no ports are mapped (either mapping disabled, or host network) | ||||||
|  |         if not container.ports: | ||||||
|  |             return False | ||||||
|         for port in self.deployment_ports: |         for port in self.deployment_ports: | ||||||
|             key = f"{port.inner_port or port.port}/{port.protocol.lower()}" |             key = f"{port.inner_port or port.port}/{port.protocol.lower()}" | ||||||
|             if key not in container.ports: |             if key not in container.ports: | ||||||
| @ -98,15 +102,16 @@ class DockerController(BaseController): | |||||||
|                 "image": image_name, |                 "image": image_name, | ||||||
|                 "name": container_name, |                 "name": container_name, | ||||||
|                 "detach": True, |                 "detach": True, | ||||||
|                 "ports": { |  | ||||||
|                     f"{port.inner_port or port.port}/{port.protocol.lower()}": port.port |  | ||||||
|                     for port in self.deployment_ports |  | ||||||
|                 }, |  | ||||||
|                 "environment": self._get_env(), |                 "environment": self._get_env(), | ||||||
|                 "labels": self._get_labels(), |                 "labels": self._get_labels(), | ||||||
|                 "restart_policy": {"Name": "unless-stopped"}, |                 "restart_policy": {"Name": "unless-stopped"}, | ||||||
|                 "network": self.outpost.config.docker_network, |                 "network": self.outpost.config.docker_network, | ||||||
|             } |             } | ||||||
|  |             if self.outpost.config.docker_map_ports: | ||||||
|  |                 container_args["ports"] = { | ||||||
|  |                     f"{port.inner_port or port.port}/{port.protocol.lower()}": str(port.port) | ||||||
|  |                     for port in self.deployment_ports | ||||||
|  |                 } | ||||||
|             if settings.TEST: |             if settings.TEST: | ||||||
|                 del container_args["ports"] |                 del container_args["ports"] | ||||||
|                 del container_args["network"] |                 del container_args["network"] | ||||||
| @ -164,11 +169,9 @@ class DockerController(BaseController): | |||||||
|                 self.down() |                 self.down() | ||||||
|                 return self.up(depth + 1) |                 return self.up(depth + 1) | ||||||
|             # Check that container is healthy |             # Check that container is healthy | ||||||
|             if ( |             if container.status == "running" and container.attrs.get("State", {}).get( | ||||||
|                 container.status == "running" |                 "Health", {} | ||||||
|                 and container.attrs.get("State", {}).get("Health", {}).get("Status", "") |             ).get("Status", "") not in ["healthy", "starting"]: | ||||||
|                 != "healthy" |  | ||||||
|             ): |  | ||||||
|                 # At this point we know the config is correct, but the container isn't healthy, |                 # At this point we know the config is correct, but the container isn't healthy, | ||||||
|                 # so we just restart it with the same config |                 # so we just restart it with the same config | ||||||
|                 if has_been_created: |                 if has_been_created: | ||||||
| @ -217,6 +220,7 @@ class DockerController(BaseController): | |||||||
|                         "AUTHENTIK_HOST": self.outpost.config.authentik_host, |                         "AUTHENTIK_HOST": self.outpost.config.authentik_host, | ||||||
|                         "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure), |                         "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure), | ||||||
|                         "AUTHENTIK_TOKEN": self.outpost.token.key, |                         "AUTHENTIK_TOKEN": self.outpost.token.key, | ||||||
|  |                         "AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser, | ||||||
|                     }, |                     }, | ||||||
|                     "labels": self._get_labels(), |                     "labels": self._get_labels(), | ||||||
|                 } |                 } | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ from structlog.stdlib import get_logger | |||||||
| from urllib3.exceptions import HTTPError | from urllib3.exceptions import HTTPError | ||||||
|  |  | ||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpdate | ||||||
| from authentik.outposts.managed import MANAGED_OUTPOST | from authentik.outposts.managed import MANAGED_OUTPOST | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
| @ -20,18 +20,6 @@ if TYPE_CHECKING: | |||||||
| T = TypeVar("T", V1Pod, V1Deployment) | T = TypeVar("T", V1Pod, V1Deployment) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReconcileTrigger(SentryIgnoredException): |  | ||||||
|     """Base trigger raised by child classes to notify us""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class NeedsRecreate(ReconcileTrigger): |  | ||||||
|     """Exception to trigger a complete recreate of the Kubernetes Object""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class NeedsUpdate(ReconcileTrigger): |  | ||||||
|     """Exception to trigger an update to the Kubernetes Object""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class KubernetesObjectReconciler(Generic[T]): | class KubernetesObjectReconciler(Generic[T]): | ||||||
|     """Base Kubernetes Reconciler, handles the basic logic.""" |     """Base Kubernetes Reconciler, handles the basic logic.""" | ||||||
|  |  | ||||||
| @ -109,7 +97,7 @@ class KubernetesObjectReconciler(Generic[T]): | |||||||
|         except (OpenApiException, HTTPError) as exc: |         except (OpenApiException, HTTPError) as exc: | ||||||
|             # pylint: disable=no-member |             # pylint: disable=no-member | ||||||
|             if isinstance(exc, ApiException) and exc.status == 404: |             if isinstance(exc, ApiException) and exc.status == 404: | ||||||
|                 self.logger.debug("Failed to get current, assuming non-existant") |                 self.logger.debug("Failed to get current, assuming non-existent") | ||||||
|                 return |                 return | ||||||
|             self.logger.debug("Other unhandled error", exc=exc) |             self.logger.debug("Other unhandled error", exc=exc) | ||||||
|             raise exc |             raise exc | ||||||
| @ -129,7 +117,7 @@ class KubernetesObjectReconciler(Generic[T]): | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def retrieve(self) -> T: |     def retrieve(self) -> T: | ||||||
|         """API Wrapper to retrive object""" |         """API Wrapper to retrieve object""" | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def delete(self, reference: T): |     def delete(self, reference: T): | ||||||
|  | |||||||
| @ -17,7 +17,9 @@ from kubernetes.client import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler, NeedsUpdate | from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||||
|  | from authentik.outposts.controllers.k8s.triggers import NeedsUpdate | ||||||
|  | from authentik.outposts.controllers.k8s.utils import compare_ports | ||||||
| from authentik.outposts.models import Outpost | from authentik.outposts.models import Outpost | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
| @ -35,7 +37,10 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | |||||||
|         self.outpost = self.controller.outpost |         self.outpost = self.controller.outpost | ||||||
|  |  | ||||||
|     def reconcile(self, current: V1Deployment, reference: V1Deployment): |     def reconcile(self, current: V1Deployment, reference: V1Deployment): | ||||||
|         super().reconcile(current, reference) |         compare_ports( | ||||||
|  |             current.spec.template.spec.containers[0].ports, | ||||||
|  |             reference.spec.template.spec.containers[0].ports, | ||||||
|  |         ) | ||||||
|         if current.spec.replicas != reference.spec.replicas: |         if current.spec.replicas != reference.spec.replicas: | ||||||
|             raise NeedsUpdate() |             raise NeedsUpdate() | ||||||
|         if ( |         if ( | ||||||
| @ -43,6 +48,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | |||||||
|             != reference.spec.template.spec.containers[0].image |             != reference.spec.template.spec.containers[0].image | ||||||
|         ): |         ): | ||||||
|             raise NeedsUpdate() |             raise NeedsUpdate() | ||||||
|  |         super().reconcile(current, reference) | ||||||
|  |  | ||||||
|     def get_pod_meta(self) -> dict[str, str]: |     def get_pod_meta(self) -> dict[str, str]: | ||||||
|         """Get common object metadata""" |         """Get common object metadata""" | ||||||
| @ -89,6 +95,15 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | |||||||
|                                             ) |                                             ) | ||||||
|                                         ), |                                         ), | ||||||
|                                     ), |                                     ), | ||||||
|  |                                     V1EnvVar( | ||||||
|  |                                         name="AUTHENTIK_HOST_BROWSER", | ||||||
|  |                                         value_from=V1EnvVarSource( | ||||||
|  |                                             secret_key_ref=V1SecretKeySelector( | ||||||
|  |                                                 name=self.name, | ||||||
|  |                                                 key="authentik_host_browser", | ||||||
|  |                                             ) | ||||||
|  |                                         ), | ||||||
|  |                                     ), | ||||||
|                                     V1EnvVar( |                                     V1EnvVar( | ||||||
|                                         name="AUTHENTIK_TOKEN", |                                         name="AUTHENTIK_TOKEN", | ||||||
|                                         value_from=V1EnvVarSource( |                                         value_from=V1EnvVarSource( | ||||||
|  | |||||||
| @ -5,7 +5,8 @@ from typing import TYPE_CHECKING | |||||||
| from kubernetes.client import CoreV1Api, V1Secret | from kubernetes.client import CoreV1Api, V1Secret | ||||||
|  |  | ||||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler, NeedsUpdate | from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||||
|  | from authentik.outposts.controllers.k8s.triggers import NeedsUpdate | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from authentik.outposts.controllers.kubernetes import KubernetesController |     from authentik.outposts.controllers.kubernetes import KubernetesController | ||||||
| @ -26,7 +27,7 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): | |||||||
|     def reconcile(self, current: V1Secret, reference: V1Secret): |     def reconcile(self, current: V1Secret, reference: V1Secret): | ||||||
|         super().reconcile(current, reference) |         super().reconcile(current, reference) | ||||||
|         for key in reference.data.keys(): |         for key in reference.data.keys(): | ||||||
|             if current.data[key] != reference.data[key]: |             if key not in current.data or current.data[key] != reference.data[key]: | ||||||
|                 raise NeedsUpdate() |                 raise NeedsUpdate() | ||||||
|  |  | ||||||
|     def get_reference_object(self) -> V1Secret: |     def get_reference_object(self) -> V1Secret: | ||||||
| @ -40,6 +41,9 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): | |||||||
|                     str(self.controller.outpost.config.authentik_host_insecure) |                     str(self.controller.outpost.config.authentik_host_insecure) | ||||||
|                 ), |                 ), | ||||||
|                 "token": b64string(self.controller.outpost.token.key), |                 "token": b64string(self.controller.outpost.token.key), | ||||||
|  |                 "authentik_host_browser": b64string( | ||||||
|  |                     self.controller.outpost.config.authentik_host_browser | ||||||
|  |                 ), | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,8 +4,9 @@ from typing import TYPE_CHECKING | |||||||
| from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec | from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec | ||||||
|  |  | ||||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler, NeedsRecreate | from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||||
| from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler | from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler | ||||||
|  | from authentik.outposts.controllers.k8s.utils import compare_ports | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from authentik.outposts.controllers.kubernetes import KubernetesController |     from authentik.outposts.controllers.kubernetes import KubernetesController | ||||||
| @ -19,12 +20,12 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | |||||||
|         self.api = CoreV1Api(controller.client) |         self.api = CoreV1Api(controller.client) | ||||||
|  |  | ||||||
|     def reconcile(self, current: V1Service, reference: V1Service): |     def reconcile(self, current: V1Service, reference: V1Service): | ||||||
|  |         compare_ports(current.spec.ports, reference.spec.ports) | ||||||
|  |         # run the base reconcile last, as that will probably raise NeedsUpdate | ||||||
|  |         # after an authentik update. However the ports might have also changed during | ||||||
|  |         # the update, so this causes the service to be re-created with higher | ||||||
|  |         # priority than being updated. | ||||||
|         super().reconcile(current, reference) |         super().reconcile(current, reference) | ||||||
|         if len(current.spec.ports) != len(reference.spec.ports): |  | ||||||
|             raise NeedsRecreate() |  | ||||||
|         for port in reference.spec.ports: |  | ||||||
|             if port not in current.spec.ports: |  | ||||||
|                 raise NeedsRecreate() |  | ||||||
|  |  | ||||||
|     def get_reference_object(self) -> V1Service: |     def get_reference_object(self) -> V1Service: | ||||||
|         """Get deployment object for outpost""" |         """Get deployment object for outpost""" | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								authentik/outposts/controllers/k8s/triggers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								authentik/outposts/controllers/k8s/triggers.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | |||||||
|  | """exceptions used by the kubernetes reconciler to trigger updates""" | ||||||
|  | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReconcileTrigger(SentryIgnoredException): | ||||||
|  |     """Base trigger raised by child classes to notify us""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NeedsRecreate(ReconcileTrigger): | ||||||
|  |     """Exception to trigger a complete recreate of the Kubernetes Object""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NeedsUpdate(ReconcileTrigger): | ||||||
|  |     """Exception to trigger an update to the Kubernetes Object""" | ||||||
| @ -1,8 +1,11 @@ | |||||||
| """k8s utils""" | """k8s utils""" | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
|  | from kubernetes.client.models.v1_container_port import V1ContainerPort | ||||||
| from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME | from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME | ||||||
|  |  | ||||||
|  | from authentik.outposts.controllers.k8s.triggers import NeedsRecreate | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_namespace() -> str: | def get_namespace() -> str: | ||||||
|     """Get the namespace if we're running in a pod, otherwise default to default""" |     """Get the namespace if we're running in a pod, otherwise default to default""" | ||||||
| @ -11,3 +14,12 @@ def get_namespace() -> str: | |||||||
|         with open(path, "r", encoding="utf8") as _namespace_file: |         with open(path, "r", encoding="utf8") as _namespace_file: | ||||||
|             return _namespace_file.read() |             return _namespace_file.read() | ||||||
|     return "default" |     return "default" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def compare_ports(current: list[V1ContainerPort], reference: list[V1ContainerPort]): | ||||||
|  |     """Compare ports of a list""" | ||||||
|  |     if len(current) != len(reference): | ||||||
|  |         raise NeedsRecreate() | ||||||
|  |     for port in reference: | ||||||
|  |         if port not in current: | ||||||
|  |             raise NeedsRecreate() | ||||||
|  | |||||||
| @ -30,7 +30,7 @@ class DockerInlineTLS: | |||||||
|         return str(path) |         return str(path) | ||||||
|  |  | ||||||
|     def write(self) -> TLSConfig: |     def write(self) -> TLSConfig: | ||||||
|         """Create TLSConfig with Certificate Keypairs""" |         """Create TLSConfig with Certificate Key pairs""" | ||||||
|         # So yes, this is quite ugly. But sadly, there is no clean way to pass |         # So yes, this is quite ugly. But sadly, there is no clean way to pass | ||||||
|         # docker-py (which is using requests (which is using urllib3)) a certificate |         # docker-py (which is using requests (which is using urllib3)) a certificate | ||||||
|         # for verification or authentication as string. |         # for verification or authentication as string. | ||||||
|  | |||||||
| @ -64,6 +64,7 @@ class OutpostConfig: | |||||||
|  |  | ||||||
|     authentik_host: str = "" |     authentik_host: str = "" | ||||||
|     authentik_host_insecure: bool = False |     authentik_host_insecure: bool = False | ||||||
|  |     authentik_host_browser: str = "" | ||||||
|  |  | ||||||
|     log_level: str = CONFIG.y("log_level") |     log_level: str = CONFIG.y("log_level") | ||||||
|     error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled") |     error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled") | ||||||
| @ -71,6 +72,7 @@ class OutpostConfig: | |||||||
|     object_naming_template: str = field(default="ak-outpost-%(name)s") |     object_naming_template: str = field(default="ak-outpost-%(name)s") | ||||||
|  |  | ||||||
|     docker_network: Optional[str] = field(default=None) |     docker_network: Optional[str] = field(default=None) | ||||||
|  |     docker_map_ports: bool = field(default=True) | ||||||
|  |  | ||||||
|     kubernetes_replicas: int = field(default=1) |     kubernetes_replicas: int = field(default=1) | ||||||
|     kubernetes_namespace: str = field(default_factory=get_namespace) |     kubernetes_namespace: str = field(default_factory=get_namespace) | ||||||
| @ -339,19 +341,8 @@ class Outpost(ManagedModel): | |||||||
|         """Username for service user""" |         """Username for service user""" | ||||||
|         return f"ak-outpost-{self.uuid.hex}" |         return f"ak-outpost-{self.uuid.hex}" | ||||||
|  |  | ||||||
|     @property |     def build_user_permissions(self, user: User): | ||||||
|     def user(self) -> User: |         """Create per-object and global permissions for outpost service-account""" | ||||||
|         """Get/create user with access to all required objects""" |  | ||||||
|         users = User.objects.filter(username=self.user_identifier) |  | ||||||
|         if not users.exists(): |  | ||||||
|             user: User = User.objects.create(username=self.user_identifier) |  | ||||||
|             user.set_unusable_password() |  | ||||||
|             user.save() |  | ||||||
|         else: |  | ||||||
|             user = users.first() |  | ||||||
|         user.attributes[USER_ATTRIBUTE_SA] = True |  | ||||||
|         user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True |  | ||||||
|         user.save() |  | ||||||
|         # To ensure the user only has the correct permissions, we delete all of them and re-add |         # To ensure the user only has the correct permissions, we delete all of them and re-add | ||||||
|         # the ones the user needs |         # the ones the user needs | ||||||
|         with transaction.atomic(): |         with transaction.atomic(): | ||||||
| @ -395,6 +386,23 @@ class Outpost(ManagedModel): | |||||||
|             "Updated service account's permissions", |             "Updated service account's permissions", | ||||||
|             perms=UserObjectPermission.objects.filter(user=user), |             perms=UserObjectPermission.objects.filter(user=user), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def user(self) -> User: | ||||||
|  |         """Get/create user with access to all required objects""" | ||||||
|  |         users = User.objects.filter(username=self.user_identifier) | ||||||
|  |         should_create_user = not users.exists() | ||||||
|  |         if should_create_user: | ||||||
|  |             user: User = User.objects.create(username=self.user_identifier) | ||||||
|  |             user.set_unusable_password() | ||||||
|  |             user.save() | ||||||
|  |         else: | ||||||
|  |             user = users.first() | ||||||
|  |         user.attributes[USER_ATTRIBUTE_SA] = True | ||||||
|  |         user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True | ||||||
|  |         user.save() | ||||||
|  |         if should_create_user: | ||||||
|  |             self.build_user_permissions(user) | ||||||
|         return user |         return user | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|  | |||||||
| @ -126,6 +126,7 @@ def outpost_token_ensurer(self: MonitoredTask): | |||||||
|     all_outposts = Outpost.objects.all() |     all_outposts = Outpost.objects.all() | ||||||
|     for outpost in all_outposts: |     for outpost in all_outposts: | ||||||
|         _ = outpost.token |         _ = outpost.token | ||||||
|  |         outpost.build_user_permissions(outpost.user) | ||||||
|     self.set_status( |     self.set_status( | ||||||
|         TaskResult( |         TaskResult( | ||||||
|             TaskResultStatus.SUCCESSFUL, |             TaskResultStatus.SUCCESSFUL, | ||||||
| @ -181,7 +182,7 @@ def outpost_post_save(model_class: str, model_pk: Any): | |||||||
|  |  | ||||||
|  |  | ||||||
| def outpost_send_update(model_instace: Model): | def outpost_send_update(model_instace: Model): | ||||||
|     """Send outpost update to all registered outposts, irregardless to which authentik |     """Send outpost update to all registered outposts, regardless to which authentik | ||||||
|     instance they are connected""" |     instance they are connected""" | ||||||
|     channel_layer = get_channel_layer() |     channel_layer = get_channel_layer() | ||||||
|     if isinstance(model_instace, OutpostModel): |     if isinstance(model_instace, OutpostModel): | ||||||
| @ -196,7 +197,7 @@ def _outpost_single_update(outpost: Outpost, layer=None): | |||||||
|     # Ensure token again, because this function is called when anything related to an |     # Ensure token again, because this function is called when anything related to an | ||||||
|     # OutpostModel is saved, so we can be sure permissions are right |     # OutpostModel is saved, so we can be sure permissions are right | ||||||
|     _ = outpost.token |     _ = outpost.token | ||||||
|     _ = outpost.user |     outpost.build_user_permissions(outpost.user) | ||||||
|     if not layer:  # pragma: no cover |     if not layer:  # pragma: no cover | ||||||
|         layer = get_channel_layer() |         layer = get_channel_layer() | ||||||
|     for state in OutpostState.for_outpost(outpost): |     for state in OutpostState.for_outpost(outpost): | ||||||
| @ -208,7 +209,7 @@ def _outpost_single_update(outpost: Outpost, layer=None): | |||||||
| @CELERY_APP.task() | @CELERY_APP.task() | ||||||
| def outpost_local_connection(): | def outpost_local_connection(): | ||||||
|     """Checks the local environment and create Service connections.""" |     """Checks the local environment and create Service connections.""" | ||||||
|     # Explicitly check against token filename, as thats |     # Explicitly check against token filename, as that's | ||||||
|     # only present when the integration is enabled |     # only present when the integration is enabled | ||||||
|     if Path(SERVICE_TOKEN_FILENAME).exists(): |     if Path(SERVICE_TOKEN_FILENAME).exists(): | ||||||
|         LOGGER.debug("Detected in-cluster Kubernetes Config") |         LOGGER.debug("Detected in-cluster Kubernetes Config") | ||||||
|  | |||||||
| @ -87,6 +87,7 @@ class PolicyViewSet( | |||||||
|         "promptstage": ["isnull"], |         "promptstage": ["isnull"], | ||||||
|     } |     } | ||||||
|     search_fields = ["name"] |     search_fields = ["name"] | ||||||
|  |     ordering = ["name"] | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self):  # pragma: no cover | ||||||
|         return Policy.objects.select_subclasses().prefetch_related("bindings", "promptstage_set") |         return Policy.objects.select_subclasses().prefetch_related("bindings", "promptstage_set") | ||||||
|  | |||||||
| @ -3,8 +3,10 @@ from ipaddress import ip_address, ip_network | |||||||
| from typing import TYPE_CHECKING, Optional | from typing import TYPE_CHECKING, Optional | ||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
|  | from django_otp import devices_for_user | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.models import User | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_SSO | from authentik.flows.planner import PLAN_CONTEXT_SSO | ||||||
| from authentik.lib.expression.evaluator import BaseEvaluator | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| @ -28,6 +30,7 @@ class PolicyEvaluator(BaseEvaluator): | |||||||
|         self._messages = [] |         self._messages = [] | ||||||
|         self._context["ak_logger"] = get_logger(policy_name) |         self._context["ak_logger"] = get_logger(policy_name) | ||||||
|         self._context["ak_message"] = self.expr_func_message |         self._context["ak_message"] = self.expr_func_message | ||||||
|  |         self._context["ak_user_has_authenticator"] = self.expr_func_user_has_authenticator | ||||||
|         self._context["ip_address"] = ip_address |         self._context["ip_address"] = ip_address | ||||||
|         self._context["ip_network"] = ip_network |         self._context["ip_network"] = ip_network | ||||||
|         self._filename = policy_name or "PolicyEvaluator" |         self._filename = policy_name or "PolicyEvaluator" | ||||||
| @ -36,6 +39,19 @@ class PolicyEvaluator(BaseEvaluator): | |||||||
|         """Wrapper to append to messages list, which is returned with PolicyResult""" |         """Wrapper to append to messages list, which is returned with PolicyResult""" | ||||||
|         self._messages.append(message) |         self._messages.append(message) | ||||||
|  |  | ||||||
|  |     def expr_func_user_has_authenticator( | ||||||
|  |         self, user: User, device_type: Optional[str] = None | ||||||
|  |     ) -> bool: | ||||||
|  |         """Check if a user has any authenticator devices, optionally matching *device_type*""" | ||||||
|  |         user_devices = devices_for_user(user) | ||||||
|  |         if device_type: | ||||||
|  |             for device in user_devices: | ||||||
|  |                 device_class = device.__class__.__name__.lower().replace("device", "") | ||||||
|  |                 if device_class == device_type: | ||||||
|  |                     return True | ||||||
|  |             return False | ||||||
|  |         return len(user_devices) > 0 | ||||||
|  |  | ||||||
|     def set_policy_request(self, request: PolicyRequest): |     def set_policy_request(self, request: PolicyRequest): | ||||||
|         """Update context based on policy request (if http request is given, update that too)""" |         """Update context based on policy request (if http request is given, update that too)""" | ||||||
|         # update website/docs/expressions/_objects.md |         # update website/docs/expressions/_objects.md | ||||||
|  | |||||||
| @ -46,7 +46,7 @@ def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: | |||||||
|  |  | ||||||
|  |  | ||||||
| class PolicyProcess(PROCESS_CLASS): | class PolicyProcess(PROCESS_CLASS): | ||||||
|     """Evaluate a single policy within a seprate process""" |     """Evaluate a single policy within a separate process""" | ||||||
|  |  | ||||||
|     connection: Connection |     connection: Connection | ||||||
|     binding: PolicyBinding |     binding: PolicyBinding | ||||||
|  | |||||||
| @ -34,7 +34,7 @@ def update_score(request: HttpRequest, username: str, amount: int): | |||||||
| @receiver(user_login_failed) | @receiver(user_login_failed) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def handle_failed_login(sender, request, credentials, **_): | def handle_failed_login(sender, request, credentials, **_): | ||||||
|     """Lower Score for failed loging attempts""" |     """Lower Score for failed login attempts""" | ||||||
|     if "username" in credentials: |     if "username" in credentials: | ||||||
|         update_score(request, credentials.get("username"), -1) |         update_score(request, credentials.get("username"), -1) | ||||||
|  |  | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ from authentik.policies.types import PolicyRequest | |||||||
|  |  | ||||||
|  |  | ||||||
| def clear_policy_cache(): | def clear_policy_cache(): | ||||||
|     """Ensure no policy-related keys are stil cached""" |     """Ensure no policy-related keys are still cached""" | ||||||
|     keys = cache.keys("policy_*") |     keys = cache.keys("policy_*") | ||||||
|     cache.delete(keys) |     cache.delete(keys) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,16 +1,15 @@ | |||||||
| """LDAP Provider Docker Contoller""" | """LDAP Provider Docker Controller""" | ||||||
| from authentik.outposts.controllers.base import DeploymentPort | from authentik.outposts.controllers.base import DeploymentPort | ||||||
| from authentik.outposts.controllers.docker import DockerController | from authentik.outposts.controllers.docker import DockerController | ||||||
| from authentik.outposts.models import DockerServiceConnection, Outpost | from authentik.outposts.models import DockerServiceConnection, Outpost | ||||||
|  |  | ||||||
|  |  | ||||||
| class LDAPDockerController(DockerController): | class LDAPDockerController(DockerController): | ||||||
|     """LDAP Provider Docker Contoller""" |     """LDAP Provider Docker Controller""" | ||||||
|  |  | ||||||
|     def __init__(self, outpost: Outpost, connection: DockerServiceConnection): |     def __init__(self, outpost: Outpost, connection: DockerServiceConnection): | ||||||
|         super().__init__(outpost, connection) |         super().__init__(outpost, connection) | ||||||
|         self.deployment_ports = [ |         self.deployment_ports = [ | ||||||
|             DeploymentPort(389, "ldap", "tcp", 3389), |             DeploymentPort(389, "ldap", "tcp", 3389), | ||||||
|             DeploymentPort(636, "ldaps", "tcp", 6636), |             DeploymentPort(636, "ldaps", "tcp", 6636), | ||||||
|             DeploymentPort(9300, "http-metrics", "tcp", 9300), |  | ||||||
|         ] |         ] | ||||||
|  | |||||||
| @ -1,11 +1,11 @@ | |||||||
| """LDAP Provider Kubernetes Contoller""" | """LDAP Provider Kubernetes Controller""" | ||||||
| from authentik.outposts.controllers.base import DeploymentPort | from authentik.outposts.controllers.base import DeploymentPort | ||||||
| from authentik.outposts.controllers.kubernetes import KubernetesController | from authentik.outposts.controllers.kubernetes import KubernetesController | ||||||
| from authentik.outposts.models import KubernetesServiceConnection, Outpost | from authentik.outposts.models import KubernetesServiceConnection, Outpost | ||||||
|  |  | ||||||
|  |  | ||||||
| class LDAPKubernetesController(KubernetesController): | class LDAPKubernetesController(KubernetesController): | ||||||
|     """LDAP Provider Kubernetes Contoller""" |     """LDAP Provider Kubernetes Controller""" | ||||||
|  |  | ||||||
|     def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): |     def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): | ||||||
|         super().__init__(outpost, connection) |         super().__init__(outpost, connection) | ||||||
|  | |||||||
| @ -153,7 +153,7 @@ def protected_resource_view(scopes: list[str]): | |||||||
|  |  | ||||||
|                 if not set(scopes).issubset(set(token.scope)): |                 if not set(scopes).issubset(set(token.scope)): | ||||||
|                     LOGGER.warning( |                     LOGGER.warning( | ||||||
|                         "Scope missmatch.", |                         "Scope mismatch.", | ||||||
|                         required=set(scopes), |                         required=set(scopes), | ||||||
|                         token_has=set(token.scope), |                         token_has=set(token.scope), | ||||||
|                     ) |                     ) | ||||||
|  | |||||||
| @ -33,7 +33,7 @@ class UserInfoView(View): | |||||||
|         for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by("scope_name"): |         for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by("scope_name"): | ||||||
|             if scope.description != "": |             if scope.description != "": | ||||||
|                 scope_descriptions.append({"id": scope.scope_name, "name": scope.description}) |                 scope_descriptions.append({"id": scope.scope_name, "name": scope.description}) | ||||||
|         # GitHub Compatibility Scopes are handeled differently, since they required custom paths |         # GitHub Compatibility Scopes are handled differently, since they required custom paths | ||||||
|         # Hence they don't exist as Scope objects |         # Hence they don't exist as Scope objects | ||||||
|         github_scope_map = { |         github_scope_map = { | ||||||
|             SCOPE_GITHUB_USER: ("GitHub Compatibility: Access your User Information"), |             SCOPE_GITHUB_USER: ("GitHub Compatibility: Access your User Information"), | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """ProxyProvider API Views""" | """ProxyProvider API Views""" | ||||||
| from typing import Any | from typing import Any, Optional | ||||||
|  |  | ||||||
| from drf_spectacular.utils import extend_schema_field | from drf_spectacular.utils import extend_schema_field | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| @ -10,6 +10,7 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | |||||||
| 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 PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
|  | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.providers.oauth2.views.provider import ProviderInfoView | from authentik.providers.oauth2.views.provider import ProviderInfoView | ||||||
| from authentik.providers.proxy.models import ProxyMode, ProxyProvider | from authentik.providers.proxy.models import ProxyMode, ProxyProvider | ||||||
|  |  | ||||||
| @ -106,6 +107,16 @@ class ProxyOutpostConfigSerializer(ModelSerializer): | |||||||
|     """Proxy provider serializer for outposts""" |     """Proxy provider serializer for outposts""" | ||||||
|  |  | ||||||
|     oidc_configuration = SerializerMethodField() |     oidc_configuration = SerializerMethodField() | ||||||
|  |     token_validity = SerializerMethodField() | ||||||
|  |  | ||||||
|  |     @extend_schema_field(OpenIDConnectConfigurationSerializer) | ||||||
|  |     def get_oidc_configuration(self, obj: ProxyProvider): | ||||||
|  |         """Embed OpenID Connect provider information""" | ||||||
|  |         return ProviderInfoView(request=self.context["request"]._request).get_info(obj) | ||||||
|  |  | ||||||
|  |     def get_token_validity(self, obj: ProxyProvider) -> Optional[float]: | ||||||
|  |         """Get token validity as second count""" | ||||||
|  |         return timedelta_from_string(obj.token_validity).total_seconds() | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -127,13 +138,9 @@ class ProxyOutpostConfigSerializer(ModelSerializer): | |||||||
|             "basic_auth_user_attribute", |             "basic_auth_user_attribute", | ||||||
|             "mode", |             "mode", | ||||||
|             "cookie_domain", |             "cookie_domain", | ||||||
|  |             "token_validity", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     @extend_schema_field(OpenIDConnectConfigurationSerializer) |  | ||||||
|     def get_oidc_configuration(self, obj: ProxyProvider): |  | ||||||
|         """Embed OpenID Connect provider information""" |  | ||||||
|         return ProviderInfoView(request=self.context["request"]._request).get_info(obj) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ProxyOutpostConfigViewSet(ReadOnlyModelViewSet): | class ProxyOutpostConfigViewSet(ReadOnlyModelViewSet): | ||||||
|     """ProxyProvider Viewset""" |     """ProxyProvider Viewset""" | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| """Proxy Provider Docker Contoller""" | """Proxy Provider Docker Controller""" | ||||||
| from urllib.parse import urlparse | from urllib.parse import urlparse | ||||||
|  |  | ||||||
| from authentik.outposts.controllers.base import DeploymentPort | from authentik.outposts.controllers.base import DeploymentPort | ||||||
| @ -8,13 +8,12 @@ from authentik.providers.proxy.models import ProxyProvider | |||||||
|  |  | ||||||
|  |  | ||||||
| class ProxyDockerController(DockerController): | class ProxyDockerController(DockerController): | ||||||
|     """Proxy Provider Docker Contoller""" |     """Proxy Provider Docker Controller""" | ||||||
|  |  | ||||||
|     def __init__(self, outpost: Outpost, connection: DockerServiceConnection): |     def __init__(self, outpost: Outpost, connection: DockerServiceConnection): | ||||||
|         super().__init__(outpost, connection) |         super().__init__(outpost, connection) | ||||||
|         self.deployment_ports = [ |         self.deployment_ports = [ | ||||||
|             DeploymentPort(9000, "http", "tcp"), |             DeploymentPort(9000, "http", "tcp"), | ||||||
|             DeploymentPort(9300, "http-metrics", "tcp"), |  | ||||||
|             DeploymentPort(9443, "https", "tcp"), |             DeploymentPort(9443, "https", "tcp"), | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
| @ -30,6 +29,11 @@ class ProxyDockerController(DockerController): | |||||||
|         labels[f"traefik.http.routers.{traefik_name}-router.rule"] = f"Host({','.join(hosts)})" |         labels[f"traefik.http.routers.{traefik_name}-router.rule"] = f"Host({','.join(hosts)})" | ||||||
|         labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true" |         labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true" | ||||||
|         labels[f"traefik.http.routers.{traefik_name}-router.service"] = f"{traefik_name}-service" |         labels[f"traefik.http.routers.{traefik_name}-router.service"] = f"{traefik_name}-service" | ||||||
|         labels[f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path"] = "/" |         labels[ | ||||||
|  |             f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path" | ||||||
|  |         ] = "/akprox/ping" | ||||||
|  |         labels[ | ||||||
|  |             f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.port" | ||||||
|  |         ] = "9300" | ||||||
|         labels[f"traefik.http.services.{traefik_name}-service.loadbalancer.server.port"] = "9000" |         labels[f"traefik.http.services.{traefik_name}-service.loadbalancer.server.port"] = "9000" | ||||||
|         return labels |         return labels | ||||||
|  | |||||||
| @ -14,11 +14,8 @@ from kubernetes.client import ( | |||||||
| from kubernetes.client.models.networking_v1beta1_ingress_rule import NetworkingV1beta1IngressRule | from kubernetes.client.models.networking_v1beta1_ingress_rule import NetworkingV1beta1IngressRule | ||||||
|  |  | ||||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import ( | from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||||
|     KubernetesObjectReconciler, | from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpdate | ||||||
|     NeedsRecreate, |  | ||||||
|     NeedsUpdate, |  | ||||||
| ) |  | ||||||
| from authentik.providers.proxy.models import ProxyMode, ProxyProvider | from authentik.providers.proxy.models import ProxyMode, ProxyProvider | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
| @ -63,8 +60,15 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): | |||||||
|         have_hosts_tls = [] |         have_hosts_tls = [] | ||||||
|         if current.spec.tls: |         if current.spec.tls: | ||||||
|             for tls_config in current.spec.tls: |             for tls_config in current.spec.tls: | ||||||
|                 if tls_config and tls_config.hosts: |                 if not tls_config: | ||||||
|  |                     continue | ||||||
|  |                 if tls_config.hosts: | ||||||
|                     have_hosts_tls += tls_config.hosts |                     have_hosts_tls += tls_config.hosts | ||||||
|  |                 if ( | ||||||
|  |                     tls_config.secret_name | ||||||
|  |                     != self.controller.outpost.config.kubernetes_ingress_secret_name | ||||||
|  |                 ): | ||||||
|  |                     raise NeedsUpdate() | ||||||
|         have_hosts_tls.sort() |         have_hosts_tls.sort() | ||||||
|  |  | ||||||
|         if have_hosts != expected_hosts: |         if have_hosts != expected_hosts: | ||||||
|  | |||||||
| @ -6,7 +6,8 @@ from dacite import from_dict | |||||||
| from kubernetes.client import ApiextensionsV1Api, CustomObjectsApi | from kubernetes.client import ApiextensionsV1Api, CustomObjectsApi | ||||||
|  |  | ||||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler, NeedsUpdate | from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||||
|  | from authentik.outposts.controllers.k8s.triggers import NeedsUpdate | ||||||
| from authentik.providers.proxy.models import ProxyMode, ProxyProvider | from authentik.providers.proxy.models import ProxyMode, ProxyProvider | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
| @ -109,11 +110,18 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware]) | |||||||
|                     address=f"http://{self.name}.{self.namespace}:9000/akprox/auth/traefik", |                     address=f"http://{self.name}.{self.namespace}:9000/akprox/auth/traefik", | ||||||
|                     authResponseHeaders=[ |                     authResponseHeaders=[ | ||||||
|                         "Set-Cookie", |                         "Set-Cookie", | ||||||
|  |                         # Legacy headers, remove after 2022.1 | ||||||
|                         "X-Auth-Username", |                         "X-Auth-Username", | ||||||
|                         "X-Auth-Groups", |                         "X-Auth-Groups", | ||||||
|                         "X-Forwarded-Email", |                         "X-Forwarded-Email", | ||||||
|                         "X-Forwarded-Preferred-Username", |                         "X-Forwarded-Preferred-Username", | ||||||
|                         "X-Forwarded-User", |                         "X-Forwarded-User", | ||||||
|  |                         # New headers, unique prefix | ||||||
|  |                         "X-authentik-username", | ||||||
|  |                         "X-authentik-groups", | ||||||
|  |                         "X-authentik-email", | ||||||
|  |                         "X-authentik-name", | ||||||
|  |                         "X-authentik-uid", | ||||||
|                     ], |                     ], | ||||||
|                     trustForwardHeader=True, |                     trustForwardHeader=True, | ||||||
|                 ) |                 ) | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| """Proxy Provider Kubernetes Contoller""" | """Proxy Provider Kubernetes Controller""" | ||||||
| from authentik.outposts.controllers.base import DeploymentPort | from authentik.outposts.controllers.base import DeploymentPort | ||||||
| from authentik.outposts.controllers.kubernetes import KubernetesController | from authentik.outposts.controllers.kubernetes import KubernetesController | ||||||
| from authentik.outposts.models import KubernetesServiceConnection, Outpost | from authentik.outposts.models import KubernetesServiceConnection, Outpost | ||||||
| @ -7,7 +7,7 @@ from authentik.providers.proxy.controllers.k8s.traefik import TraefikMiddlewareR | |||||||
|  |  | ||||||
|  |  | ||||||
| class ProxyKubernetesController(KubernetesController): | class ProxyKubernetesController(KubernetesController): | ||||||
|     """Proxy Provider Kubernetes Contoller""" |     """Proxy Provider Kubernetes Controller""" | ||||||
|  |  | ||||||
|     def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): |     def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): | ||||||
|         super().__init__(outpost, connection) |         super().__init__(outpost, connection) | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ from getpass import getuser | |||||||
|  |  | ||||||
| from django.core.management.base import BaseCommand | from django.core.management.base import BaseCommand | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  | from django.utils.text import slugify | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| @ -42,7 +43,7 @@ class Command(BaseCommand): | |||||||
|             user=user, |             user=user, | ||||||
|             intent=TokenIntents.INTENT_RECOVERY, |             intent=TokenIntents.INTENT_RECOVERY, | ||||||
|             description=f"Recovery Token generated by {getuser()} on {_now}", |             description=f"Recovery Token generated by {getuser()} on {_now}", | ||||||
|             identifier=f"ak-recovery-{user}-{_now}", |             identifier=slugify(f"ak-recovery-{user}-{_now}"), | ||||||
|         ) |         ) | ||||||
|         self.stdout.write( |         self.stdout.write( | ||||||
|             (f"Store this link safely, as it will allow" f" anyone to access authentik as {user}.") |             (f"Store this link safely, as it will allow" f" anyone to access authentik as {user}.") | ||||||
|  | |||||||
| @ -3,10 +3,20 @@ import os | |||||||
| from logging.config import dictConfig | from logging.config import dictConfig | ||||||
|  |  | ||||||
| from celery import Celery | from celery import Celery | ||||||
| from celery.signals import after_task_publish, setup_logging, task_postrun, task_prerun | from celery.signals import ( | ||||||
|  |     after_task_publish, | ||||||
|  |     setup_logging, | ||||||
|  |     task_failure, | ||||||
|  |     task_internal_error, | ||||||
|  |     task_postrun, | ||||||
|  |     task_prerun, | ||||||
|  | ) | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.lib.sentry import before_send | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
| # set the default Django settings module for the 'celery' program. | # set the default Django settings module for the 'celery' program. | ||||||
| os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings") | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings") | ||||||
|  |  | ||||||
| @ -43,6 +53,18 @@ def task_postrun_hook(task_id, task, *args, retval=None, state=None, **kwargs): | |||||||
|     LOGGER.debug("Task finished", task_id=task_id, task_name=task.__name__, state=state) |     LOGGER.debug("Task finished", task_id=task_id, task_name=task.__name__, state=state) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # pylint: disable=unused-argument | ||||||
|  | @task_failure.connect | ||||||
|  | @task_internal_error.connect | ||||||
|  | def task_error_hook(task_id, exception: Exception, traceback, *args, **kwargs): | ||||||
|  |     """Create system event for failed task""" | ||||||
|  |     from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  |     LOGGER.warning("Task failure", exception=exception) | ||||||
|  |     if before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||||
|  |         Event.new(EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception)).save() | ||||||
|  |  | ||||||
|  |  | ||||||
| # Using a string here means the worker doesn't have to serialize | # Using a string here means the worker doesn't have to serialize | ||||||
| # the configuration object to child processes. | # the configuration object to child processes. | ||||||
| # - namespace='CELERY' means all celery-related configuration keys | # - namespace='CELERY' means all celery-related configuration keys | ||||||
|  | |||||||
| @ -59,14 +59,14 @@ class MetricsView(View): | |||||||
|  |  | ||||||
|  |  | ||||||
| class LiveView(View): | class LiveView(View): | ||||||
|     """View for liveness probe, always returns Http 201""" |     """View for liveness probe, always returns Http 204""" | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: |     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||||
|         return HttpResponse(status=201) |         return HttpResponse(status=204) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReadyView(View): | class ReadyView(View): | ||||||
|     """View for readiness probe, always returns Http 201, unless sql or redis is down""" |     """View for readiness probe, always returns Http 204, unless sql or redis is down""" | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: |     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||||
|         try: |         try: | ||||||
| @ -79,4 +79,4 @@ class ReadyView(View): | |||||||
|             redis_conn.ping() |             redis_conn.ping() | ||||||
|         except RedisError: |         except RedisError: | ||||||
|             return HttpResponse(status=503) |             return HttpResponse(status=503) | ||||||
|         return HttpResponse(status=201) |         return HttpResponse(status=204) | ||||||
|  | |||||||
| @ -529,7 +529,7 @@ for _app in INSTALLED_APPS: | |||||||
|         if "apps" in _app: |         if "apps" in _app: | ||||||
|             _app = ".".join(_app.split(".")[:-2]) |             _app = ".".join(_app.split(".")[:-2]) | ||||||
|         try: |         try: | ||||||
|             app_settings = importlib.import_module("%s.settings" % _app) |             app_settings = importlib.import_module(f"{_app}.settings") | ||||||
|             INSTALLED_APPS.extend(getattr(app_settings, "INSTALLED_APPS", [])) |             INSTALLED_APPS.extend(getattr(app_settings, "INSTALLED_APPS", [])) | ||||||
|             MIDDLEWARE.extend(getattr(app_settings, "MIDDLEWARE", [])) |             MIDDLEWARE.extend(getattr(app_settings, "MIDDLEWARE", [])) | ||||||
|             AUTHENTICATION_BACKENDS.extend(getattr(app_settings, "AUTHENTICATION_BACKENDS", [])) |             AUTHENTICATION_BACKENDS.extend(getattr(app_settings, "AUTHENTICATION_BACKENDS", [])) | ||||||
|  | |||||||
| @ -1,12 +1,11 @@ | |||||||
| """Source API Views""" | """Source API Views""" | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.http.response import Http404 |  | ||||||
| from django.utils.text import slugify | from django.utils.text import slugify | ||||||
| from django_filters.filters import AllValuesMultipleFilter | from django_filters.filters import AllValuesMultipleFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field | from drf_spectacular.utils import extend_schema, extend_schema_field | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -19,6 +18,9 @@ from authentik.core.api.sources import SourceSerializer | |||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.events.monitored_tasks import TaskInfo | from authentik.events.monitored_tasks import TaskInfo | ||||||
| from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource | from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource | ||||||
|  | from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer | ||||||
|  | from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer | ||||||
|  | from authentik.sources.ldap.sync.users import UserLDAPSynchronizer | ||||||
|  |  | ||||||
|  |  | ||||||
| class LDAPSourceSerializer(SourceSerializer): | class LDAPSourceSerializer(SourceSerializer): | ||||||
| @ -95,19 +97,24 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: TaskSerializer(many=False), |             200: TaskSerializer(many=True), | ||||||
|             404: OpenApiResponse(description="Task not found"), |  | ||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
|     @action(methods=["GET"], detail=True) |     @action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def sync_status(self, request: Request, slug: str) -> Response: |     def sync_status(self, request: Request, slug: str) -> Response: | ||||||
|         """Get source's sync status""" |         """Get source's sync status""" | ||||||
|         source = self.get_object() |         source = self.get_object() | ||||||
|         task = TaskInfo.by_name(f"ldap_sync_{slugify(source.name)}") |         results = [] | ||||||
|         if not task: |         for sync_class in [ | ||||||
|             raise Http404 |             UserLDAPSynchronizer, | ||||||
|         return Response(TaskSerializer(task, many=False).data) |             GroupLDAPSynchronizer, | ||||||
|  |             MembershipLDAPSynchronizer, | ||||||
|  |         ]: | ||||||
|  |             task = TaskInfo.by_name(f"ldap_sync_{slugify(source.name)}-{sync_class.__name__}") | ||||||
|  |             if task: | ||||||
|  |                 results.append(task) | ||||||
|  |         return Response(TaskSerializer(results, many=True).data) | ||||||
|  |  | ||||||
|  |  | ||||||
| class LDAPPropertyMappingSerializer(PropertyMappingSerializer): | class LDAPPropertyMappingSerializer(PropertyMappingSerializer): | ||||||
|  | |||||||
| @ -119,7 +119,7 @@ class LDAPPasswordChanger: | |||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     def ad_password_complexity(self, password: str, user: Optional[User] = None) -> bool: |     def ad_password_complexity(self, password: str, user: Optional[User] = None) -> bool: | ||||||
|         """Check if password matches Active direcotry password policies |         """Check if password matches Active directory password policies | ||||||
|  |  | ||||||
|         https://docs.microsoft.com/en-us/windows/security/threat-protection/ |         https://docs.microsoft.com/en-us/windows/security/threat-protection/ | ||||||
|             security-policy-settings/password-must-meet-complexity-requirements |             security-policy-settings/password-must-meet-complexity-requirements | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from celery.schedules import crontab | |||||||
| CELERY_BEAT_SCHEDULE = { | CELERY_BEAT_SCHEDULE = { | ||||||
|     "sources_ldap_sync": { |     "sources_ldap_sync": { | ||||||
|         "task": "authentik.sources.ldap.tasks.ldap_sync_all", |         "task": "authentik.sources.ldap.tasks.ldap_sync_all", | ||||||
|         "schedule": crontab(minute="*/60"),  # Run every hour |         "schedule": crontab(minute="*/120"),  # Run every other hour | ||||||
|         "options": {"queue": "authentik_scheduled"}, |         "options": {"queue": "authentik_scheduled"}, | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -11,8 +11,12 @@ from authentik.core.models import User | |||||||
| from authentik.core.signals import password_changed | from authentik.core.signals import password_changed | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
|  | from authentik.lib.utils.reflection import class_to_path | ||||||
| from authentik.sources.ldap.models import LDAPSource | from authentik.sources.ldap.models import LDAPSource | ||||||
| from authentik.sources.ldap.password import LDAPPasswordChanger | from authentik.sources.ldap.password import LDAPPasswordChanger | ||||||
|  | from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer | ||||||
|  | from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer | ||||||
|  | from authentik.sources.ldap.sync.users import UserLDAPSynchronizer | ||||||
| from authentik.sources.ldap.tasks import ldap_sync | from authentik.sources.ldap.tasks import ldap_sync | ||||||
| from authentik.stages.prompt.signals import password_validate | from authentik.stages.prompt.signals import password_validate | ||||||
|  |  | ||||||
| @ -21,8 +25,20 @@ from authentik.stages.prompt.signals import password_validate | |||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def sync_ldap_source_on_save(sender, instance: LDAPSource, **_): | def sync_ldap_source_on_save(sender, instance: LDAPSource, **_): | ||||||
|     """Ensure that source is synced on save (if enabled)""" |     """Ensure that source is synced on save (if enabled)""" | ||||||
|     if instance.enabled: |     if not instance.enabled: | ||||||
|         ldap_sync.delay(instance.pk) |         return | ||||||
|  |     # Don't sync sources when they don't have any property mappings. This will only happen if: | ||||||
|  |     # - the user forgets to set them or | ||||||
|  |     # - the source is newly created, this is the first save event | ||||||
|  |     #   and the mappings are created with an m2m event | ||||||
|  |     if not instance.property_mappings.exists() or not instance.property_mappings_group.exists(): | ||||||
|  |         return | ||||||
|  |     for sync_class in [ | ||||||
|  |         UserLDAPSynchronizer, | ||||||
|  |         GroupLDAPSynchronizer, | ||||||
|  |         MembershipLDAPSynchronizer, | ||||||
|  |     ]: | ||||||
|  |         ldap_sync.delay(instance.pk, class_to_path(sync_class)) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(password_validate) | @receiver(password_validate) | ||||||
| @ -39,7 +55,7 @@ def ldap_password_validate(sender, password: str, plan_context: dict[str, Any], | |||||||
|             password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None) |             password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None) | ||||||
|         ) |         ) | ||||||
|         if not passing: |         if not passing: | ||||||
|             raise ValidationError(_("Password does not match Active Direcory Complexity.")) |             raise ValidationError(_("Password does not match Active Directory Complexity.")) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(password_changed) | @receiver(password_changed) | ||||||
|  | |||||||
| @ -17,11 +17,18 @@ class BaseLDAPSynchronizer: | |||||||
|  |  | ||||||
|     _source: LDAPSource |     _source: LDAPSource | ||||||
|     _logger: BoundLogger |     _logger: BoundLogger | ||||||
|  |     _messages: list[str] | ||||||
|  |  | ||||||
|     def __init__(self, source: LDAPSource): |     def __init__(self, source: LDAPSource): | ||||||
|         self._source = source |         self._source = source | ||||||
|  |         self._messages = [] | ||||||
|         self._logger = get_logger().bind(source=source, syncer=self.__class__.__name__) |         self._logger = get_logger().bind(source=source, syncer=self.__class__.__name__) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def messages(self) -> list[str]: | ||||||
|  |         """Get all UI messages""" | ||||||
|  |         return self._messages | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def base_dn_users(self) -> str: |     def base_dn_users(self) -> str: | ||||||
|         """Shortcut to get full base_dn for user lookups""" |         """Shortcut to get full base_dn for user lookups""" | ||||||
| @ -36,6 +43,11 @@ class BaseLDAPSynchronizer: | |||||||
|             return f"{self._source.additional_group_dn},{self._source.base_dn}" |             return f"{self._source.additional_group_dn},{self._source.base_dn}" | ||||||
|         return self._source.base_dn |         return self._source.base_dn | ||||||
|  |  | ||||||
|  |     def message(self, *args, **kwargs): | ||||||
|  |         """Add message that is later added to the System Task and shown to the user""" | ||||||
|  |         self._messages.append(" ".join(args)) | ||||||
|  |         self._logger.warning(*args, **kwargs) | ||||||
|  |  | ||||||
|     def sync(self) -> int: |     def sync(self) -> int: | ||||||
|         """Sync function, implemented in subclass""" |         """Sync function, implemented in subclass""" | ||||||
|         raise NotImplementedError() |         raise NotImplementedError() | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|     def sync(self) -> int: |     def sync(self) -> int: | ||||||
|         """Iterate over all LDAP Groups and create authentik_core.Group instances""" |         """Iterate over all LDAP Groups and create authentik_core.Group instances""" | ||||||
|         if not self._source.sync_groups: |         if not self._source.sync_groups: | ||||||
|             self._logger.warning("Group syncing is disabled for this Source") |             self.message("Group syncing is disabled for this Source") | ||||||
|             return -1 |             return -1 | ||||||
|         groups = self._source.connection.extend.standard.paged_search( |         groups = self._source.connection.extend.standard.paged_search( | ||||||
|             search_base=self.base_dn_groups, |             search_base=self.base_dn_groups, | ||||||
| @ -28,8 +28,8 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|             attributes = group.get("attributes", {}) |             attributes = group.get("attributes", {}) | ||||||
|             group_dn = self._flatten(self._flatten(group.get("entryDN", group.get("dn")))) |             group_dn = self._flatten(self._flatten(group.get("entryDN", group.get("dn")))) | ||||||
|             if self._source.object_uniqueness_field not in attributes: |             if self._source.object_uniqueness_field not in attributes: | ||||||
|                 self._logger.warning( |                 self.message( | ||||||
|                     "Cannot find uniqueness Field in attributes", |                     f"Cannot find uniqueness field in attributes: '{group_dn}", | ||||||
|                     attributes=attributes.keys(), |                     attributes=attributes.keys(), | ||||||
|                     dn=group_dn, |                     dn=group_dn, | ||||||
|                 ) |                 ) | ||||||
|  | |||||||
| @ -39,11 +39,17 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|             if not ak_group: |             if not ak_group: | ||||||
|                 continue |                 continue | ||||||
|  |  | ||||||
|  |             membership_mapping_attribute = LDAP_DISTINGUISHED_NAME | ||||||
|  |             if self._source.group_membership_field == "memberUid": | ||||||
|  |                 # If memberships are based on the posixGroup's 'memberUid' | ||||||
|  |                 # attribute we use the RDN instead of the FDN to lookup members. | ||||||
|  |                 membership_mapping_attribute = LDAP_UNIQUENESS | ||||||
|  |  | ||||||
|             users = User.objects.filter( |             users = User.objects.filter( | ||||||
|                 Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members}) |                 Q(**{f"attributes__{membership_mapping_attribute}__in": members}) | ||||||
|                 | Q( |                 | Q( | ||||||
|                     **{ |                     **{ | ||||||
|                         f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True, |                         f"attributes__{membership_mapping_attribute}__isnull": True, | ||||||
|                         "ak_groups__in": [ak_group], |                         "ak_groups__in": [ak_group], | ||||||
|                     } |                     } | ||||||
|                 ) |                 ) | ||||||
| @ -62,8 +68,8 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|         # group_uniq might be a single string or an array with (hopefully) a single string |         # group_uniq might be a single string or an array with (hopefully) a single string | ||||||
|         if isinstance(group_uniq, list): |         if isinstance(group_uniq, list): | ||||||
|             if len(group_uniq) < 1: |             if len(group_uniq) < 1: | ||||||
|                 self._logger.warning( |                 self.message( | ||||||
|                     "Group does not have a uniqueness attribute.", |                     f"Group does not have a uniqueness attribute: '{group_dn}'", | ||||||
|                     group=group_dn, |                     group=group_dn, | ||||||
|                 ) |                 ) | ||||||
|                 return None |                 return None | ||||||
| @ -71,8 +77,8 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|         if group_uniq not in self.group_cache: |         if group_uniq not in self.group_cache: | ||||||
|             groups = Group.objects.filter(**{f"attributes__{LDAP_UNIQUENESS}": group_uniq}) |             groups = Group.objects.filter(**{f"attributes__{LDAP_UNIQUENESS}": group_uniq}) | ||||||
|             if not groups.exists(): |             if not groups.exists(): | ||||||
|                 self._logger.warning( |                 self.message( | ||||||
|                     "Group does not exist in our DB yet, run sync_groups first.", |                     f"Group does not exist in our DB yet, run sync_groups first: '{group_dn}'", | ||||||
|                     group=group_dn, |                     group=group_dn, | ||||||
|                 ) |                 ) | ||||||
|                 return None |                 return None | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ from pytz import UTC | |||||||
| 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.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer | from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer | ||||||
|  | from authentik.sources.ldap.sync.vendor.ad import UserAccountControl | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserLDAPSynchronizer(BaseLDAPSynchronizer): | class UserLDAPSynchronizer(BaseLDAPSynchronizer): | ||||||
| @ -18,7 +19,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|     def sync(self) -> int: |     def sync(self) -> int: | ||||||
|         """Iterate over all LDAP Users and create authentik_core.User instances""" |         """Iterate over all LDAP Users and create authentik_core.User instances""" | ||||||
|         if not self._source.sync_users: |         if not self._source.sync_users: | ||||||
|             self._logger.warning("User syncing is disabled for this Source") |             self.message("User syncing is disabled for this Source") | ||||||
|             return -1 |             return -1 | ||||||
|         users = self._source.connection.extend.standard.paged_search( |         users = self._source.connection.extend.standard.paged_search( | ||||||
|             search_base=self.base_dn_users, |             search_base=self.base_dn_users, | ||||||
| @ -31,8 +32,8 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|             attributes = user.get("attributes", {}) |             attributes = user.get("attributes", {}) | ||||||
|             user_dn = self._flatten(user.get("entryDN", user.get("dn"))) |             user_dn = self._flatten(user.get("entryDN", user.get("dn"))) | ||||||
|             if self._source.object_uniqueness_field not in attributes: |             if self._source.object_uniqueness_field not in attributes: | ||||||
|                 self._logger.warning( |                 self.message( | ||||||
|                     "Cannot find uniqueness Field in attributes", |                     f"Cannot find uniqueness field in attributes: '{user_dn}", | ||||||
|                     attributes=attributes.keys(), |                     attributes=attributes.keys(), | ||||||
|                     dn=user_dn, |                     dn=user_dn, | ||||||
|                 ) |                 ) | ||||||
| @ -66,6 +67,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|                 pwd_last_set: datetime = attributes.get("pwdLastSet", datetime.now()) |                 pwd_last_set: datetime = attributes.get("pwdLastSet", datetime.now()) | ||||||
|                 pwd_last_set = pwd_last_set.replace(tzinfo=UTC) |                 pwd_last_set = pwd_last_set.replace(tzinfo=UTC) | ||||||
|                 if created or pwd_last_set >= ak_user.password_change_date: |                 if created or pwd_last_set >= ak_user.password_change_date: | ||||||
|  |                     self.message(f"'{ak_user.username}': Reset user's password") | ||||||
|                     self._logger.debug( |                     self._logger.debug( | ||||||
|                         "Reset user's password", |                         "Reset user's password", | ||||||
|                         user=ak_user.username, |                         user=ak_user.username, | ||||||
| @ -74,4 +76,8 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|                     ) |                     ) | ||||||
|                     ak_user.set_unusable_password() |                     ak_user.set_unusable_password() | ||||||
|                     ak_user.save() |                     ak_user.save() | ||||||
|  |                 if "userAccountControl" in attributes: | ||||||
|  |                     uac = UserAccountControl(attributes.get("userAccountControl")) | ||||||
|  |                     ak_user.is_active = UserAccountControl.ACCOUNTDISABLE not in uac | ||||||
|  |                     ak_user.save() | ||||||
|         return user_count |         return user_count | ||||||
|  | |||||||
							
								
								
									
										0
									
								
								authentik/sources/ldap/sync/vendor/__init__.py
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/ldap/sync/vendor/__init__.py
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
								
								
									
										32
									
								
								authentik/sources/ldap/sync/vendor/ad.py
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								authentik/sources/ldap/sync/vendor/ad.py
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | """Active Directory specific""" | ||||||
|  | from enum import IntFlag | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class UserAccountControl(IntFlag): | ||||||
|  |     """UserAccountControl attribute for Active directory users""" | ||||||
|  |  | ||||||
|  |     # https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity | ||||||
|  |     #   /useraccountcontrol-manipulate-account-properties | ||||||
|  |  | ||||||
|  |     SCRIPT = 1 | ||||||
|  |     ACCOUNTDISABLE = 2 | ||||||
|  |     HOMEDIR_REQUIRED = 8 | ||||||
|  |     LOCKOUT = 16 | ||||||
|  |     PASSWD_NOTREQD = 32 | ||||||
|  |     PASSWD_CANT_CHANGE = 64 | ||||||
|  |     ENCRYPTED_TEXT_PWD_ALLOWED = 128 | ||||||
|  |     TEMP_DUPLICATE_ACCOUNT = 256 | ||||||
|  |     NORMAL_ACCOUNT = 512 | ||||||
|  |     INTERDOMAIN_TRUST_ACCOUNT = 2048 | ||||||
|  |     WORKSTATION_TRUST_ACCOUNT = 4096 | ||||||
|  |     SERVER_TRUST_ACCOUNT = 8192 | ||||||
|  |     DONT_EXPIRE_PASSWORD = 65536 | ||||||
|  |     MNS_LOGON_ACCOUNT = 131072 | ||||||
|  |     SMARTCARD_REQUIRED = 262144 | ||||||
|  |     TRUSTED_FOR_DELEGATION = 524288 | ||||||
|  |     NOT_DELEGATED = 1048576 | ||||||
|  |     USE_DES_KEY_ONLY = 2097152 | ||||||
|  |     DONT_REQ_PREAUTH = 4194304 | ||||||
|  |     PASSWORD_EXPIRED = 8388608 | ||||||
|  |     TRUSTED_TO_AUTH_FOR_DELEGATION = 16777216 | ||||||
|  |     PARTIAL_SECRETS_ACCOUNT = 67108864 | ||||||
| @ -1,9 +1,12 @@ | |||||||
| """LDAP Sync tasks""" | """LDAP Sync tasks""" | ||||||
|  | from typing import Optional | ||||||
|  |  | ||||||
| from django.utils.text import slugify | from django.utils.text import slugify | ||||||
| from ldap3.core.exceptions import LDAPException | from ldap3.core.exceptions import LDAPException | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||||
|  | from authentik.lib.utils.reflection import class_to_path, path_to_class | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| from authentik.sources.ldap.models import LDAPSource | from authentik.sources.ldap.models import LDAPSource | ||||||
| from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer | from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer | ||||||
| @ -17,11 +20,19 @@ LOGGER = get_logger() | |||||||
| def ldap_sync_all(): | def ldap_sync_all(): | ||||||
|     """Sync all sources""" |     """Sync all sources""" | ||||||
|     for source in LDAPSource.objects.filter(enabled=True): |     for source in LDAPSource.objects.filter(enabled=True): | ||||||
|         ldap_sync.delay(source.pk) |         for sync_class in [ | ||||||
|  |             UserLDAPSynchronizer, | ||||||
|  |             GroupLDAPSynchronizer, | ||||||
|  |             MembershipLDAPSynchronizer, | ||||||
|  |         ]: | ||||||
|  |             ldap_sync.delay(source.pk, class_to_path(sync_class)) | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) | @CELERY_APP.task( | ||||||
| def ldap_sync(self: MonitoredTask, source_pk: str): |     bind=True, base=MonitoredTask, soft_time_limit=60 * 60 * 2, task_time_limit=60 * 60 * 2 | ||||||
|  | ) | ||||||
|  | # TODO: remove Optional[str] in 2021.10 | ||||||
|  | def ldap_sync(self: MonitoredTask, source_pk: str, sync_class: Optional[str] = None): | ||||||
|     """Synchronization of an LDAP Source""" |     """Synchronization of an LDAP Source""" | ||||||
|     self.result_timeout_hours = 2 |     self.result_timeout_hours = 2 | ||||||
|     try: |     try: | ||||||
| @ -30,17 +41,15 @@ def ldap_sync(self: MonitoredTask, source_pk: str): | |||||||
|         # Because the source couldn't be found, we don't have a UID |         # Because the source couldn't be found, we don't have a UID | ||||||
|         # to set the state with |         # to set the state with | ||||||
|         return |         return | ||||||
|     self.set_uid(slugify(source.name)) |     if not sync_class: | ||||||
|  |         return | ||||||
|  |     sync = path_to_class(sync_class) | ||||||
|  |     self.set_uid(f"{slugify(source.name)}-{sync.__name__}") | ||||||
|     try: |     try: | ||||||
|         messages = [] |         sync_inst = sync(source) | ||||||
|         for sync_class in [ |         count = sync_inst.sync() | ||||||
|             UserLDAPSynchronizer, |         messages = sync_inst.messages | ||||||
|             GroupLDAPSynchronizer, |         messages.append(f"Synced {count} objects.") | ||||||
|             MembershipLDAPSynchronizer, |  | ||||||
|         ]: |  | ||||||
|             sync_inst = sync_class(source) |  | ||||||
|             count = sync_inst.sync() |  | ||||||
|             messages.append(f"Synced {count} objects from {sync_class.__name__}") |  | ||||||
|         self.set_status( |         self.set_status( | ||||||
|             TaskResult( |             TaskResult( | ||||||
|                 TaskResultStatus.SUCCESSFUL, |                 TaskResultStatus.SUCCESSFUL, | ||||||
|  | |||||||
| @ -2,6 +2,8 @@ | |||||||
|  |  | ||||||
| from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server | from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server | ||||||
|  |  | ||||||
|  | from authentik.sources.ldap.sync.vendor.ad import UserAccountControl | ||||||
|  |  | ||||||
|  |  | ||||||
| def mock_ad_connection(password: str) -> Connection: | def mock_ad_connection(password: str) -> Connection: | ||||||
|     """Create mock AD connection""" |     """Create mock AD connection""" | ||||||
| @ -54,6 +56,8 @@ def mock_ad_connection(password: str) -> Connection: | |||||||
|             "objectSid": "user0", |             "objectSid": "user0", | ||||||
|             "objectClass": "person", |             "objectClass": "person", | ||||||
|             "distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io", |             "distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io", | ||||||
|  |             "userAccountControl": UserAccountControl.ACCOUNTDISABLE | ||||||
|  |             + UserAccountControl.NORMAL_ACCOUNT, | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     # User without SID |     # User without SID | ||||||
|  | |||||||
| @ -77,5 +77,24 @@ def mock_slapd_connection(password: str) -> Connection: | |||||||
|             "objectClass": "person", |             "objectClass": "person", | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|  |     # Group with posixGroup and memberUid | ||||||
|  |     connection.strategy.add_entry( | ||||||
|  |         "cn=group-posix,ou=groups,dc=goauthentik,dc=io", | ||||||
|  |         { | ||||||
|  |             "cn": "group-posix", | ||||||
|  |             "objectClass": "posixGroup", | ||||||
|  |             "memberUid": ["user-posix"], | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     # User with posixAccount | ||||||
|  |     connection.strategy.add_entry( | ||||||
|  |         "cn=user-posix,ou=users,dc=goauthentik,dc=io", | ||||||
|  |         { | ||||||
|  |             "userPassword": password, | ||||||
|  |             "uid": "user-posix", | ||||||
|  |             "cn": "user-posix", | ||||||
|  |             "objectClass": "posixAccount", | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|     connection.bind() |     connection.bind() | ||||||
|     return connection |     return connection | ||||||
|  | |||||||
| @ -72,7 +72,8 @@ class LDAPSyncTests(TestCase): | |||||||
|         with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): |         with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): | ||||||
|             user_sync = UserLDAPSynchronizer(self.source) |             user_sync = UserLDAPSynchronizer(self.source) | ||||||
|             user_sync.sync() |             user_sync.sync() | ||||||
|             self.assertTrue(User.objects.filter(username="user0_sn").exists()) |             user = User.objects.filter(username="user0_sn").first() | ||||||
|  |             self.assertFalse(user.is_active) | ||||||
|             self.assertFalse(User.objects.filter(username="user1_sn").exists()) |             self.assertFalse(User.objects.filter(username="user1_sn").exists()) | ||||||
|  |  | ||||||
|     def test_sync_users_openldap(self): |     def test_sync_users_openldap(self): | ||||||
| @ -136,6 +137,34 @@ class LDAPSyncTests(TestCase): | |||||||
|             group = Group.objects.filter(name="group1") |             group = Group.objects.filter(name="group1") | ||||||
|             self.assertTrue(group.exists()) |             self.assertTrue(group.exists()) | ||||||
|  |  | ||||||
|  |     def test_sync_groups_openldap_posix_group(self): | ||||||
|  |         """Test posix group sync""" | ||||||
|  |         self.source.object_uniqueness_field = "cn" | ||||||
|  |         self.source.group_membership_field = "memberUid" | ||||||
|  |         self.source.user_object_filter = "(objectClass=posixAccount)" | ||||||
|  |         self.source.group_object_filter = "(objectClass=posixGroup)" | ||||||
|  |         self.source.property_mappings.set( | ||||||
|  |             LDAPPropertyMapping.objects.filter( | ||||||
|  |                 Q(managed__startswith="goauthentik.io/sources/ldap/default") | ||||||
|  |                 | Q(managed__startswith="goauthentik.io/sources/ldap/openldap") | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.source.property_mappings_group.set( | ||||||
|  |             LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn") | ||||||
|  |         ) | ||||||
|  |         self.source.save() | ||||||
|  |         connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) | ||||||
|  |         with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): | ||||||
|  |             user_sync = UserLDAPSynchronizer(self.source) | ||||||
|  |             user_sync.sync() | ||||||
|  |             group_sync = GroupLDAPSynchronizer(self.source) | ||||||
|  |             group_sync.sync() | ||||||
|  |             membership_sync = MembershipLDAPSynchronizer(self.source) | ||||||
|  |             membership_sync.sync() | ||||||
|  |             # Test if membership mapping based on memberUid works. | ||||||
|  |             posix_group = Group.objects.filter(name="group-posix").first() | ||||||
|  |             self.assertTrue(posix_group.users.filter(name="user-posix").exists()) | ||||||
|  |  | ||||||
|     def test_tasks_ad(self): |     def test_tasks_ad(self): | ||||||
|         """Test Scheduled tasks""" |         """Test Scheduled tasks""" | ||||||
|         self.source.property_mappings.set( |         self.source.property_mappings.set( | ||||||
|  | |||||||
| @ -46,9 +46,9 @@ class OAuthSourceSerializer(SourceSerializer): | |||||||
|     type = SerializerMethodField() |     type = SerializerMethodField() | ||||||
|  |  | ||||||
|     @extend_schema_field(SourceTypeSerializer) |     @extend_schema_field(SourceTypeSerializer) | ||||||
|     def get_type(self, instace: OAuthSource) -> SourceTypeSerializer: |     def get_type(self, instance: OAuthSource) -> SourceTypeSerializer: | ||||||
|         """Get source's type configuration""" |         """Get source's type configuration""" | ||||||
|         return SourceTypeSerializer(instace.type).data |         return SourceTypeSerializer(instance.type).data | ||||||
|  |  | ||||||
|     def validate(self, attrs: dict) -> dict: |     def validate(self, attrs: dict) -> dict: | ||||||
|         provider_type = MANAGER.find_type(attrs.get("provider_type", "")) |         provider_type = MANAGER.find_type(attrs.get("provider_type", "")) | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ class Migration(migrations.Migration): | |||||||
|             model_name="oauthsource", |             model_name="oauthsource", | ||||||
|             name="access_token_url", |             name="access_token_url", | ||||||
|             field=models.CharField( |             field=models.CharField( | ||||||
|                 help_text="URL used by authentik to retrive tokens.", |                 help_text="URL used by authentik to retrieve tokens.", | ||||||
|                 max_length=255, |                 max_length=255, | ||||||
|                 verbose_name="Access Token URL", |                 verbose_name="Access Token URL", | ||||||
|             ), |             ), | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ class Migration(migrations.Migration): | |||||||
|             name="access_token_url", |             name="access_token_url", | ||||||
|             field=models.CharField( |             field=models.CharField( | ||||||
|                 blank=True, |                 blank=True, | ||||||
|                 help_text="URL used by authentik to retrive tokens.", |                 help_text="URL used by authentik to retrieve tokens.", | ||||||
|                 max_length=255, |                 max_length=255, | ||||||
|                 verbose_name="Access Token URL", |                 verbose_name="Access Token URL", | ||||||
|             ), |             ), | ||||||
|  | |||||||
| @ -39,7 +39,7 @@ class Migration(migrations.Migration): | |||||||
|             model_name="oauthsource", |             model_name="oauthsource", | ||||||
|             name="access_token_url", |             name="access_token_url", | ||||||
|             field=models.CharField( |             field=models.CharField( | ||||||
|                 help_text="URL used by authentik to retrive tokens.", |                 help_text="URL used by authentik to retrieve tokens.", | ||||||
|                 max_length=255, |                 max_length=255, | ||||||
|                 null=True, |                 null=True, | ||||||
|                 verbose_name="Access Token URL", |                 verbose_name="Access Token URL", | ||||||
|  | |||||||
| @ -37,7 +37,7 @@ class OAuthSource(Source): | |||||||
|         max_length=255, |         max_length=255, | ||||||
|         null=True, |         null=True, | ||||||
|         verbose_name=_("Access Token URL"), |         verbose_name=_("Access Token URL"), | ||||||
|         help_text=_("URL used by authentik to retrive tokens."), |         help_text=_("URL used by authentik to retrieve tokens."), | ||||||
|     ) |     ) | ||||||
|     profile_url = models.CharField( |     profile_url = models.CharField( | ||||||
|         max_length=255, |         max_length=255, | ||||||
|  | |||||||
| @ -24,7 +24,7 @@ class Migration(migrations.Migration): | |||||||
|         migrations.AddField( |         migrations.AddField( | ||||||
|             model_name="plexsource", |             model_name="plexsource", | ||||||
|             name="plex_token", |             name="plex_token", | ||||||
|             field=models.TextField(default="", help_text="Plex token used to check firends"), |             field=models.TextField(default="", help_text="Plex token used to check friends"), | ||||||
|         ), |         ), | ||||||
|         migrations.AlterField( |         migrations.AlterField( | ||||||
|             model_name="plexsource", |             model_name="plexsource", | ||||||
|  | |||||||
| @ -13,6 +13,6 @@ class Migration(migrations.Migration): | |||||||
|         migrations.AlterField( |         migrations.AlterField( | ||||||
|             model_name="plexsource", |             model_name="plexsource", | ||||||
|             name="plex_token", |             name="plex_token", | ||||||
|             field=models.TextField(help_text="Plex token used to check firends"), |             field=models.TextField(help_text="Plex token used to check friends"), | ||||||
|         ), |         ), | ||||||
|     ] |     ] | ||||||
|  | |||||||
| @ -50,7 +50,7 @@ class PlexSource(Source): | |||||||
|         default=True, |         default=True, | ||||||
|         help_text=_("Allow friends to authenticate, even if you don't share a server."), |         help_text=_("Allow friends to authenticate, even if you don't share a server."), | ||||||
|     ) |     ) | ||||||
|     plex_token = models.TextField(help_text=_("Plex token used to check firends")) |     plex_token = models.TextField(help_text=_("Plex token used to check friends")) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def component(self) -> str: |     def component(self) -> str: | ||||||
|  | |||||||
| @ -54,7 +54,7 @@ class CaptchaChallengeResponse(ChallengeResponse): | |||||||
|  |  | ||||||
|  |  | ||||||
| class CaptchaStageView(ChallengeStageView): | class CaptchaStageView(ChallengeStageView): | ||||||
|     """Simple captcha checker, logic is handeled in django-captcha module""" |     """Simple captcha checker, logic is handled in django-captcha module""" | ||||||
|  |  | ||||||
|     response_class = CaptchaChallengeResponse |     response_class = CaptchaChallengeResponse | ||||||
|  |  | ||||||
|  | |||||||
| @ -43,6 +43,7 @@ class EmailStageSerializer(StageSerializer): | |||||||
|             "token_expiry", |             "token_expiry", | ||||||
|             "subject", |             "subject", | ||||||
|             "template", |             "template", | ||||||
|  |             "activate_user_on_success", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = {"password": {"write_only": True}} |         extra_kwargs = {"password": {"write_only": True}} | ||||||
|  |  | ||||||
| @ -65,6 +66,7 @@ class EmailStageViewSet(UsedByMixin, ModelViewSet): | |||||||
|         "token_expiry", |         "token_expiry", | ||||||
|         "subject", |         "subject", | ||||||
|         "template", |         "template", | ||||||
|  |         "activate_user_on_success", | ||||||
|     ] |     ] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|  | |||||||
| @ -0,0 +1,20 @@ | |||||||
|  | # Generated by Django 3.2.7 on 2021-10-04 16:38 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_stages_email", "0003_auto_20210404_1054"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="emailstage", | ||||||
|  |             name="activate_user_on_success", | ||||||
|  |             field=models.BooleanField( | ||||||
|  |                 default=False, help_text="Activate users upon completion of stage." | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -71,6 +71,10 @@ class EmailStage(Stage): | |||||||
|     timeout = models.IntegerField(default=10) |     timeout = models.IntegerField(default=10) | ||||||
|     from_address = models.EmailField(default="system@authentik.local") |     from_address = models.EmailField(default="system@authentik.local") | ||||||
|  |  | ||||||
|  |     activate_user_on_success = models.BooleanField( | ||||||
|  |         default=False, help_text=_("Activate users upon completion of stage.") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     token_expiry = models.IntegerField( |     token_expiry = models.IntegerField( | ||||||
|         default=30, help_text=_("Time in minutes the token sent is valid.") |         default=30, help_text=_("Time in minutes the token sent is valid.") | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -3,9 +3,9 @@ from datetime import timedelta | |||||||
|  |  | ||||||
| from django.contrib import messages | from django.contrib import messages | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.shortcuts import get_object_or_404 |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.http import urlencode | from django.utils.http import urlencode | ||||||
|  | from django.utils.text import slugify | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| @ -22,7 +22,7 @@ from authentik.stages.email.tasks import send_mails | |||||||
| from authentik.stages.email.utils import TemplateEmailMessage | from authentik.stages.email.utils import TemplateEmailMessage | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| QS_KEY_TOKEN = "token"  # nosec | QS_KEY_TOKEN = "etoken"  # nosec | ||||||
| PLAN_CONTEXT_EMAIL_SENT = "email_sent" | PLAN_CONTEXT_EMAIL_SENT = "email_sent" | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -65,7 +65,7 @@ class EmailStageView(ChallengeStageView): | |||||||
|         )  # + 1 because django timesince always rounds down |         )  # + 1 because django timesince always rounds down | ||||||
|         token_filters = { |         token_filters = { | ||||||
|             "user": pending_user, |             "user": pending_user, | ||||||
|             "identifier": f"ak-email-stage-{current_stage.name}-{pending_user}", |             "identifier": slugify(f"ak-email-stage-{current_stage.name}-{pending_user}"), | ||||||
|         } |         } | ||||||
|         # Don't check for validity here, we only care if the token exists |         # Don't check for validity here, we only care if the token exists | ||||||
|         tokens = Token.objects.filter(**token_filters) |         tokens = Token.objects.filter(**token_filters) | ||||||
| @ -99,10 +99,16 @@ class EmailStageView(ChallengeStageView): | |||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         # Check if the user came back from the email link to verify |         # Check if the user came back from the email link to verify | ||||||
|         if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}): |         if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}): | ||||||
|             token = get_object_or_404(Token, key=request.session[SESSION_KEY_GET][QS_KEY_TOKEN]) |             tokens = Token.filter_not_expired(key=request.session[SESSION_KEY_GET][QS_KEY_TOKEN]) | ||||||
|  |             if not tokens.exists(): | ||||||
|  |                 return self.executor.stage_invalid(_("Invalid token")) | ||||||
|  |             token = tokens.first() | ||||||
|             self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user |             self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user | ||||||
|             token.delete() |             token.delete() | ||||||
|             messages.success(request, _("Successfully verified Email.")) |             messages.success(request, _("Successfully verified Email.")) | ||||||
|  |             if self.executor.current_stage.activate_user_on_success: | ||||||
|  |                 self.executor.plan.context[PLAN_CONTEXT_PENDING_USER].is_active = True | ||||||
|  |                 self.executor.plan.context[PLAN_CONTEXT_PENDING_USER].save() | ||||||
|             return self.executor.stage_ok() |             return self.executor.stage_ok() | ||||||
|         if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: |         if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: | ||||||
|             LOGGER.debug("No pending user") |             LOGGER.debug("No pending user") | ||||||
| @ -118,7 +124,7 @@ class EmailStageView(ChallengeStageView): | |||||||
|         challenge = EmailChallenge( |         challenge = EmailChallenge( | ||||||
|             data={ |             data={ | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|                 "title": "Email sent.", |                 "title": _("Email sent."), | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|         return challenge |         return challenge | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ class TestEmailStage(APITestCase): | |||||||
|         ) |         ) | ||||||
|         self.stage = EmailStage.objects.create( |         self.stage = EmailStage.objects.create( | ||||||
|             name="email", |             name="email", | ||||||
|  |             activate_user_on_success=True, | ||||||
|         ) |         ) | ||||||
|         self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) |         self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) | ||||||
|  |  | ||||||
| @ -84,6 +85,8 @@ class TestEmailStage(APITestCase): | |||||||
|         """Test with token""" |         """Test with token""" | ||||||
|         # Make sure token exists |         # Make sure token exists | ||||||
|         self.test_pending_user() |         self.test_pending_user() | ||||||
|  |         self.user.is_active = False | ||||||
|  |         self.user.save() | ||||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) |         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
| @ -125,3 +128,4 @@ class TestEmailStage(APITestCase): | |||||||
|             session = self.client.session |             session = self.client.session | ||||||
|             plan: FlowPlan = session[SESSION_KEY_PLAN] |             plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||||
|             self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER], self.user) |             self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER], self.user) | ||||||
|  |             self.assertTrue(plan.context[PLAN_CONTEXT_PENDING_USER].is_active) | ||||||
|  | |||||||
| @ -15,7 +15,8 @@ from authentik.stages.invitation.signals import invitation_used | |||||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| INVITATION_TOKEN_KEY = "token"  # nosec | INVITATION_TOKEN_KEY_CONTEXT = "token"  # nosec | ||||||
|  | INVITATION_TOKEN_KEY = "itoken"  # nosec | ||||||
| INVITATION_IN_EFFECT = "invitation_in_effect" | INVITATION_IN_EFFECT = "invitation_in_effect" | ||||||
| INVITATION = "invitation" | INVITATION = "invitation" | ||||||
|  |  | ||||||
| @ -29,10 +30,14 @@ class InvitationStageView(StageView): | |||||||
|  |  | ||||||
|     def get_token(self) -> Optional[str]: |     def get_token(self) -> Optional[str]: | ||||||
|         """Get token from saved get-arguments or prompt_data""" |         """Get token from saved get-arguments or prompt_data""" | ||||||
|  |         # Check for ?token= and ?itoken= | ||||||
|         if INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}): |         if INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}): | ||||||
|             return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY] |             return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY] | ||||||
|         if INVITATION_TOKEN_KEY in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}): |         if INVITATION_TOKEN_KEY_CONTEXT in self.request.session.get(SESSION_KEY_GET, {}): | ||||||
|             return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY] |             return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY_CONTEXT] | ||||||
|  |         # Check for {'token': ''} in the context | ||||||
|  |         if INVITATION_TOKEN_KEY_CONTEXT in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}): | ||||||
|  |             return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT] | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     def get(self, request: HttpRequest) -> HttpResponse: |     def get(self, request: HttpRequest) -> HttpResponse: | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	