Compare commits
	
		
			248 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 430a207865 | |||
| 894873b373 | |||
| 1ce2a1b846 | |||
| 4731ccfafe | |||
| 3e9c28d0a4 | |||
| 12d4394d73 | |||
| b872e7072d | |||
| b0ea657b18 | |||
| a5f26b2ce0 | |||
| c1b9b5c5e2 | |||
| b288393cd4 | |||
| 767ffc09d0 | |||
| 446dc0a17b | |||
| c85474ec37 | |||
| 3a59b75f4a | |||
| 8deac81364 | |||
| 98485c528e | |||
| 1a5b626f96 | |||
| 5736a1542c | |||
| 43854dc828 | |||
| 64af78110a | |||
| 59be3c7746 | |||
| 9e30f01fce | |||
| fc8fe5317a | |||
| 92090ced9f | |||
| ce47d4cf39 | |||
| c11367553e | |||
| c61529e4d4 | |||
| 8709f3300c | |||
| e78bc1b32f | |||
| 89c4a7b4a4 | |||
| 9701907b82 | |||
| 7ac73bfcf9 | |||
| 1423d5d45b | |||
| 768ff67e8c | |||
| b13deefd91 | |||
| 69e445211e | |||
| ada8fc2a55 | |||
| d9cc45f9ce | |||
| 515a402db7 | |||
| 813f70b806 | |||
| a302a72379 | |||
| e390f5b2d1 | |||
| f09305a444 | |||
| 60189ce9ca | |||
| fdc445e6a1 | |||
| e3f8afcf80 | |||
| 9e2e8132a6 | |||
| 26f9bbeefa | |||
| 49b6c71079 | |||
| 97acc77e0a | |||
| eb1e0427c1 | |||
| 6e0c9acb34 | |||
| b75d659707 | |||
| 8894861a59 | |||
| 7878755acd | |||
| 2b62d6646e | |||
| 4f81f750ce | |||
| fa216e2e93 | |||
| 181bd903be | |||
| 23c69c456a | |||
| c73fce4f58 | |||
| bd0ef69ece | |||
| 19ee98b36d | |||
| 75d4246b79 | |||
| d2fd84d98c | |||
| 678378403b | |||
| 7f32d0eb9a | |||
| f1b3598a0f | |||
| 07767c9376 | |||
| 5a3f9d1417 | |||
| 44a6303c91 | |||
| 5f7f80fdee | |||
| a332a465ef | |||
| 8b16fed926 | |||
| be10dd629b | |||
| a6a868cbc1 | |||
| a9ed275f4e | |||
| fbc5378158 | |||
| 20210b614d | |||
| 063877a615 | |||
| a73d50d379 | |||
| 9568f4dbd6 | |||
| 9b2ceb0d44 | |||
| 2deb185550 | |||
| 69d4719687 | |||
| d31e566873 | |||
| 0ddcefce80 | |||
| 4c45d35507 | |||
| 829e49275d | |||
| 143309448e | |||
| 1f038ecee2 | |||
| 1b1f2ea72c | |||
| 6e1a54753e | |||
| 67d1f06c91 | |||
| d37de6bc00 | |||
| 8deced771d | |||
| c380512cc8 | |||
| e0b06bc4de | |||
| 1bd6107ec7 | |||
| ce1409fb6c | |||
| b6b97f4706 | |||
| cd12e177ea | |||
| 31c6ea9fda | |||
| 20931ccc1d | |||
| 9c9f441cff | |||
| 36822c128c | |||
| 29d3fdaa1d | |||
| ac5167b8a3 | |||
| 0db434a922 | |||
| 3c0675486c | |||
| f6d56e7e29 | |||
| fac56390a0 | |||
| c6e3229f0b | |||
| ace30933bd | |||
| d313f1576b | |||
| ac07576676 | |||
| df42480284 | |||
| d2f722f032 | |||
| a8fdcab927 | |||
| 0cba3c7788 | |||
| 0d414ec0ea | |||
| c42b34a46b | |||
| 7a1050300d | |||
| a64e87a6b1 | |||
| 81e9f2d608 | |||
| ddbd8153e2 | |||
| f7037b9f33 | |||
| 67a6fa6399 | |||
| a35b8f5862 | |||
| 5b7c6f1b0e | |||
| 662101fd1f | |||
| 3f633460a8 | |||
| be2d1a522a | |||
| d6f5b8e421 | |||
| b424c5dd27 | |||
| 2a83d79ace | |||
| 1ed24a5eef | |||
| f2961cb536 | |||
| 4d66e42708 | |||
| bd3a721753 | |||
| 25c3086d7a | |||
| 1bdd09342a | |||
| ad6d773d26 | |||
| b555ccd549 | |||
| 9445354b31 | |||
| a42f2f7217 | |||
| d1aa1f46da | |||
| a1be924fa4 | |||
| db60427e21 | |||
| d3e2f41561 | |||
| 8840f6ef63 | |||
| 3b103b22e2 | |||
| 158f4c1c4c | |||
| 42606a499b | |||
| c0841120bf | |||
| 61442a7e4a | |||
| 98876df5c5 | |||
| a9680d6088 | |||
| 7eb6320d74 | |||
| 47aba4a996 | |||
| 643b36b732 | |||
| 001869641d | |||
| bec538c543 | |||
| c63ba3f378 | |||
| 0fb2b5550a | |||
| 762294c0f9 | |||
| 2a2ab94e97 | |||
| 53cab07a48 | |||
| 2604dc14fe | |||
| 06f67c738c | |||
| 1b001060a3 | |||
| a960ce9454 | |||
| 439bdc54d6 | |||
| e6b5810e03 | |||
| 89b73a4d89 | |||
| ed3f36e72a | |||
| 78b711ec9d | |||
| ac07833688 | |||
| 4be0a707b1 | |||
| 1e73b42c58 | |||
| 3df3bceccb | |||
| a4370458cb | |||
| 742bad4080 | |||
| be473470a4 | |||
| 445cd5b2c4 | |||
| 805a4b766a | |||
| 730139e43c | |||
| 24e8915e0a | |||
| f15946e216 | |||
| b54415dcde | |||
| 471293ba25 | |||
| 3e7320734c | |||
| 3131e557d9 | |||
| 1efc7eecbf | |||
| 15ec6a9284 | |||
| dc1359a763 | |||
| 1e01e9813d | |||
| 119a268eb7 | |||
| e887a315be | |||
| c4bb51469b | |||
| 6edc043775 | |||
| 4379f5bc8e | |||
| 1ad56f4a13 | |||
| f54e82781a | |||
| e334d8ab00 | |||
| e1c0f74152 | |||
| e8f850285e | |||
| 4b93f40c5e | |||
| 57400925a4 | |||
| ffed653cae | |||
| ba5cd6e719 | |||
| 9564894eda | |||
| 042cd0b2cb | |||
| 049a97a800 | |||
| aa6668f8cb | |||
| 13a129bb01 | |||
| 0974f58367 | |||
| 1d59bfd16e | |||
| ebd73ec34f | |||
| 0629dee23b | |||
| 2dc0792d9e | |||
| fde848ee51 | |||
| e9d52282b7 | |||
| c810628fe3 | |||
| de0a5191f7 | |||
| f6794829e4 | |||
| 475853fb14 | |||
| 1c1319927e | |||
| 964fdf171b | |||
| 93e20bce2e | |||
| 960a2aab74 | |||
| 2cae6596eb | |||
| 11b1eb4173 | |||
| ee615c2d22 | |||
| aef9a22331 | |||
| 3980eea7c6 | |||
| d6d72489a7 | |||
| 9fdfb8c99b | |||
| b9bb27008e | |||
| 82184b2882 | |||
| f90a52c7d6 | |||
| 9ea0441559 | |||
| 5cab280759 | |||
| a03a64b35c | |||
| 780b986be8 | |||
| 9d422918b3 | |||
| b548ccca6e | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2022.12.1 | current_version = 2023.1.1 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) | ||||||
|  | |||||||
| @ -38,6 +38,14 @@ runs: | |||||||
|             AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s |             AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s | ||||||
|             ``` |             ``` | ||||||
|  |  | ||||||
|  |             For arm64, use these values: | ||||||
|  |  | ||||||
|  |             ```shell | ||||||
|  |             AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server | ||||||
|  |             AUTHENTIK_TAG=${{ inputs.tag }}-arm64 | ||||||
|  |             AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s | ||||||
|  |             ``` | ||||||
|  |  | ||||||
|             Afterwards, run the upgrade commands from the latest release notes. |             Afterwards, run the upgrade commands from the latest release notes. | ||||||
|           </details> |           </details> | ||||||
|           <details> |           <details> | ||||||
| @ -54,6 +62,17 @@ runs: | |||||||
|                 tag: ${{ inputs.tag }} |                 tag: ${{ inputs.tag }} | ||||||
|             ``` |             ``` | ||||||
|  |  | ||||||
|  |             For arm64, use these values: | ||||||
|  |  | ||||||
|  |             ```yaml | ||||||
|  |             authentik: | ||||||
|  |                 outposts: | ||||||
|  |                     container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s | ||||||
|  |             image: | ||||||
|  |                 repository: ghcr.io/goauthentik/dev-server | ||||||
|  |                 tag: ${{ inputs.tag }}-arm64 | ||||||
|  |             ``` | ||||||
|  |  | ||||||
|             Afterwards, run the upgrade commands from the latest release notes. |             Afterwards, run the upgrade commands from the latest release notes. | ||||||
|           </details> |           </details> | ||||||
|         edit-mode: replace |         edit-mode: replace | ||||||
|  | |||||||
| @ -17,6 +17,9 @@ outputs: | |||||||
|   sha: |   sha: | ||||||
|     description: "sha" |     description: "sha" | ||||||
|     value: ${{ steps.ev.outputs.sha }} |     value: ${{ steps.ev.outputs.sha }} | ||||||
|  |   shortHash: | ||||||
|  |     description: "shortHash" | ||||||
|  |     value: ${{ steps.ev.outputs.shortHash }} | ||||||
|   version: |   version: | ||||||
|     description: "version" |     description: "version" | ||||||
|     value: ${{ steps.ev.outputs.version }} |     value: ${{ steps.ev.outputs.version }} | ||||||
| @ -53,6 +56,7 @@ runs: | |||||||
|             print("branchNameContainer=%s" % safe_branch_name, file=_output) |             print("branchNameContainer=%s" % safe_branch_name, file=_output) | ||||||
|             print("timestamp=%s" % int(time()), file=_output) |             print("timestamp=%s" % int(time()), file=_output) | ||||||
|             print("sha=%s" % os.environ["GITHUB_SHA"], file=_output) |             print("sha=%s" % os.environ["GITHUB_SHA"], file=_output) | ||||||
|  |             print("shortHash=%s" % os.environ["GITHUB_SHA"][:7], file=_output) | ||||||
|             print("shouldBuild=%s" % should_build, file=_output) |             print("shouldBuild=%s" % should_build, file=_output) | ||||||
|             print("version=%s" % version, file=_output) |             print("version=%s" % version, file=_output) | ||||||
|             print("versionFamily=%s" % version_family, file=_output) |             print("versionFamily=%s" % version_family, file=_output) | ||||||
|  | |||||||
							
								
								
									
										103
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										103
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -102,14 +102,31 @@ jobs: | |||||||
|         uses: helm/kind-action@v1.5.0 |         uses: helm/kind-action@v1.5.0 | ||||||
|       - name: run integration |       - name: run integration | ||||||
|         run: | |         run: | | ||||||
|           poetry run make test-integration |           poetry run coverage run manage.py test tests/integration | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v3 |         uses: codecov/codecov-action@v3 | ||||||
|         with: |         with: | ||||||
|           flags: integration |           flags: integration | ||||||
|   test-e2e-provider: |   test-e2e: | ||||||
|  |     name: test-e2e (${{ matrix.job.name }}) | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|  |     strategy: | ||||||
|  |       fail-fast: false | ||||||
|  |       matrix: | ||||||
|  |         job: | ||||||
|  |           - name: proxy | ||||||
|  |             glob: tests/e2e/test_provider_proxy* | ||||||
|  |           - name: oauth | ||||||
|  |             glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth* | ||||||
|  |           - name: oauth-oidc | ||||||
|  |             glob: tests/e2e/test_provider_oidc* | ||||||
|  |           - name: saml | ||||||
|  |             glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml* | ||||||
|  |           - name: ldap | ||||||
|  |             glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* | ||||||
|  |           - name: flows | ||||||
|  |             glob: tests/e2e/test_flows* | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
| @ -131,36 +148,7 @@ jobs: | |||||||
|           npm run build |           npm run build | ||||||
|       - name: run e2e |       - name: run e2e | ||||||
|         run: | |         run: | | ||||||
|           poetry run make test-e2e-provider |           poetry run coverage run manage.py test ${{ matrix.job.glob }} | ||||||
|           poetry run coverage xml |  | ||||||
|       - if: ${{ always() }} |  | ||||||
|         uses: codecov/codecov-action@v3 |  | ||||||
|         with: |  | ||||||
|           flags: e2e |  | ||||||
|   test-e2e-rest: |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     steps: |  | ||||||
|       - uses: actions/checkout@v3 |  | ||||||
|       - name: Setup authentik env |  | ||||||
|         uses: ./.github/actions/setup |  | ||||||
|       - name: Setup e2e env (chrome, etc) |  | ||||||
|         run: | |  | ||||||
|           docker-compose -f tests/e2e/docker-compose.yml up -d |  | ||||||
|       - id: cache-web |  | ||||||
|         uses: actions/cache@v3 |  | ||||||
|         with: |  | ||||||
|           path: web/dist |  | ||||||
|           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }} |  | ||||||
|       - name: prepare web ui |  | ||||||
|         if: steps.cache-web.outputs.cache-hit != 'true' |  | ||||||
|         working-directory: web/ |  | ||||||
|         run: | |  | ||||||
|           npm ci |  | ||||||
|           make -C .. gen-client-ts |  | ||||||
|           npm run build |  | ||||||
|       - name: run e2e |  | ||||||
|         run: | |  | ||||||
|           poetry run make test-e2e-rest |  | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v3 |         uses: codecov/codecov-action@v3 | ||||||
| @ -173,8 +161,7 @@ jobs: | |||||||
|       - test-migrations-from-stable |       - test-migrations-from-stable | ||||||
|       - test-unittest |       - test-unittest | ||||||
|       - test-integration |       - test-integration | ||||||
|       - test-e2e-rest |       - test-e2e | ||||||
|       - test-e2e-provider |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - run: echo mark |       - run: echo mark | ||||||
| @ -182,11 +169,6 @@ jobs: | |||||||
|     needs: ci-core-mark |     needs: ci-core-mark | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
|     strategy: |  | ||||||
|       fail-fast: false |  | ||||||
|       matrix: |  | ||||||
|         arch: |  | ||||||
|           - 'linux/amd64' |  | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - name: Set up QEMU |       - name: Set up QEMU | ||||||
| @ -205,7 +187,7 @@ jobs: | |||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|       - name: Building Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v3 |         uses: docker/build-push-action@v3 | ||||||
|         with: |         with: | ||||||
|           secrets: | |           secrets: | | ||||||
| @ -214,14 +196,49 @@ jobs: | |||||||
|           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|           tags: | |           tags: | | ||||||
|             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }} |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }} | ||||||
|             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }} |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }} | ||||||
|           build-args: | |           build-args: | | ||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|           platforms: ${{ matrix.arch }} |  | ||||||
|       - name: Comment on PR |       - name: Comment on PR | ||||||
|         if: github.event_name == 'pull_request' |         if: github.event_name == 'pull_request' | ||||||
|         continue-on-error: true |         continue-on-error: true | ||||||
|         uses: ./.github/actions/comment-pr-instructions |         uses: ./.github/actions/comment-pr-instructions | ||||||
|         with: |         with: | ||||||
|           tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }} |           tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }} | ||||||
|  |   build-arm64: | ||||||
|  |     needs: ci-core-mark | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     timeout-minutes: 120 | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v3 | ||||||
|  |       - name: Set up QEMU | ||||||
|  |         uses: docker/setup-qemu-action@v2.1.0 | ||||||
|  |       - name: Set up Docker Buildx | ||||||
|  |         uses: docker/setup-buildx-action@v2 | ||||||
|  |       - name: prepare variables | ||||||
|  |         uses: ./.github/actions/docker-push-variables | ||||||
|  |         id: ev | ||||||
|  |         env: | ||||||
|  |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|  |       - name: Login to Container Registry | ||||||
|  |         uses: docker/login-action@v2 | ||||||
|  |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|  |         with: | ||||||
|  |           registry: ghcr.io | ||||||
|  |           username: ${{ github.repository_owner }} | ||||||
|  |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  |       - name: Build Docker Image | ||||||
|  |         uses: docker/build-push-action@v3 | ||||||
|  |         with: | ||||||
|  |           secrets: | | ||||||
|  |             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} | ||||||
|  |             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} | ||||||
|  |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|  |           tags: | | ||||||
|  |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-arm64 | ||||||
|  |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}-arm64 | ||||||
|  |           build-args: | | ||||||
|  |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|  |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|  |           platforms: linux/arm64 | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -28,6 +28,8 @@ jobs: | |||||||
|         run: make gen-client-go |         run: make gen-client-go | ||||||
|       - name: golangci-lint |       - name: golangci-lint | ||||||
|         uses: golangci/golangci-lint-action@v3 |         uses: golangci/golangci-lint-action@v3 | ||||||
|  |         with: | ||||||
|  |           args: --timeout 5000s | ||||||
|   test-unittest: |   test-unittest: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
| @ -80,13 +82,12 @@ jobs: | |||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-client-go |         run: make gen-client-go | ||||||
|       - name: Building Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v3 |         uses: docker/build-push-action@v3 | ||||||
|         with: |         with: | ||||||
|           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|           tags: | |           tags: | | ||||||
|             ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }} |             ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }} | ||||||
|             ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }} |  | ||||||
|             ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }} |             ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }} | ||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           build-args: | |           build-args: | | ||||||
| @ -111,7 +112,7 @@ jobs: | |||||||
|       - uses: actions/setup-go@v3 |       - uses: actions/setup-go@v3 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.17" |           go-version: "^1.17" | ||||||
|       - uses: actions/setup-node@v3.5.1 |       - uses: actions/setup-node@v3.6.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										25
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										25
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -15,7 +15,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.5.1 |       - uses: actions/setup-node@v3.6.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -27,11 +27,27 @@ jobs: | |||||||
|       - name: Eslint |       - name: Eslint | ||||||
|         working-directory: web/ |         working-directory: web/ | ||||||
|         run: npm run lint |         run: npm run lint | ||||||
|  |   lint-build: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v3 | ||||||
|  |       - uses: actions/setup-node@v3.6.0 | ||||||
|  |         with: | ||||||
|  |           node-version: '16' | ||||||
|  |           cache: 'npm' | ||||||
|  |           cache-dependency-path: web/package-lock.json | ||||||
|  |       - working-directory: web/ | ||||||
|  |         run: npm ci | ||||||
|  |       - name: Generate API | ||||||
|  |         run: make gen-client-ts | ||||||
|  |       - name: TSC | ||||||
|  |         working-directory: web/ | ||||||
|  |         run: npm run tsc | ||||||
|   lint-prettier: |   lint-prettier: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.5.1 |       - uses: actions/setup-node@v3.6.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -47,7 +63,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.5.1 |       - uses: actions/setup-node@v3.6.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -69,6 +85,7 @@ jobs: | |||||||
|       - lint-eslint |       - lint-eslint | ||||||
|       - lint-prettier |       - lint-prettier | ||||||
|       - lint-lit-analyse |       - lint-lit-analyse | ||||||
|  |       - lint-build | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - run: echo mark |       - run: echo mark | ||||||
| @ -78,7 +95,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.5.1 |       - uses: actions/setup-node@v3.6.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -15,7 +15,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.5.1 |       - uses: actions/setup-node@v3.6.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,12 +11,12 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - name: Delete 'dev' containers older than a week |       - name: Delete 'dev' containers older than a week | ||||||
|         uses: sondrelg/container-retention-policy@v1 |         uses: snok/container-retention-policy@v1 | ||||||
|         with: |         with: | ||||||
|           image-names: dev-server,dev-ldap,dev-proxy |           image-names: dev-server,dev-ldap,dev-proxy | ||||||
|           cut-off: One week ago UTC |           cut-off: One week ago UTC | ||||||
|           account-type: org |           account-type: org | ||||||
|           org-name: goauthentik |           org-name: goauthentik | ||||||
|           untagged-only: false |           untagged-only: false | ||||||
|           token: ${{ secrets.GHCR_CLEANUP_TOKEN }} |           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||||
|           skip-tags: gh-next,gh-main |           skip-tags: gh-next,gh-main | ||||||
|  | |||||||
							
								
								
									
										11
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -27,11 +27,11 @@ jobs: | |||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|       - name: Building Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v3 |         uses: docker/build-push-action@v3 | ||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           secrets: |           secrets: | | ||||||
|             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} |             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} | ||||||
|             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} |             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} | ||||||
|           tags: | |           tags: | | ||||||
| @ -75,7 +75,7 @@ jobs: | |||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|       - name: Building Docker Image |       - name: Build Docker Image | ||||||
|         uses: docker/build-push-action@v3 |         uses: docker/build-push-action@v3 | ||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
| @ -88,9 +88,6 @@ jobs: | |||||||
|             ghcr.io/goauthentik/${{ matrix.type }}:latest |             ghcr.io/goauthentik/${{ matrix.type }}:latest | ||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           secrets: | |  | ||||||
|             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} |  | ||||||
|             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} |  | ||||||
|           build-args: | |           build-args: | | ||||||
|             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|   build-outpost-binary: |   build-outpost-binary: | ||||||
| @ -109,7 +106,7 @@ jobs: | |||||||
|       - uses: actions/setup-go@v3 |       - uses: actions/setup-go@v3 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.17" |           go-version: "^1.17" | ||||||
|       - uses: actions/setup-node@v3.5.1 |       - uses: actions/setup-node@v3.6.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -26,14 +26,14 @@ jobs: | |||||||
|         id: get_version |         id: get_version | ||||||
|         uses: actions/github-script@v6 |         uses: actions/github-script@v6 | ||||||
|         with: |         with: | ||||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} |           github-token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||||
|           script: | |           script: | | ||||||
|             return context.payload.ref.replace(/\/refs\/tags\/version\//, ''); |             return context.payload.ref.replace(/\/refs\/tags\/version\//, ''); | ||||||
|       - name: Create Release |       - name: Create Release | ||||||
|         id: create_release |         id: create_release | ||||||
|         uses: actions/create-release@v1.1.4 |         uses: actions/create-release@v1.1.4 | ||||||
|         env: |         env: | ||||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |           GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||||
|         with: |         with: | ||||||
|           tag_name: ${{ github.ref }} |           tag_name: ${{ github.ref }} | ||||||
|           release_name: Release ${{ steps.get_version.outputs.result }} |           release_name: Release ${{ steps.get_version.outputs.result }} | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							| @ -19,6 +19,8 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|  |         with: | ||||||
|  |           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - name: run compile |       - name: run compile | ||||||
| @ -27,7 +29,7 @@ jobs: | |||||||
|         uses: peter-evans/create-pull-request@v4 |         uses: peter-evans/create-pull-request@v4 | ||||||
|         id: cpr |         id: cpr | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.GITHUB_TOKEN }} |           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||||
|           branch: compile-backend-translation |           branch: compile-backend-translation | ||||||
|           commit-message: "core: compile backend translations" |           commit-message: "core: compile backend translations" | ||||||
|           title: "core: compile backend translations" |           title: "core: compile backend translations" | ||||||
|  | |||||||
							
								
								
									
										16
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -10,7 +10,9 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.5.1 |         with: | ||||||
|  |           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||||
|  |       - uses: actions/setup-node@v3.6.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           registry-url: 'https://registry.npmjs.org' |           registry-url: 'https://registry.npmjs.org' | ||||||
| @ -28,14 +30,20 @@ jobs: | |||||||
|         run: | |         run: | | ||||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` |           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||||
|           npm i @goauthentik/api@$VERSION |           npm i @goauthentik/api@$VERSION | ||||||
|       - name: Create Pull Request |       - uses: peter-evans/create-pull-request@v4 | ||||||
|         uses: peter-evans/create-pull-request@v4 |  | ||||||
|         id: cpr |         id: cpr | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.GITHUB_TOKEN }} |           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||||
|           branch: update-web-api-client |           branch: update-web-api-client | ||||||
|           commit-message: "web: bump API Client version" |           commit-message: "web: bump API Client version" | ||||||
|           title: "web: bump API Client version" |           title: "web: bump API Client version" | ||||||
|           body: "web: bump API Client version" |           body: "web: bump API Client version" | ||||||
|           delete-branch: true |           delete-branch: true | ||||||
|           signoff: true |           signoff: true | ||||||
|  |           team-reviewers: "@goauthentik/core" | ||||||
|  |           author: authentik bot <github-bot@goauthentik.io> | ||||||
|  |       - uses: peter-evans/enable-pull-request-automerge@v2 | ||||||
|  |         with: | ||||||
|  |           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||||
|  |           pull-request-number: ${{ steps.cpr.outputs.pull-request-number }} | ||||||
|  |           merge-method: squash | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -14,7 +14,9 @@ | |||||||
|         "webauthn", |         "webauthn", | ||||||
|         "traefik", |         "traefik", | ||||||
|         "passwordless", |         "passwordless", | ||||||
|         "kubernetes" |         "kubernetes", | ||||||
|  |         "sso", | ||||||
|  |         "slo" | ||||||
|     ], |     ], | ||||||
|     "python.linting.pylintEnabled": true, |     "python.linting.pylintEnabled": true, | ||||||
|     "todo-tree.tree.showCountsInTree": true, |     "todo-tree.tree.showCountsInTree": true, | ||||||
|  | |||||||
| @ -59,19 +59,18 @@ These are the current packages: | |||||||
| authentik | authentik | ||||||
| ├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks) | ├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks) | ||||||
| ├── api - General API Configuration (Routes, Schema and general API utilities) | ├── api - General API Configuration (Routes, Schema and general API utilities) | ||||||
|  | ├── blueprints - Handle managed models and their state. | ||||||
| ├── core - Core authentik functionality, central routes, core Models | ├── core - Core authentik functionality, central routes, core Models | ||||||
| ├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys | ├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys | ||||||
| ├── events - Event Log, middleware and signals to generate signals | ├── events - Event Log, middleware and signals to generate signals | ||||||
| ├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc | ├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc | ||||||
| ├── lib - Generic library of functions, few dependencies on other packages. | ├── lib - Generic library of functions, few dependencies on other packages. | ||||||
| ├── managed - Handle managed models and their state. |  | ||||||
| ├── outposts - Configure and deploy outposts on kubernetes and docker. | ├── outposts - Configure and deploy outposts on kubernetes and docker. | ||||||
| ├── policies - General PolicyEngine | ├── policies - General PolicyEngine | ||||||
| │   ├── dummy - A Dummy policy used for testing | │   ├── dummy - A Dummy policy used for testing | ||||||
| │   ├── event_matcher - Match events based on different criteria | │   ├── event_matcher - Match events based on different criteria | ||||||
| │   ├── expiry - Check when a user's password was last set | │   ├── expiry - Check when a user's password was last set | ||||||
| │   ├── expression - Execute any arbitrary python code | │   ├── expression - Execute any arbitrary python code | ||||||
| │   ├── hibp - Check a password against HaveIBeenPwned |  | ||||||
| │   ├── password - Check a password against several rules | │   ├── password - Check a password against several rules | ||||||
| │   └── reputation - Check the user's/client's reputation | │   └── reputation - Check the user's/client's reputation | ||||||
| ├── providers | ├── providers | ||||||
|  | |||||||
| @ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \ | |||||||
|     poetry export -f requirements.txt --dev --output requirements-dev.txt |     poetry export -f requirements.txt --dev --output requirements-dev.txt | ||||||
|  |  | ||||||
| # Stage 4: Build go proxy | # Stage 4: Build go proxy | ||||||
| FROM docker.io/golang:1.19.4-bullseye AS go-builder | FROM docker.io/golang:1.19.5-bullseye AS go-builder | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
|  |  | ||||||
| @ -50,6 +50,7 @@ RUN go build -o /work/authentik ./cmd/server/ | |||||||
| FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip | FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip | ||||||
|  |  | ||||||
| ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City" | ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City" | ||||||
|  | ENV GEOIPUPDATE_VERBOSE="true" | ||||||
|  |  | ||||||
| RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | ||||||
|     --mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \ |     --mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \ | ||||||
| @ -57,7 +58,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | |||||||
|     /bin/sh -c "\ |     /bin/sh -c "\ | ||||||
|         export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \ |         export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \ | ||||||
|         export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \ |         export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \ | ||||||
|         /usr/bin/entry.sh || exit 0 \ |         /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0 \ | ||||||
|     " |     " | ||||||
|  |  | ||||||
| # Stage 6: Run | # Stage 6: Run | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								Makefile
									
									
									
									
									
								
							| @ -6,15 +6,6 @@ NPM_VERSION = $(shell python -m scripts.npm_version) | |||||||
|  |  | ||||||
| all: lint-fix lint test gen web | all: lint-fix lint test gen web | ||||||
|  |  | ||||||
| test-integration: |  | ||||||
| 	coverage run manage.py test tests/integration |  | ||||||
|  |  | ||||||
| test-e2e-provider: |  | ||||||
| 	coverage run manage.py test tests/e2e/test_provider* |  | ||||||
|  |  | ||||||
| test-e2e-rest: |  | ||||||
| 	coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source* |  | ||||||
|  |  | ||||||
| test-go: | test-go: | ||||||
| 	go test -timeout 0 -v -race -cover ./... | 	go test -timeout 0 -v -race -cover ./... | ||||||
|  |  | ||||||
| @ -126,7 +117,7 @@ gen: gen-build gen-clean gen-client-ts | |||||||
| web-build: web-install | web-build: web-install | ||||||
| 	cd web && npm run build | 	cd web && npm run build | ||||||
|  |  | ||||||
| web: web-lint-fix web-lint | web: web-lint-fix web-lint web-check-compile | ||||||
|  |  | ||||||
| web-install: | web-install: | ||||||
| 	cd web && npm ci | 	cd web && npm ci | ||||||
| @ -144,6 +135,9 @@ web-lint: | |||||||
| 	cd web && npm run lint | 	cd web && npm run lint | ||||||
| 	cd web && npm run lit-analyse | 	cd web && npm run lit-analyse | ||||||
|  |  | ||||||
|  | web-check-compile: | ||||||
|  | 	cd web && npm run tsc | ||||||
|  |  | ||||||
| web-extract: | web-extract: | ||||||
| 	cd web && npm run extract | 	cd web && npm run extract | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,8 +6,8 @@ Authentik takes security very seriously. We follow the rules of [responsible dis | |||||||
|  |  | ||||||
| | Version   | Supported          | | | Version   | Supported          | | ||||||
| | --------- | ------------------ | | | --------- | ------------------ | | ||||||
| | 2022.11.x | :white_check_mark: | |  | ||||||
| | 2022.12.x | :white_check_mark: | | | 2022.12.x | :white_check_mark: | | ||||||
|  | | 2023.1.x  | :white_check_mark: | | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,16 +2,14 @@ | |||||||
| from os import environ | from os import environ | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| __version__ = "2022.12.1" | __version__ = "2023.1.1" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_build_hash(fallback: Optional[str] = None) -> str: | def get_build_hash(fallback: Optional[str] = None) -> str: | ||||||
|     """Get build hash""" |     """Get build hash""" | ||||||
|     build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "") |     build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "") | ||||||
|     if build_hash == "" and fallback: |     return fallback if build_hash == "" and fallback else build_hash | ||||||
|         return fallback |  | ||||||
|     return build_hash |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_full_version() -> str: | def get_full_version() -> str: | ||||||
|  | |||||||
| @ -1,4 +1,7 @@ | |||||||
| """authentik administration metrics""" | """authentik administration metrics""" | ||||||
|  | from datetime import timedelta | ||||||
|  |  | ||||||
|  | from django.db.models.functions import ExtractHour | ||||||
| from drf_spectacular.utils import extend_schema, extend_schema_field | from drf_spectacular.utils import extend_schema, extend_schema_field | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.fields import IntegerField, SerializerMethodField | from rest_framework.fields import IntegerField, SerializerMethodField | ||||||
| @ -21,38 +24,44 @@ class CoordinateSerializer(PassiveSerializer): | |||||||
| class LoginMetricsSerializer(PassiveSerializer): | class LoginMetricsSerializer(PassiveSerializer): | ||||||
|     """Login Metrics per 1h""" |     """Login Metrics per 1h""" | ||||||
|  |  | ||||||
|     logins_per_1h = SerializerMethodField() |     logins = SerializerMethodField() | ||||||
|     logins_failed_per_1h = SerializerMethodField() |     logins_failed = SerializerMethodField() | ||||||
|     authorizations_per_1h = SerializerMethodField() |     authorizations = SerializerMethodField() | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_logins_per_1h(self, _): |     def get_logins(self, _): | ||||||
|         """Get successful logins per hour for the last 24 hours""" |         """Get successful logins per 8 hours for the last 7 days""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
|         return ( |         return ( | ||||||
|             get_objects_for_user(user, "authentik_events.view_event") |             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||||
|             .filter(action=EventAction.LOGIN) |                 action=EventAction.LOGIN | ||||||
|             .get_events_per_hour() |             ) | ||||||
|  |             # 3 data points per day, so 8 hour spans | ||||||
|  |             .get_events_per(timedelta(days=7), ExtractHour, 7 * 3) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_logins_failed_per_1h(self, _): |     def get_logins_failed(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per 8 hours for the last 7 days""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
|         return ( |         return ( | ||||||
|             get_objects_for_user(user, "authentik_events.view_event") |             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||||
|             .filter(action=EventAction.LOGIN_FAILED) |                 action=EventAction.LOGIN_FAILED | ||||||
|             .get_events_per_hour() |             ) | ||||||
|  |             # 3 data points per day, so 8 hour spans | ||||||
|  |             .get_events_per(timedelta(days=7), ExtractHour, 7 * 3) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_authorizations_per_1h(self, _): |     def get_authorizations(self, _): | ||||||
|         """Get successful authorizations per hour for the last 24 hours""" |         """Get successful authorizations per 8 hours for the last 7 days""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
|         return ( |         return ( | ||||||
|             get_objects_for_user(user, "authentik_events.view_event") |             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||||
|             .filter(action=EventAction.AUTHORIZE_APPLICATION) |                 action=EventAction.AUTHORIZE_APPLICATION | ||||||
|             .get_events_per_hour() |             ) | ||||||
|  |             # 3 data points per day, so 8 hour spans | ||||||
|  |             .get_events_per(timedelta(days=7), ExtractHour, 7 * 3) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -8,7 +8,6 @@ from typing import TypedDict | |||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from gunicorn import version_info as gunicorn_version | from gunicorn import version_info as gunicorn_version | ||||||
| from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME |  | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -16,6 +15,7 @@ from rest_framework.response import Response | |||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
|  | from authentik.lib.utils.reflection import get_env | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST | from authentik.outposts.apps import MANAGED_OUTPOST | ||||||
| from authentik.outposts.models import Outpost | from authentik.outposts.models import Outpost | ||||||
|  |  | ||||||
| @ -69,7 +69,7 @@ class SystemSerializer(PassiveSerializer): | |||||||
|         return { |         return { | ||||||
|             "python_version": python_version, |             "python_version": python_version, | ||||||
|             "gunicorn_version": ".".join(str(x) for x in gunicorn_version), |             "gunicorn_version": ".".join(str(x) for x in gunicorn_version), | ||||||
|             "environment": "kubernetes" if SERVICE_HOST_ENV_NAME in os.environ else "compose", |             "environment": get_env(), | ||||||
|             "architecture": platform.machine(), |             "architecture": platform.machine(), | ||||||
|             "platform": platform.platform(), |             "platform": platform.platform(), | ||||||
|             "uname": " ".join(platform.uname()), |             "uname": " ".join(platform.uname()), | ||||||
|  | |||||||
| @ -7,7 +7,13 @@ from django.utils.translation import gettext_lazy as _ | |||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField | from rest_framework.fields import ( | ||||||
|  |     CharField, | ||||||
|  |     ChoiceField, | ||||||
|  |     DateTimeField, | ||||||
|  |     ListField, | ||||||
|  |     SerializerMethodField, | ||||||
|  | ) | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @ -26,6 +32,7 @@ class TaskSerializer(PassiveSerializer): | |||||||
|     task_name = CharField() |     task_name = CharField() | ||||||
|     task_description = CharField() |     task_description = CharField() | ||||||
|     task_finish_timestamp = DateTimeField(source="finish_time") |     task_finish_timestamp = DateTimeField(source="finish_time") | ||||||
|  |     task_duration = SerializerMethodField() | ||||||
|  |  | ||||||
|     status = ChoiceField( |     status = ChoiceField( | ||||||
|         source="result.status.name", |         source="result.status.name", | ||||||
| @ -33,7 +40,11 @@ class TaskSerializer(PassiveSerializer): | |||||||
|     ) |     ) | ||||||
|     messages = ListField(source="result.messages") |     messages = ListField(source="result.messages") | ||||||
|  |  | ||||||
|     def to_representation(self, instance): |     def get_task_duration(self, instance: TaskInfo) -> int: | ||||||
|  |         """Get the duration a task took to run""" | ||||||
|  |         return max(instance.finish_timestamp - instance.start_timestamp, 0) | ||||||
|  |  | ||||||
|  |     def to_representation(self, instance: TaskInfo): | ||||||
|         """When a new version of authentik adds fields to TaskInfo, |         """When a new version of authentik adds fields to TaskInfo, | ||||||
|         the API will fail with an AttributeError, as the classes |         the API will fail with an AttributeError, as the classes | ||||||
|         are pickled in cache. In that case, just delete the info""" |         are pickled in cache. In that case, just delete the info""" | ||||||
| @ -68,7 +79,6 @@ class TaskViewSet(ViewSet): | |||||||
|             ), |             ), | ||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=invalid-name |  | ||||||
|     def retrieve(self, request: Request, pk=None) -> Response: |     def retrieve(self, request: Request, pk=None) -> Response: | ||||||
|         """Get a single system task""" |         """Get a single system task""" | ||||||
|         task = TaskInfo.by_name(pk) |         task = TaskInfo.by_name(pk) | ||||||
| @ -99,7 +109,6 @@ class TaskViewSet(ViewSet): | |||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["post"]) |     @action(detail=True, methods=["post"]) | ||||||
|     # pylint: disable=invalid-name |  | ||||||
|     def retry(self, request: Request, pk=None) -> Response: |     def retry(self, request: Request, pk=None) -> Response: | ||||||
|         """Retry task""" |         """Retry task""" | ||||||
|         task = TaskInfo.by_name(pk) |         task = TaskInfo.by_name(pk) | ||||||
|  | |||||||
| @ -8,7 +8,6 @@ from authentik.root.monitoring import monitoring_set | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(monitoring_set) | @receiver(monitoring_set) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def monitoring_set_workers(sender, **kwargs): | def monitoring_set_workers(sender, **kwargs): | ||||||
|     """Set worker gauge""" |     """Set worker gauge""" | ||||||
|     count = len(CELERY_APP.control.ping(timeout=0.5)) |     count = len(CELERY_APP.control.ping(timeout=0.5)) | ||||||
| @ -16,7 +15,6 @@ def monitoring_set_workers(sender, **kwargs): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(monitoring_set) | @receiver(monitoring_set) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def monitoring_set_tasks(sender, **kwargs): | def monitoring_set_tasks(sender, **kwargs): | ||||||
|     """Set task gauges""" |     """Set task gauges""" | ||||||
|     for task in TaskInfo.all().values(): |     for task in TaskInfo.all().values(): | ||||||
|  | |||||||
| @ -62,7 +62,7 @@ window.addEventListener('DOMContentLoaded', (event) => { | |||||||
|     allow-spec-url-load="false" |     allow-spec-url-load="false" | ||||||
|     allow-spec-file-load="false"> |     allow-spec-file-load="false"> | ||||||
|     <div slot="nav-logo"> |     <div slot="nav-logo"> | ||||||
|         <img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" /> |         <img  alt="authentik Logo" class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" /> | ||||||
|     </div> |     </div> | ||||||
| </rapi-doc> | </rapi-doc> | ||||||
| <script> | <script> | ||||||
|  | |||||||
| @ -68,7 +68,7 @@ class ConfigView(APIView): | |||||||
|             caps.append(Capabilities.CAN_GEO_IP) |             caps.append(Capabilities.CAN_GEO_IP) | ||||||
|         if CONFIG.y_bool("impersonation"): |         if CONFIG.y_bool("impersonation"): | ||||||
|             caps.append(Capabilities.CAN_IMPERSONATE) |             caps.append(Capabilities.CAN_IMPERSONATE) | ||||||
|         if settings.DEBUG: |         if settings.DEBUG:  # pragma: no cover | ||||||
|             caps.append(Capabilities.CAN_DEBUG) |             caps.append(Capabilities.CAN_DEBUG) | ||||||
|         return caps |         return caps | ||||||
|  |  | ||||||
|  | |||||||
| @ -45,7 +45,6 @@ from authentik.policies.dummy.api import DummyPolicyViewSet | |||||||
| from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet | from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet | ||||||
| from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet | from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet | ||||||
| from authentik.policies.expression.api import ExpressionPolicyViewSet | from authentik.policies.expression.api import ExpressionPolicyViewSet | ||||||
| from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet |  | ||||||
| from authentik.policies.password.api import PasswordPolicyViewSet | from authentik.policies.password.api import PasswordPolicyViewSet | ||||||
| from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet | from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet | ||||||
| from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet | from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet | ||||||
| @ -150,7 +149,6 @@ router.register("policies/all", PolicyViewSet) | |||||||
| router.register("policies/bindings", PolicyBindingViewSet) | router.register("policies/bindings", PolicyBindingViewSet) | ||||||
| router.register("policies/expression", ExpressionPolicyViewSet) | router.register("policies/expression", ExpressionPolicyViewSet) | ||||||
| router.register("policies/event_matcher", EventMatcherPolicyViewSet) | router.register("policies/event_matcher", EventMatcherPolicyViewSet) | ||||||
| router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) |  | ||||||
| router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) | router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) | ||||||
| router.register("policies/password", PasswordPolicyViewSet) | router.register("policies/password", PasswordPolicyViewSet) | ||||||
| router.register("policies/reputation/scores", ReputationViewSet) | router.register("policies/reputation/scores", ReputationViewSet) | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| """Serializer mixin for managed models""" | """Serializer mixin for managed models""" | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_spectacular.utils import extend_schema, inline_serializer | from drf_spectacular.utils import extend_schema, inline_serializer | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| @ -11,6 +12,7 @@ from rest_framework.viewsets import ModelViewSet | |||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed | from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed | ||||||
|  | from authentik.blueprints.v1.importer import Importer | ||||||
| from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict | from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict | ||||||
| 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 | ||||||
| @ -40,6 +42,21 @@ class BlueprintInstanceSerializer(ModelSerializer): | |||||||
|             raise ValidationError(exc) from exc |             raise ValidationError(exc) from exc | ||||||
|         return path |         return path | ||||||
|  |  | ||||||
|  |     def validate_content(self, content: str) -> str: | ||||||
|  |         """Ensure content (if set) is a valid blueprint""" | ||||||
|  |         if content == "": | ||||||
|  |             return content | ||||||
|  |         context = self.instance.context if self.instance else {} | ||||||
|  |         valid, logs = Importer(content, context).validate() | ||||||
|  |         if not valid: | ||||||
|  |             raise ValidationError(_("Failed to validate blueprint"), *[x["msg"] for x in logs]) | ||||||
|  |         return content | ||||||
|  |  | ||||||
|  |     def validate(self, attrs: dict) -> dict: | ||||||
|  |         if attrs.get("path", "") == "" and attrs.get("content", "") == "": | ||||||
|  |             raise ValidationError(_("Either path or content must be set.")) | ||||||
|  |         return super().validate(attrs) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = BlueprintInstance |         model = BlueprintInstance | ||||||
| @ -54,6 +71,7 @@ class BlueprintInstanceSerializer(ModelSerializer): | |||||||
|             "enabled", |             "enabled", | ||||||
|             "managed_models", |             "managed_models", | ||||||
|             "metadata", |             "metadata", | ||||||
|  |             "content", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "status": {"read_only": True}, |             "status": {"read_only": True}, | ||||||
|  | |||||||
| @ -0,0 +1,23 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-01-10 19:48 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_blueprints", "0001_initial"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="blueprintinstance", | ||||||
|  |             name="content", | ||||||
|  |             field=models.TextField(blank=True, default=""), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="blueprintinstance", | ||||||
|  |             name="path", | ||||||
|  |             field=models.TextField(blank=True, default=""), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -1,30 +1,18 @@ | |||||||
| """blueprint models""" | """blueprint models""" | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from urllib.parse import urlparse |  | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from django.contrib.postgres.fields import ArrayField | from django.contrib.postgres.fields import ArrayField | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from opencontainers.distribution.reggie import ( |  | ||||||
|     NewClient, |  | ||||||
|     WithDebug, |  | ||||||
|     WithDefaultName, |  | ||||||
|     WithDigest, |  | ||||||
|     WithReference, |  | ||||||
|     WithUserAgent, |  | ||||||
|     WithUsernamePassword, |  | ||||||
| ) |  | ||||||
| from requests.exceptions import RequestException |  | ||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from authentik.blueprints.v1.oci import BlueprintOCIClient, OCIException | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.models import CreatedUpdatedModel, SerializerModel | from authentik.lib.models import CreatedUpdatedModel, SerializerModel | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.lib.utils.http import authentik_user_agent |  | ||||||
|  |  | ||||||
| OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml" |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -74,7 +62,8 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel): | |||||||
|  |  | ||||||
|     name = models.TextField() |     name = models.TextField() | ||||||
|     metadata = models.JSONField(default=dict) |     metadata = models.JSONField(default=dict) | ||||||
|     path = models.TextField() |     path = models.TextField(default="", blank=True) | ||||||
|  |     content = models.TextField(default="", blank=True) | ||||||
|     context = models.JSONField(default=dict) |     context = models.JSONField(default=dict) | ||||||
|     last_applied = models.DateTimeField(auto_now=True) |     last_applied = models.DateTimeField(auto_now=True) | ||||||
|     last_applied_hash = models.TextField() |     last_applied_hash = models.TextField() | ||||||
| @ -86,60 +75,29 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel): | |||||||
|  |  | ||||||
|     def retrieve_oci(self) -> str: |     def retrieve_oci(self) -> str: | ||||||
|         """Get blueprint from an OCI registry""" |         """Get blueprint from an OCI registry""" | ||||||
|         url = urlparse(self.path) |         client = BlueprintOCIClient(self.path.replace("oci://", "https://")) | ||||||
|         ref = "latest" |  | ||||||
|         path = url.path[1:] |  | ||||||
|         if ":" in url.path: |  | ||||||
|             path, _, ref = path.partition(":") |  | ||||||
|         client = NewClient( |  | ||||||
|             f"https://{url.hostname}", |  | ||||||
|             WithUserAgent(authentik_user_agent()), |  | ||||||
|             WithUsernamePassword(url.username, url.password), |  | ||||||
|             WithDefaultName(path), |  | ||||||
|             WithDebug(True), |  | ||||||
|         ) |  | ||||||
|         LOGGER.info("Fetching OCI manifests for blueprint", instance=self) |  | ||||||
|         manifest_request = client.NewRequest( |  | ||||||
|             "GET", |  | ||||||
|             "/v2/<name>/manifests/<reference>", |  | ||||||
|             WithReference(ref), |  | ||||||
|         ).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json") |  | ||||||
|         try: |         try: | ||||||
|             manifest_response = client.Do(manifest_request) |             manifests = client.fetch_manifests() | ||||||
|             manifest_response.raise_for_status() |             return client.fetch_blobs(manifests) | ||||||
|         except RequestException as exc: |         except OCIException as exc: | ||||||
|             raise BlueprintRetrievalFailed(exc) from exc |             raise BlueprintRetrievalFailed(exc) from exc | ||||||
|         manifest = manifest_response.json() |  | ||||||
|         if "errors" in manifest: |  | ||||||
|             raise BlueprintRetrievalFailed(manifest["errors"]) |  | ||||||
|  |  | ||||||
|         blob = None |     def retrieve_file(self) -> str: | ||||||
|         for layer in manifest.get("layers", []): |         """Get blueprint from path""" | ||||||
|             if layer.get("mediaType", "") == OCI_MEDIA_TYPE: |  | ||||||
|                 blob = layer.get("digest") |  | ||||||
|                 LOGGER.debug("Found layer with matching media type", instance=self, blob=blob) |  | ||||||
|         if not blob: |  | ||||||
|             raise BlueprintRetrievalFailed("Blob not found") |  | ||||||
|  |  | ||||||
|         blob_request = client.NewRequest( |  | ||||||
|             "GET", |  | ||||||
|             "/v2/<name>/blobs/<digest>", |  | ||||||
|             WithDigest(blob), |  | ||||||
|         ) |  | ||||||
|         try: |         try: | ||||||
|             blob_response = client.Do(blob_request) |             full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path)) | ||||||
|             blob_response.raise_for_status() |             with full_path.open("r", encoding="utf-8") as _file: | ||||||
|             return blob_response.text |                 return _file.read() | ||||||
|         except RequestException as exc: |         except (IOError, OSError) as exc: | ||||||
|             raise BlueprintRetrievalFailed(exc) from exc |             raise BlueprintRetrievalFailed(exc) from exc | ||||||
|  |  | ||||||
|     def retrieve(self) -> str: |     def retrieve(self) -> str: | ||||||
|         """Retrieve blueprint contents""" |         """Retrieve blueprint contents""" | ||||||
|         if self.path.startswith("oci://"): |         if self.path.startswith("oci://"): | ||||||
|             return self.retrieve_oci() |             return self.retrieve_oci() | ||||||
|         full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path)) |         if self.path != "": | ||||||
|         with full_path.open("r", encoding="utf-8") as _file: |             return self.retrieve_file() | ||||||
|             return _file.read() |         return self.content | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Serializer: |     def serializer(self) -> Serializer: | ||||||
|  | |||||||
							
								
								
									
										51
									
								
								authentik/blueprints/tests/fixtures/tags.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										51
									
								
								authentik/blueprints/tests/fixtures/tags.yaml
									
									
									
									
										vendored
									
									
								
							| @ -3,6 +3,12 @@ context: | |||||||
|     foo: bar |     foo: bar | ||||||
|     policy_property: name |     policy_property: name | ||||||
|     policy_property_value: foo-bar-baz-qux |     policy_property_value: foo-bar-baz-qux | ||||||
|  |     sequence: | ||||||
|  |     - foo | ||||||
|  |     - bar | ||||||
|  |     mapping: | ||||||
|  |       key1: value | ||||||
|  |       key2: 2 | ||||||
| entries: | entries: | ||||||
|     - model: !Format ["%s", authentik_sources_oauth.oauthsource] |     - model: !Format ["%s", authentik_sources_oauth.oauthsource] | ||||||
|       state: !Format ["%s", present] |       state: !Format ["%s", present] | ||||||
| @ -19,7 +25,7 @@ entries: | |||||||
|                   [slug, default-source-authentication], |                   [slug, default-source-authentication], | ||||||
|               ] |               ] | ||||||
|           enrollment_flow: |           enrollment_flow: | ||||||
|               !Find [authentik_flows.Flow, [slug, default-source-enrollment]] |               !Find [!Format  ["%s", authentik_flows.Flow], [slug, default-source-enrollment]] | ||||||
|     - attrs: |     - attrs: | ||||||
|           expression: return True |           expression: return True | ||||||
|       identifiers: |       identifiers: | ||||||
| @ -92,6 +98,49 @@ entries: | |||||||
|                   ] |                   ] | ||||||
|               if_true_simple: !If [!Context foo, true, text] |               if_true_simple: !If [!Context foo, true, text] | ||||||
|               if_false_simple: !If [null, false, 2] |               if_false_simple: !If [null, false, 2] | ||||||
|  |               enumerate_mapping_to_mapping: !Enumerate [ | ||||||
|  |                   !Context mapping, | ||||||
|  |                   MAP, | ||||||
|  |                   [!Format ["prefix-%s", !Index 0], !Format ["other-prefix-%s", !Value 0]] | ||||||
|  |               ] | ||||||
|  |               enumerate_mapping_to_sequence: !Enumerate [ | ||||||
|  |                   !Context mapping, | ||||||
|  |                   SEQ, | ||||||
|  |                   !Format ["prefixed-pair-%s-%s", !Index 0, !Value 0] | ||||||
|  |               ] | ||||||
|  |               enumerate_sequence_to_sequence: !Enumerate [ | ||||||
|  |                   !Context sequence, | ||||||
|  |                   SEQ, | ||||||
|  |                   !Format ["prefixed-items-%s-%s", !Index 0, !Value 0] | ||||||
|  |               ] | ||||||
|  |               enumerate_sequence_to_mapping: !Enumerate [ | ||||||
|  |                   !Context sequence, | ||||||
|  |                   MAP, | ||||||
|  |                   [!Format ["index: %d", !Index 0], !Value 0] | ||||||
|  |               ] | ||||||
|  |               nested_complex_enumeration: !Enumerate [ | ||||||
|  |                   !Context sequence, | ||||||
|  |                   MAP, | ||||||
|  |                   [ | ||||||
|  |                       !Index 0, | ||||||
|  |                       !Enumerate [ | ||||||
|  |                           !Context mapping, | ||||||
|  |                           MAP, | ||||||
|  |                           [ | ||||||
|  |                               !Format ["%s", !Index 0], | ||||||
|  |                               [ | ||||||
|  |                                   !Enumerate [!Value 2, SEQ, !Format ["prefixed-%s", !Value 0]], | ||||||
|  |                                   { | ||||||
|  |                                     outer_value: !Value 1, | ||||||
|  |                                     outer_index: !Index 1, | ||||||
|  |                                     middle_value: !Value 0, | ||||||
|  |                                     middle_index: !Index 0 | ||||||
|  |                                   } | ||||||
|  |                               ] | ||||||
|  |                           ] | ||||||
|  |                       ] | ||||||
|  |                   ] | ||||||
|  |               ] | ||||||
|       identifiers: |       identifiers: | ||||||
|           name: test |           name: test | ||||||
|       conditions: |       conditions: | ||||||
|  | |||||||
| @ -16,7 +16,7 @@ def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable: | |||||||
|     """Test serializer""" |     """Test serializer""" | ||||||
|  |  | ||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|         if test_model._meta.abstract: |         if test_model._meta.abstract:  # pragma: no cover | ||||||
|             return |             return | ||||||
|         model_class = test_model() |         model_class = test_model() | ||||||
|         self.assertTrue(isinstance(model_class, SerializerModel)) |         self.assertTrue(isinstance(model_class, SerializerModel)) | ||||||
|  | |||||||
| @ -2,7 +2,8 @@ | |||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
| from requests_mock import Mocker | from requests_mock import Mocker | ||||||
|  |  | ||||||
| from authentik.blueprints.models import OCI_MEDIA_TYPE, BlueprintInstance, BlueprintRetrievalFailed | from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed | ||||||
|  | from authentik.blueprints.v1.oci import OCI_MEDIA_TYPE | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestBlueprintOCI(TransactionTestCase): | class TestBlueprintOCI(TransactionTestCase): | ||||||
| @ -26,8 +27,8 @@ class TestBlueprintOCI(TransactionTestCase): | |||||||
|  |  | ||||||
|             self.assertEqual( |             self.assertEqual( | ||||||
|                 BlueprintInstance( |                 BlueprintInstance( | ||||||
|                     path="https://ghcr.io/goauthentik/blueprints/test:latest" |                     path="oci://ghcr.io/goauthentik/blueprints/test:latest" | ||||||
|                 ).retrieve_oci(), |                 ).retrieve(), | ||||||
|                 "foo", |                 "foo", | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
| @ -40,7 +41,7 @@ class TestBlueprintOCI(TransactionTestCase): | |||||||
|  |  | ||||||
|             with self.assertRaises(BlueprintRetrievalFailed): |             with self.assertRaises(BlueprintRetrievalFailed): | ||||||
|                 BlueprintInstance( |                 BlueprintInstance( | ||||||
|                     path="https://ghcr.io/goauthentik/blueprints/test:latest" |                     path="oci://ghcr.io/goauthentik/blueprints/test:latest" | ||||||
|                 ).retrieve_oci() |                 ).retrieve_oci() | ||||||
|  |  | ||||||
|     def test_manifests_error_response(self): |     def test_manifests_error_response(self): | ||||||
| @ -53,7 +54,7 @@ class TestBlueprintOCI(TransactionTestCase): | |||||||
|  |  | ||||||
|             with self.assertRaises(BlueprintRetrievalFailed): |             with self.assertRaises(BlueprintRetrievalFailed): | ||||||
|                 BlueprintInstance( |                 BlueprintInstance( | ||||||
|                     path="https://ghcr.io/goauthentik/blueprints/test:latest" |                     path="oci://ghcr.io/goauthentik/blueprints/test:latest" | ||||||
|                 ).retrieve_oci() |                 ).retrieve_oci() | ||||||
|  |  | ||||||
|     def test_no_matching_blob(self): |     def test_no_matching_blob(self): | ||||||
| @ -72,7 +73,7 @@ class TestBlueprintOCI(TransactionTestCase): | |||||||
|             ) |             ) | ||||||
|             with self.assertRaises(BlueprintRetrievalFailed): |             with self.assertRaises(BlueprintRetrievalFailed): | ||||||
|                 BlueprintInstance( |                 BlueprintInstance( | ||||||
|                     path="https://ghcr.io/goauthentik/blueprints/test:latest" |                     path="oci://ghcr.io/goauthentik/blueprints/test:latest" | ||||||
|                 ).retrieve_oci() |                 ).retrieve_oci() | ||||||
|  |  | ||||||
|     def test_blob_error(self): |     def test_blob_error(self): | ||||||
| @ -93,5 +94,5 @@ class TestBlueprintOCI(TransactionTestCase): | |||||||
|  |  | ||||||
|             with self.assertRaises(BlueprintRetrievalFailed): |             with self.assertRaises(BlueprintRetrievalFailed): | ||||||
|                 BlueprintInstance( |                 BlueprintInstance( | ||||||
|                     path="https://ghcr.io/goauthentik/blueprints/test:latest" |                     path="oci://ghcr.io/goauthentik/blueprints/test:latest" | ||||||
|                 ).retrieve_oci() |                 ).retrieve_oci() | ||||||
|  | |||||||
| @ -162,6 +162,61 @@ class TestBlueprintsV1(TransactionTestCase): | |||||||
|                     "if_false_complex": ["list", "with", "items", "foo-bar"], |                     "if_false_complex": ["list", "with", "items", "foo-bar"], | ||||||
|                     "if_true_simple": True, |                     "if_true_simple": True, | ||||||
|                     "if_false_simple": 2, |                     "if_false_simple": 2, | ||||||
|  |                     "enumerate_mapping_to_mapping": { | ||||||
|  |                         "prefix-key1": "other-prefix-value", | ||||||
|  |                         "prefix-key2": "other-prefix-2", | ||||||
|  |                     }, | ||||||
|  |                     "enumerate_mapping_to_sequence": [ | ||||||
|  |                         "prefixed-pair-key1-value", | ||||||
|  |                         "prefixed-pair-key2-2", | ||||||
|  |                     ], | ||||||
|  |                     "enumerate_sequence_to_sequence": [ | ||||||
|  |                         "prefixed-items-0-foo", | ||||||
|  |                         "prefixed-items-1-bar", | ||||||
|  |                     ], | ||||||
|  |                     "enumerate_sequence_to_mapping": {"index: 0": "foo", "index: 1": "bar"}, | ||||||
|  |                     "nested_complex_enumeration": { | ||||||
|  |                         "0": { | ||||||
|  |                             "key1": [ | ||||||
|  |                                 ["prefixed-f", "prefixed-o", "prefixed-o"], | ||||||
|  |                                 { | ||||||
|  |                                     "outer_value": "foo", | ||||||
|  |                                     "outer_index": 0, | ||||||
|  |                                     "middle_value": "value", | ||||||
|  |                                     "middle_index": "key1", | ||||||
|  |                                 }, | ||||||
|  |                             ], | ||||||
|  |                             "key2": [ | ||||||
|  |                                 ["prefixed-f", "prefixed-o", "prefixed-o"], | ||||||
|  |                                 { | ||||||
|  |                                     "outer_value": "foo", | ||||||
|  |                                     "outer_index": 0, | ||||||
|  |                                     "middle_value": 2, | ||||||
|  |                                     "middle_index": "key2", | ||||||
|  |                                 }, | ||||||
|  |                             ], | ||||||
|  |                         }, | ||||||
|  |                         "1": { | ||||||
|  |                             "key1": [ | ||||||
|  |                                 ["prefixed-b", "prefixed-a", "prefixed-r"], | ||||||
|  |                                 { | ||||||
|  |                                     "outer_value": "bar", | ||||||
|  |                                     "outer_index": 1, | ||||||
|  |                                     "middle_value": "value", | ||||||
|  |                                     "middle_index": "key1", | ||||||
|  |                                 }, | ||||||
|  |                             ], | ||||||
|  |                             "key2": [ | ||||||
|  |                                 ["prefixed-b", "prefixed-a", "prefixed-r"], | ||||||
|  |                                 { | ||||||
|  |                                     "outer_value": "bar", | ||||||
|  |                                     "outer_index": 1, | ||||||
|  |                                     "middle_value": 2, | ||||||
|  |                                     "middle_index": "key2", | ||||||
|  |                                 }, | ||||||
|  |                             ], | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -43,3 +43,28 @@ class TestBlueprintsV1API(APITestCase): | |||||||
|                     "6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522" |                     "6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522" | ||||||
|                 ), |                 ), | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  |     def test_api_blank(self): | ||||||
|  |         """Test blank""" | ||||||
|  |         res = self.client.post( | ||||||
|  |             reverse("authentik_api:blueprintinstance-list"), | ||||||
|  |             data={ | ||||||
|  |                 "name": "foo", | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(res.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             res.content.decode(), {"non_field_errors": ["Either path or content must be set."]} | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_api_content(self): | ||||||
|  |         """Test blank""" | ||||||
|  |         res = self.client.post( | ||||||
|  |             reverse("authentik_api:blueprintinstance-list"), | ||||||
|  |             data={ | ||||||
|  |                 "name": "foo", | ||||||
|  |                 "content": '{"version": 3}', | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(res.status_code, 400) | ||||||
|  |         self.assertJSONEqual(res.content.decode(), {"content": ["Failed to validate blueprint"]}) | ||||||
|  | |||||||
| @ -1,13 +1,15 @@ | |||||||
| """transfer common classes""" | """transfer common classes""" | ||||||
| from collections import OrderedDict | from collections import OrderedDict | ||||||
|  | from copy import copy | ||||||
| from dataclasses import asdict, dataclass, field, is_dataclass | from dataclasses import asdict, dataclass, field, is_dataclass | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from functools import reduce | from functools import reduce | ||||||
| from operator import ixor | from operator import ixor | ||||||
| from os import getenv | from os import getenv | ||||||
| from typing import Any, Literal, Optional, Union | from typing import Any, Iterable, Literal, Mapping, Optional, Union | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
|  | from deepmerge import always_merger | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.db.models import Model, Q | from django.db.models import Model, Q | ||||||
| from rest_framework.fields import Field | from rest_framework.fields import Field | ||||||
| @ -64,11 +66,13 @@ class BlueprintEntry: | |||||||
|     identifiers: dict[str, Any] = field(default_factory=dict) |     identifiers: dict[str, Any] = field(default_factory=dict) | ||||||
|     attrs: Optional[dict[str, Any]] = field(default_factory=dict) |     attrs: Optional[dict[str, Any]] = field(default_factory=dict) | ||||||
|  |  | ||||||
|     # pylint: disable=invalid-name |  | ||||||
|     id: Optional[str] = None |     id: Optional[str] = None | ||||||
|  |  | ||||||
|     _state: BlueprintEntryState = field(default_factory=BlueprintEntryState) |     _state: BlueprintEntryState = field(default_factory=BlueprintEntryState) | ||||||
|  |  | ||||||
|  |     def __post_init__(self, *args, **kwargs) -> None: | ||||||
|  |         self.__tag_contexts: list["YAMLTagContext"] = [] | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry": |     def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry": | ||||||
|         """Convert a SerializerModel instance to a blueprint Entry""" |         """Convert a SerializerModel instance to a blueprint Entry""" | ||||||
| @ -85,17 +89,46 @@ class BlueprintEntry: | |||||||
|             attrs=all_attrs, |             attrs=all_attrs, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def _get_tag_context( | ||||||
|  |         self, | ||||||
|  |         depth: int = 0, | ||||||
|  |         context_tag_type: Optional[type["YAMLTagContext"] | tuple["YAMLTagContext", ...]] = None, | ||||||
|  |     ) -> "YAMLTagContext": | ||||||
|  |         """Get a YAMLTagContext object located at a certain depth in the tag tree""" | ||||||
|  |         if depth < 0: | ||||||
|  |             raise ValueError("depth must be a positive number or zero") | ||||||
|  |  | ||||||
|  |         if context_tag_type: | ||||||
|  |             contexts = [x for x in self.__tag_contexts if isinstance(x, context_tag_type)] | ||||||
|  |         else: | ||||||
|  |             contexts = self.__tag_contexts | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             return contexts[-(depth + 1)] | ||||||
|  |         except IndexError: | ||||||
|  |             raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}") | ||||||
|  |  | ||||||
|     def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any: |     def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any: | ||||||
|         """Check if we have any special tags that need handling""" |         """Check if we have any special tags that need handling""" | ||||||
|  |         val = copy(value) | ||||||
|  |  | ||||||
|  |         if isinstance(value, YAMLTagContext): | ||||||
|  |             self.__tag_contexts.append(value) | ||||||
|  |  | ||||||
|         if isinstance(value, YAMLTag): |         if isinstance(value, YAMLTag): | ||||||
|             return value.resolve(self, blueprint) |             val = value.resolve(self, blueprint) | ||||||
|  |  | ||||||
|         if isinstance(value, dict): |         if isinstance(value, dict): | ||||||
|             for key, inner_value in value.items(): |             for key, inner_value in value.items(): | ||||||
|                 value[key] = self.tag_resolver(inner_value, blueprint) |                 val[key] = self.tag_resolver(inner_value, blueprint) | ||||||
|         if isinstance(value, list): |         if isinstance(value, list): | ||||||
|             for idx, inner_value in enumerate(value): |             for idx, inner_value in enumerate(value): | ||||||
|                 value[idx] = self.tag_resolver(inner_value, blueprint) |                 val[idx] = self.tag_resolver(inner_value, blueprint) | ||||||
|         return value |  | ||||||
|  |         if isinstance(value, YAMLTagContext): | ||||||
|  |             self.__tag_contexts.pop() | ||||||
|  |  | ||||||
|  |         return val | ||||||
|  |  | ||||||
|     def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]: |     def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]: | ||||||
|         """Get attributes of this entry, with all yaml tags resolved""" |         """Get attributes of this entry, with all yaml tags resolved""" | ||||||
| @ -145,12 +178,19 @@ class YAMLTag: | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class YAMLTagContext: | ||||||
|  |     """Base class for all YAML Tag Contexts""" | ||||||
|  |  | ||||||
|  |     def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|  |         """Implement yaml tag context logic""" | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
| class KeyOf(YAMLTag): | class KeyOf(YAMLTag): | ||||||
|     """Reference another object by their ID""" |     """Reference another object by their ID""" | ||||||
|  |  | ||||||
|     id_from: str |     id_from: str | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.id_from = node.value |         self.id_from = node.value | ||||||
| @ -177,7 +217,6 @@ class Env(YAMLTag): | |||||||
|     key: str |     key: str | ||||||
|     default: Optional[Any] |     default: Optional[Any] | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.default = None |         self.default = None | ||||||
| @ -197,7 +236,6 @@ class Context(YAMLTag): | |||||||
|     key: str |     key: str | ||||||
|     default: Optional[Any] |     default: Optional[Any] | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.default = None |         self.default = None | ||||||
| @ -220,7 +258,6 @@ class Format(YAMLTag): | |||||||
|     format_string: str |     format_string: str | ||||||
|     args: list[Any] |     args: list[Any] | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.format_string = node.value[0].value |         self.format_string = node.value[0].value | ||||||
| @ -245,15 +282,12 @@ class Format(YAMLTag): | |||||||
| class Find(YAMLTag): | class Find(YAMLTag): | ||||||
|     """Find any object""" |     """Find any object""" | ||||||
|  |  | ||||||
|     model_name: str |     model_name: str | YAMLTag | ||||||
|     conditions: list[list] |     conditions: list[list] | ||||||
|  |  | ||||||
|     model_class: type[Model] |  | ||||||
|  |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.model_name = node.value[0].value |         self.model_name = loader.construct_object(node.value[0]) | ||||||
|         self.model_class = apps.get_model(*self.model_name.split(".")) |  | ||||||
|         self.conditions = [] |         self.conditions = [] | ||||||
|         for raw_node in node.value[1:]: |         for raw_node in node.value[1:]: | ||||||
|             values = [] |             values = [] | ||||||
| @ -262,6 +296,13 @@ class Find(YAMLTag): | |||||||
|             self.conditions.append(values) |             self.conditions.append(values) | ||||||
|  |  | ||||||
|     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: |     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|  |         if isinstance(self.model_name, YAMLTag): | ||||||
|  |             model_name = self.model_name.resolve(entry, blueprint) | ||||||
|  |         else: | ||||||
|  |             model_name = self.model_name | ||||||
|  |  | ||||||
|  |         model_class = apps.get_model(*model_name.split(".")) | ||||||
|  |  | ||||||
|         query = Q() |         query = Q() | ||||||
|         for cond in self.conditions: |         for cond in self.conditions: | ||||||
|             if isinstance(cond[0], YAMLTag): |             if isinstance(cond[0], YAMLTag): | ||||||
| @ -273,7 +314,7 @@ class Find(YAMLTag): | |||||||
|             else: |             else: | ||||||
|                 query_value = cond[1] |                 query_value = cond[1] | ||||||
|             query &= Q(**{query_key: query_value}) |             query &= Q(**{query_key: query_value}) | ||||||
|         instance = self.model_class.objects.filter(query).first() |         instance = model_class.objects.filter(query).first() | ||||||
|         if instance: |         if instance: | ||||||
|             return instance.pk |             return instance.pk | ||||||
|         return None |         return None | ||||||
| @ -296,7 +337,6 @@ class Condition(YAMLTag): | |||||||
|         "XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]), |         "XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]), | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.mode = node.value[0].value |         self.mode = node.value[0].value | ||||||
| @ -329,7 +369,6 @@ class If(YAMLTag): | |||||||
|     when_true: Any |     when_true: Any | ||||||
|     when_false: Any |     when_false: Any | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: |     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.condition = loader.construct_object(node.value[0]) |         self.condition = loader.construct_object(node.value[0]) | ||||||
| @ -351,6 +390,133 @@ class If(YAMLTag): | |||||||
|             raise EntryInvalidError(exc) |             raise EntryInvalidError(exc) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Enumerate(YAMLTag, YAMLTagContext): | ||||||
|  |     """Iterate over an iterable.""" | ||||||
|  |  | ||||||
|  |     iterable: YAMLTag | Iterable | ||||||
|  |     item_body: Any | ||||||
|  |     output_body: Literal["SEQ", "MAP"] | ||||||
|  |  | ||||||
|  |     _OUTPUT_BODIES = { | ||||||
|  |         "SEQ": (list, lambda a, b: [*a, b]), | ||||||
|  |         "MAP": ( | ||||||
|  |             dict, | ||||||
|  |             lambda a, b: always_merger.merge( | ||||||
|  |                 a, {b[0]: b[1]} if isinstance(b, (tuple, list)) else b | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: | ||||||
|  |         super().__init__() | ||||||
|  |         self.iterable = loader.construct_object(node.value[0]) | ||||||
|  |         self.output_body = node.value[1].value | ||||||
|  |         self.item_body = loader.construct_object(node.value[2]) | ||||||
|  |         self.__current_context: tuple[Any, Any] = tuple() | ||||||
|  |  | ||||||
|  |     def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|  |         return self.__current_context | ||||||
|  |  | ||||||
|  |     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|  |         if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0: | ||||||
|  |             raise EntryInvalidError( | ||||||
|  |                 f"{self.__class__.__name__} tag's iterable references this tag's context. " | ||||||
|  |                 "This is a noop. Check you are setting depth bigger than 0." | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         if isinstance(self.iterable, YAMLTag): | ||||||
|  |             iterable = self.iterable.resolve(entry, blueprint) | ||||||
|  |         else: | ||||||
|  |             iterable = self.iterable | ||||||
|  |  | ||||||
|  |         if not isinstance(iterable, Iterable): | ||||||
|  |             raise EntryInvalidError( | ||||||
|  |                 f"{self.__class__.__name__}'s iterable must be an iterable " | ||||||
|  |                 "such as a sequence or a mapping" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         if isinstance(iterable, Mapping): | ||||||
|  |             iterable = tuple(iterable.items()) | ||||||
|  |         else: | ||||||
|  |             iterable = tuple(enumerate(iterable)) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()] | ||||||
|  |         except KeyError as exc: | ||||||
|  |             raise EntryInvalidError(exc) | ||||||
|  |  | ||||||
|  |         result = output_class() | ||||||
|  |  | ||||||
|  |         self.__current_context = tuple() | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             for item in iterable: | ||||||
|  |                 self.__current_context = item | ||||||
|  |                 resolved_body = entry.tag_resolver(self.item_body, blueprint) | ||||||
|  |                 result = add_fn(result, resolved_body) | ||||||
|  |                 if not isinstance(result, output_class): | ||||||
|  |                     raise EntryInvalidError( | ||||||
|  |                         f"Invalid {self.__class__.__name__} item found: {resolved_body}" | ||||||
|  |                     ) | ||||||
|  |         finally: | ||||||
|  |             self.__current_context = tuple() | ||||||
|  |  | ||||||
|  |         return result | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EnumeratedItem(YAMLTag): | ||||||
|  |     """Get the current item value and index provided by an Enumerate tag context""" | ||||||
|  |  | ||||||
|  |     depth: int | ||||||
|  |  | ||||||
|  |     _SUPPORTED_CONTEXT_TAGS = (Enumerate,) | ||||||
|  |  | ||||||
|  |     def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None: | ||||||
|  |         super().__init__() | ||||||
|  |         self.depth = int(node.value) | ||||||
|  |  | ||||||
|  |     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|  |         try: | ||||||
|  |             context_tag: Enumerate = entry._get_tag_context( | ||||||
|  |                 depth=self.depth, | ||||||
|  |                 context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS, | ||||||
|  |             ) | ||||||
|  |         except ValueError as exc: | ||||||
|  |             if self.depth == 0: | ||||||
|  |                 raise EntryInvalidError( | ||||||
|  |                     f"{self.__class__.__name__} tags are only usable " | ||||||
|  |                     f"inside an {Enumerate.__name__} tag" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |             raise EntryInvalidError(f"{self.__class__.__name__} tag: {exc}") | ||||||
|  |  | ||||||
|  |         return context_tag.get_context(entry, blueprint) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Index(EnumeratedItem): | ||||||
|  |     """Get the current item index provided by an Enumerate tag context""" | ||||||
|  |  | ||||||
|  |     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|  |         context = super().resolve(entry, blueprint) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             return context[0] | ||||||
|  |         except IndexError:  # pragma: no cover | ||||||
|  |             raise EntryInvalidError(f"Empty/invalid context: {context}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Value(EnumeratedItem): | ||||||
|  |     """Get the current item value provided by an Enumerate tag context""" | ||||||
|  |  | ||||||
|  |     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|  |         context = super().resolve(entry, blueprint) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             return context[1] | ||||||
|  |         except IndexError:  # pragma: no cover | ||||||
|  |             raise EntryInvalidError(f"Empty/invalid context: {context}") | ||||||
|  |  | ||||||
|  |  | ||||||
| class BlueprintDumper(SafeDumper): | class BlueprintDumper(SafeDumper): | ||||||
|     """Dump dataclasses to yaml""" |     """Dump dataclasses to yaml""" | ||||||
|  |  | ||||||
| @ -394,6 +560,9 @@ class BlueprintLoader(SafeLoader): | |||||||
|         self.add_constructor("!Condition", Condition) |         self.add_constructor("!Condition", Condition) | ||||||
|         self.add_constructor("!If", If) |         self.add_constructor("!If", If) | ||||||
|         self.add_constructor("!Env", Env) |         self.add_constructor("!Env", Env) | ||||||
|  |         self.add_constructor("!Enumerate", Enumerate) | ||||||
|  |         self.add_constructor("!Value", Value) | ||||||
|  |         self.add_constructor("!Index", Index) | ||||||
|  |  | ||||||
|  |  | ||||||
| class EntryInvalidError(SentryIgnoredException): | class EntryInvalidError(SentryIgnoredException): | ||||||
|  | |||||||
							
								
								
									
										98
									
								
								authentik/blueprints/v1/oci.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								authentik/blueprints/v1/oci.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,98 @@ | |||||||
|  | """OCI Client""" | ||||||
|  | from typing import Any | ||||||
|  | from urllib.parse import ParseResult, urlparse | ||||||
|  |  | ||||||
|  | from opencontainers.distribution.reggie import ( | ||||||
|  |     NewClient, | ||||||
|  |     WithDebug, | ||||||
|  |     WithDefaultName, | ||||||
|  |     WithDigest, | ||||||
|  |     WithReference, | ||||||
|  |     WithUserAgent, | ||||||
|  |     WithUsernamePassword, | ||||||
|  | ) | ||||||
|  | from requests.exceptions import RequestException | ||||||
|  | from structlog import get_logger | ||||||
|  | from structlog.stdlib import BoundLogger | ||||||
|  |  | ||||||
|  | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  | from authentik.lib.utils.http import authentik_user_agent | ||||||
|  |  | ||||||
|  | OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OCIException(SentryIgnoredException): | ||||||
|  |     """OCI-related errors""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BlueprintOCIClient: | ||||||
|  |     """Blueprint OCI Client""" | ||||||
|  |  | ||||||
|  |     url: ParseResult | ||||||
|  |     sanitized_url: str | ||||||
|  |     logger: BoundLogger | ||||||
|  |     ref: str | ||||||
|  |     client: NewClient | ||||||
|  |  | ||||||
|  |     def __init__(self, url: str) -> None: | ||||||
|  |         self._parse_url(url) | ||||||
|  |         self.logger = get_logger().bind(url=self.sanitized_url) | ||||||
|  |  | ||||||
|  |         self.ref = "latest" | ||||||
|  |         path = self.url.path[1:] | ||||||
|  |         if ":" in self.url.path: | ||||||
|  |             path, _, self.ref = path.partition(":") | ||||||
|  |         self.client = NewClient( | ||||||
|  |             f"https://{self.url.hostname}", | ||||||
|  |             WithUserAgent(authentik_user_agent()), | ||||||
|  |             WithUsernamePassword(self.url.username, self.url.password), | ||||||
|  |             WithDefaultName(path), | ||||||
|  |             WithDebug(True), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def _parse_url(self, url: str): | ||||||
|  |         self.url = urlparse(url) | ||||||
|  |         netloc = self.url.netloc | ||||||
|  |         if "@" in netloc: | ||||||
|  |             netloc = netloc[netloc.index("@") + 1 :] | ||||||
|  |         self.sanitized_url = self.url._replace(netloc=netloc).geturl() | ||||||
|  |  | ||||||
|  |     def fetch_manifests(self) -> dict[str, Any]: | ||||||
|  |         """Fetch manifests for ref""" | ||||||
|  |         self.logger.info("Fetching OCI manifests for blueprint") | ||||||
|  |         manifest_request = self.client.NewRequest( | ||||||
|  |             "GET", | ||||||
|  |             "/v2/<name>/manifests/<reference>", | ||||||
|  |             WithReference(self.ref), | ||||||
|  |         ).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json") | ||||||
|  |         try: | ||||||
|  |             manifest_response = self.client.Do(manifest_request) | ||||||
|  |             manifest_response.raise_for_status() | ||||||
|  |         except RequestException as exc: | ||||||
|  |             raise OCIException(exc) from exc | ||||||
|  |         manifest = manifest_response.json() | ||||||
|  |         if "errors" in manifest: | ||||||
|  |             raise OCIException(manifest["errors"]) | ||||||
|  |         return manifest | ||||||
|  |  | ||||||
|  |     def fetch_blobs(self, manifest: dict[str, Any]): | ||||||
|  |         """Fetch blob based on manifest info""" | ||||||
|  |         blob = None | ||||||
|  |         for layer in manifest.get("layers", []): | ||||||
|  |             if layer.get("mediaType", "") == OCI_MEDIA_TYPE: | ||||||
|  |                 blob = layer.get("digest") | ||||||
|  |                 self.logger.debug("Found layer with matching media type", blob=blob) | ||||||
|  |         if not blob: | ||||||
|  |             raise OCIException("Blob not found") | ||||||
|  |  | ||||||
|  |         blob_request = self.client.NewRequest( | ||||||
|  |             "GET", | ||||||
|  |             "/v2/<name>/blobs/<digest>", | ||||||
|  |             WithDigest(blob), | ||||||
|  |         ) | ||||||
|  |         try: | ||||||
|  |             blob_response = self.client.Do(blob_request) | ||||||
|  |             blob_response.raise_for_status() | ||||||
|  |             return blob_response.text | ||||||
|  |         except RequestException as exc: | ||||||
|  |             raise OCIException(exc) from exc | ||||||
| @ -1,8 +1,10 @@ | |||||||
| """Application API Views""" | """Application API Views""" | ||||||
|  | from datetime import timedelta | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
|  | from django.db.models.functions import ExtractHour | ||||||
| from django.http.response import HttpResponseBadRequest | from django.http.response import HttpResponseBadRequest | ||||||
| from django.shortcuts import get_object_or_404 | from django.shortcuts import get_object_or_404 | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| @ -225,7 +227,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         methods=["POST"], |         methods=["POST"], | ||||||
|         parser_classes=(MultiPartParser,), |         parser_classes=(MultiPartParser,), | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_icon(self, request: Request, slug: str): |     def set_icon(self, request: Request, slug: str): | ||||||
|         """Set application icon""" |         """Set application icon""" | ||||||
|         app: Application = self.get_object() |         app: Application = self.get_object() | ||||||
| @ -245,7 +246,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         filter_backends=[], |         filter_backends=[], | ||||||
|         methods=["POST"], |         methods=["POST"], | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_icon_url(self, request: Request, slug: str): |     def set_icon_url(self, request: Request, slug: str): | ||||||
|         """Set application icon (as URL)""" |         """Set application icon (as URL)""" | ||||||
|         app: Application = self.get_object() |         app: Application = self.get_object() | ||||||
| @ -254,15 +254,14 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|     @permission_required("authentik_core.view_application", ["authentik_events.view_event"]) |     @permission_required("authentik_core.view_application", ["authentik_events.view_event"]) | ||||||
|     @extend_schema(responses={200: CoordinateSerializer(many=True)}) |     @extend_schema(responses={200: CoordinateSerializer(many=True)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def metrics(self, request: Request, slug: str): |     def metrics(self, request: Request, slug: str): | ||||||
|         """Metrics for application logins""" |         """Metrics for application logins""" | ||||||
|         app = self.get_object() |         app = self.get_object() | ||||||
|         return Response( |         return Response( | ||||||
|             get_objects_for_user(request.user, "authentik_events.view_event") |             get_objects_for_user(request.user, "authentik_events.view_event").filter( | ||||||
|             .filter( |  | ||||||
|                 action=EventAction.AUTHORIZE_APPLICATION, |                 action=EventAction.AUTHORIZE_APPLICATION, | ||||||
|                 context__authorized_application__pk=app.pk.hex, |                 context__authorized_application__pk=app.pk.hex, | ||||||
|             ) |             ) | ||||||
|             .get_events_per_hour() |             # 3 data points per day, so 8 hour spans | ||||||
|  |             .get_events_per(timedelta(days=7), ExtractHour, 7 * 3) | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.db.models.query import QuerySet | |||||||
| from django.http import Http404 | from django.http import Http404 | ||||||
| from django_filters.filters import CharFilter, ModelMultipleChoiceFilter | from django_filters.filters import CharFilter, ModelMultipleChoiceFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, IntegerField, JSONField | from rest_framework.fields import CharField, IntegerField, JSONField | ||||||
| @ -17,7 +17,7 @@ from rest_framework_guardian.filters import ObjectPermissionsFilter | |||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import is_dict | from authentik.core.api.utils import PassiveSerializer, is_dict | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -96,7 +96,6 @@ class GroupFilter(FilterSet): | |||||||
|         queryset=User.objects.all(), |         queryset=User.objects.all(), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def filter_attributes(self, queryset, name, value): |     def filter_attributes(self, queryset, name, value): | ||||||
|         """Filter attributes by query args""" |         """Filter attributes by query args""" | ||||||
|         try: |         try: | ||||||
| @ -120,6 +119,12 @@ class GroupFilter(FilterSet): | |||||||
|         fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"] |         fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class UserAccountSerializer(PassiveSerializer): | ||||||
|  |     """Account adding/removing operations""" | ||||||
|  |  | ||||||
|  |     pk = IntegerField(required=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupViewSet(UsedByMixin, ModelViewSet): | class GroupViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Group Viewset""" |     """Group Viewset""" | ||||||
|  |  | ||||||
| @ -144,19 +149,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_core.add_user"]) |     @permission_required(None, ["authentik_core.add_user"]) | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=inline_serializer( |         request=UserAccountSerializer, | ||||||
|             "UserAccountSerializer", |  | ||||||
|             { |  | ||||||
|                 "pk": IntegerField(required=True), |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|         responses={ |         responses={ | ||||||
|             204: OpenApiResponse(description="User added"), |             204: OpenApiResponse(description="User added"), | ||||||
|             404: OpenApiResponse(description="User not found"), |             404: OpenApiResponse(description="User not found"), | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[]) |     @action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument, invalid-name |  | ||||||
|     def add_user(self, request: Request, pk: str) -> Response: |     def add_user(self, request: Request, pk: str) -> Response: | ||||||
|         """Add user to group""" |         """Add user to group""" | ||||||
|         group: Group = self.get_object() |         group: Group = self.get_object() | ||||||
| @ -174,19 +173,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_core.add_user"]) |     @permission_required(None, ["authentik_core.add_user"]) | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=inline_serializer( |         request=UserAccountSerializer, | ||||||
|             "UserAccountSerializer", |  | ||||||
|             { |  | ||||||
|                 "pk": IntegerField(required=True), |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|         responses={ |         responses={ | ||||||
|             204: OpenApiResponse(description="User added"), |             204: OpenApiResponse(description="User added"), | ||||||
|             404: OpenApiResponse(description="User not found"), |             404: OpenApiResponse(description="User not found"), | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[]) |     @action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument, invalid-name |  | ||||||
|     def remove_user(self, request: Request, pk: str) -> Response: |     def remove_user(self, request: Request, pk: str) -> Response: | ||||||
|         """Add user to group""" |         """Add user to group""" | ||||||
|         group: Group = self.get_object() |         group: Group = self.get_object() | ||||||
|  | |||||||
| @ -117,7 +117,6 @@ class PropertyMappingViewSet( | |||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) | ||||||
|     # pylint: disable=unused-argument, invalid-name |  | ||||||
|     def test(self, request: Request, pk: str) -> Response: |     def test(self, request: Request, pk: str) -> Response: | ||||||
|         """Test Property Mapping""" |         """Test Property Mapping""" | ||||||
|         mapping: PropertyMapping = self.get_object() |         mapping: PropertyMapping = self.get_object() | ||||||
|  | |||||||
| @ -102,7 +102,6 @@ class SourceViewSet( | |||||||
|         methods=["POST"], |         methods=["POST"], | ||||||
|         parser_classes=(MultiPartParser,), |         parser_classes=(MultiPartParser,), | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_icon(self, request: Request, slug: str): |     def set_icon(self, request: Request, slug: str): | ||||||
|         """Set source icon""" |         """Set source icon""" | ||||||
|         source: Source = self.get_object() |         source: Source = self.get_object() | ||||||
| @ -122,7 +121,6 @@ class SourceViewSet( | |||||||
|         filter_backends=[], |         filter_backends=[], | ||||||
|         methods=["POST"], |         methods=["POST"], | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_icon_url(self, request: Request, slug: str): |     def set_icon_url(self, request: Request, slug: str): | ||||||
|         """Set source icon (as URL)""" |         """Set source icon (as URL)""" | ||||||
|         source: Source = self.get_object() |         source: Source = self.get_object() | ||||||
|  | |||||||
| @ -112,7 +112,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet): | |||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"]) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def view_key(self, request: Request, identifier: str) -> Response: |     def view_key(self, request: Request, identifier: str) -> Response: | ||||||
|         """Return token key and log access""" |         """Return token key and log access""" | ||||||
|         token: Token = self.get_object() |         token: Token = self.get_object() | ||||||
| @ -134,7 +133,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet): | |||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_key(self, request: Request, identifier: str) -> Response: |     def set_key(self, request: Request, identifier: str) -> Response: | ||||||
|         """Return token key and log access""" |         """Return token key and log access""" | ||||||
|         token: Token = self.get_object() |         token: Token = self.get_object() | ||||||
|  | |||||||
| @ -53,7 +53,7 @@ class UsedByMixin: | |||||||
|         responses={200: UsedBySerializer(many=True)}, |         responses={200: UsedBySerializer(many=True)}, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument, too-many-locals |     # pylint: disable=too-many-locals | ||||||
|     def used_by(self, request: Request, *args, **kwargs) -> Response: |     def used_by(self, request: Request, *args, **kwargs) -> Response: | ||||||
|         """Get a list of all objects that use this object""" |         """Get a list of all objects that use this object""" | ||||||
|         # pyright: reportGeneralTypeIssues=false |         # pyright: reportGeneralTypeIssues=false | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ from json import loads | |||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.contrib.auth import update_session_auth_hash | from django.contrib.auth import update_session_auth_hash | ||||||
|  | from django.db.models.functions import ExtractHour | ||||||
| from django.db.models.query import QuerySet | from django.db.models.query import QuerySet | ||||||
| from django.db.transaction import atomic | from django.db.transaction import atomic | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| @ -199,38 +200,44 @@ class SessionUserSerializer(PassiveSerializer): | |||||||
| class UserMetricsSerializer(PassiveSerializer): | class UserMetricsSerializer(PassiveSerializer): | ||||||
|     """User Metrics""" |     """User Metrics""" | ||||||
|  |  | ||||||
|     logins_per_1h = SerializerMethodField() |     logins = SerializerMethodField() | ||||||
|     logins_failed_per_1h = SerializerMethodField() |     logins_failed = SerializerMethodField() | ||||||
|     authorizations_per_1h = SerializerMethodField() |     authorizations = SerializerMethodField() | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_logins_per_1h(self, _): |     def get_logins(self, _): | ||||||
|         """Get successful logins per hour for the last 24 hours""" |         """Get successful logins per 8 hours for the last 7 days""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
|         return ( |         return ( | ||||||
|             get_objects_for_user(user, "authentik_events.view_event") |             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||||
|             .filter(action=EventAction.LOGIN, user__pk=user.pk) |                 action=EventAction.LOGIN, user__pk=user.pk | ||||||
|             .get_events_per_hour() |             ) | ||||||
|  |             # 3 data points per day, so 8 hour spans | ||||||
|  |             .get_events_per(timedelta(days=7), ExtractHour, 7 * 3) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_logins_failed_per_1h(self, _): |     def get_logins_failed(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per 8 hours for the last 7 days""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
|         return ( |         return ( | ||||||
|             get_objects_for_user(user, "authentik_events.view_event") |             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||||
|             .filter(action=EventAction.LOGIN_FAILED, context__username=user.username) |                 action=EventAction.LOGIN_FAILED, context__username=user.username | ||||||
|             .get_events_per_hour() |             ) | ||||||
|  |             # 3 data points per day, so 8 hour spans | ||||||
|  |             .get_events_per(timedelta(days=7), ExtractHour, 7 * 3) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_authorizations_per_1h(self, _): |     def get_authorizations(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per 8 hours for the last 7 days""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
|         return ( |         return ( | ||||||
|             get_objects_for_user(user, "authentik_events.view_event") |             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||||
|             .filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk) |                 action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk | ||||||
|             .get_events_per_hour() |             ) | ||||||
|  |             # 3 data points per day, so 8 hour spans | ||||||
|  |             .get_events_per(timedelta(days=7), ExtractHour, 7 * 3) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -262,7 +269,6 @@ class UsersFilter(FilterSet): | |||||||
|         queryset=Group.objects.all(), |         queryset=Group.objects.all(), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def filter_attributes(self, queryset, name, value): |     def filter_attributes(self, queryset, name, value): | ||||||
|         """Filter attributes by query args""" |         """Filter attributes by query args""" | ||||||
|         try: |         try: | ||||||
| @ -397,9 +403,8 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|                 return Response(data={"non_field_errors": [str(exc)]}, status=400) |                 return Response(data={"non_field_errors": [str(exc)]}, status=400) | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: SessionUserSerializer(many=False)}) |     @extend_schema(responses={200: SessionUserSerializer(many=False)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(url_path="me", url_name="me", detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name |     def user_me(self, request: Request) -> Response: | ||||||
|     def me(self, request: Request) -> Response: |  | ||||||
|         """Get information about current user""" |         """Get information about current user""" | ||||||
|         context = {"request": request} |         context = {"request": request} | ||||||
|         serializer = SessionUserSerializer( |         serializer = SessionUserSerializer( | ||||||
| @ -427,7 +432,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["POST"]) |     @action(detail=True, methods=["POST"]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def set_password(self, request: Request, pk: int) -> Response: |     def set_password(self, request: Request, pk: int) -> Response: | ||||||
|         """Set password for user""" |         """Set password for user""" | ||||||
|         user: User = self.get_object() |         user: User = self.get_object() | ||||||
| @ -445,7 +449,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) |     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) | ||||||
|     @extend_schema(responses={200: UserMetricsSerializer(many=False)}) |     @extend_schema(responses={200: UserMetricsSerializer(many=False)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def metrics(self, request: Request, pk: int) -> Response: |     def metrics(self, request: Request, pk: int) -> Response: | ||||||
|         """User metrics per 1h""" |         """User metrics per 1h""" | ||||||
|         user: User = self.get_object() |         user: User = self.get_object() | ||||||
| @ -461,7 +464,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def recovery(self, request: Request, pk: int) -> Response: |     def recovery(self, request: Request, pk: int) -> Response: | ||||||
|         """Create a temporary link that a user can use to recover their accounts""" |         """Create a temporary link that a user can use to recover their accounts""" | ||||||
|         link, _ = self._create_recovery_link() |         link, _ = self._create_recovery_link() | ||||||
| @ -486,7 +488,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def recovery_email(self, request: Request, pk: int) -> Response: |     def recovery_email(self, request: Request, pk: int) -> Response: | ||||||
|         """Create a temporary link that a user can use to recover their accounts""" |         """Create a temporary link that a user can use to recover their accounts""" | ||||||
|         for_user: User = self.get_object() |         for_user: User = self.get_object() | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """Property Mapping Evaluator""" | """Property Mapping Evaluator""" | ||||||
| from traceback import format_tb |  | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| @ -8,6 +7,7 @@ from django.http import HttpRequest | |||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.expression.evaluator import BaseEvaluator | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -38,7 +38,7 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|  |  | ||||||
|     def handle_error(self, exc: Exception, expression_source: str): |     def handle_error(self, exc: Exception, expression_source: str): | ||||||
|         """Exception Handler""" |         """Exception Handler""" | ||||||
|         error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)]) |         error_string = exception_to_string(exc) | ||||||
|         event = Event.new( |         event = Event.new( | ||||||
|             EventAction.PROPERTY_MAPPING_EXCEPTION, |             EventAction.PROPERTY_MAPPING_EXCEPTION, | ||||||
|             expression=expression_source, |             expression=expression_source, | ||||||
|  | |||||||
| @ -49,7 +49,6 @@ class Command(BaseCommand): | |||||||
|         return namespace |         return namespace | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def post_save_handler(sender, instance: Model, created: bool, **_): |     def post_save_handler(sender, instance: Model, created: bool, **_): | ||||||
|         """Signal handler for all object's post_save""" |         """Signal handler for all object's post_save""" | ||||||
|         if not should_log_model(instance): |         if not should_log_model(instance): | ||||||
| @ -65,7 +64,6 @@ class Command(BaseCommand): | |||||||
|         ).save() |         ).save() | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def pre_delete_handler(sender, instance: Model, **_): |     def pre_delete_handler(sender, instance: Model, **_): | ||||||
|         """Signal handler for all object's pre_delete""" |         """Signal handler for all object's pre_delete""" | ||||||
|         if not should_log_model(instance):  # pragma: no cover |         if not should_log_model(instance):  # pragma: no cover | ||||||
|  | |||||||
| @ -20,7 +20,6 @@ if TYPE_CHECKING: | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def post_save_application(sender: type[Model], instance, created: bool, **_): | def post_save_application(sender: type[Model], instance, created: bool, **_): | ||||||
|     """Clear user's application cache upon application creation""" |     """Clear user's application cache upon application creation""" | ||||||
|     from authentik.core.api.applications import user_app_cache_key |     from authentik.core.api.applications import user_app_cache_key | ||||||
| @ -36,7 +35,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_in) | @receiver(user_logged_in) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): | def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): | ||||||
|     """Create an AuthenticatedSession from request""" |     """Create an AuthenticatedSession from request""" | ||||||
|     from authentik.core.models import AuthenticatedSession |     from authentik.core.models import AuthenticatedSession | ||||||
| @ -47,7 +45,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_out) | @receiver(user_logged_out) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): | def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): | ||||||
|     """Delete AuthenticatedSession if it exists""" |     """Delete AuthenticatedSession if it exists""" | ||||||
|     from authentik.core.models import AuthenticatedSession |     from authentik.core.models import AuthenticatedSession | ||||||
|  | |||||||
| @ -48,7 +48,6 @@ class Action(Enum): | |||||||
| class MessageStage(StageView): | class MessageStage(StageView): | ||||||
|     """Show a pre-configured message after the flow is done""" |     """Show a pre-configured message after the flow is done""" | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         """Show a pre-configured message after the flow is done""" |         """Show a pre-configured message after the flow is done""" | ||||||
|         message = getattr(self.executor.current_stage, "message", "") |         message = getattr(self.executor.current_stage, "message", "") | ||||||
| @ -209,7 +208,6 @@ class SourceFlowManager: | |||||||
|             response.error_message = error.messages |             response.error_message = error.messages | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def get_stages_to_append(self, flow: Flow) -> list[Stage]: |     def get_stages_to_append(self, flow: Flow) -> list[Stage]: | ||||||
|         """Hook to override stages which are appended to the flow""" |         """Hook to override stages which are appended to the flow""" | ||||||
|         if not self.source.enrollment_flow: |         if not self.source.enrollment_flow: | ||||||
| @ -264,7 +262,6 @@ class SourceFlowManager: | |||||||
|             flow_slug=flow.slug, |             flow_slug=flow.slug, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def handle_auth( |     def handle_auth( | ||||||
|         self, |         self, | ||||||
|         connection: UserSourceConnection, |         connection: UserSourceConnection, | ||||||
|  | |||||||
| @ -13,7 +13,6 @@ class PostUserEnrollmentStage(StageView): | |||||||
|     """Dynamically injected stage which saves the Connection after |     """Dynamically injected stage which saves the Connection after | ||||||
|     the user has been enrolled.""" |     the user has been enrolled.""" | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         """Stage used after the user has been enrolled""" |         """Stage used after the user has been enrolled""" | ||||||
|         connection: UserSourceConnection = self.executor.plan.context[ |         connection: UserSourceConnection = self.executor.plan.context[ | ||||||
|  | |||||||
| @ -60,7 +60,7 @@ | |||||||
|     <div class="ak-login-container"> |     <div class="ak-login-container"> | ||||||
|         <header class="pf-c-login__header"> |         <header class="pf-c-login__header"> | ||||||
|             <div class="pf-c-brand ak-brand"> |             <div class="pf-c-brand ak-brand"> | ||||||
|                 <img src="{{ tenant.branding_logo }}" alt="authentik icon" /> |                 <img src="{{ tenant.branding_logo }}" alt="authentik Logo" /> | ||||||
|             </div> |             </div> | ||||||
|         </header> |         </header> | ||||||
|         {% block main_container %} |         {% block main_container %} | ||||||
|  | |||||||
| @ -35,7 +35,7 @@ def source_tester_factory(test_model: type[Stage]) -> Callable: | |||||||
|  |  | ||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|         model_class = None |         model_class = None | ||||||
|         if test_model._meta.abstract: |         if test_model._meta.abstract:  # pragma: no cover | ||||||
|             model_class = test_model.__bases__[0]() |             model_class = test_model.__bases__[0]() | ||||||
|         else: |         else: | ||||||
|             model_class = test_model() |             model_class = test_model() | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """Test Source flow_manager""" | """Test Source flow_manager""" | ||||||
| from django.contrib.auth.models import AnonymousUser | from django.contrib.auth.models import AnonymousUser | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from django.test.client import RequestFactory |  | ||||||
| from guardian.utils import get_anonymous_user | from guardian.utils import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.models import SourceUserMatchingModes, User | from authentik.core.models import SourceUserMatchingModes, User | ||||||
| @ -22,7 +21,6 @@ class TestSourceFlowManager(TestCase): | |||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         self.source: OAuthSource = OAuthSource.objects.create(name="test") |         self.source: OAuthSource = OAuthSource.objects.create(name="test") | ||||||
|         self.factory = RequestFactory() |  | ||||||
|         self.identifier = generate_id() |         self.identifier = generate_id() | ||||||
|  |  | ||||||
|     def test_unauthenticated_enroll(self): |     def test_unauthenticated_enroll(self): | ||||||
|  | |||||||
| @ -47,11 +47,11 @@ def create_test_tenant() -> Tenant: | |||||||
| def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair: | def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair: | ||||||
|     """Generate a certificate for testing""" |     """Generate a certificate for testing""" | ||||||
|     builder = CertificateBuilder( |     builder = CertificateBuilder( | ||||||
|  |         name=f"{generate_id()}.self-signed.goauthentik.io", | ||||||
|         use_ec_private_key=use_ec_private_key, |         use_ec_private_key=use_ec_private_key, | ||||||
|     ) |     ) | ||||||
|     builder.common_name = "goauthentik.io" |  | ||||||
|     builder.build( |     builder.build( | ||||||
|         subject_alt_names=["goauthentik.io"], |         subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"], | ||||||
|         validity_days=360, |         validity_days=360, | ||||||
|     ) |     ) | ||||||
|     builder.common_name = generate_id() |     builder.common_name = generate_id() | ||||||
|  | |||||||
| @ -187,7 +187,6 @@ class CertificateKeyPairFilter(FilterSet): | |||||||
|         label="Only return certificate-key pairs with keys", method="filter_has_key" |         label="Only return certificate-key pairs with keys", method="filter_has_key" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def filter_has_key(self, queryset, name, value):  # pragma: no cover |     def filter_has_key(self, queryset, name, value):  # pragma: no cover | ||||||
|         """Only return certificate-key pairs with keys""" |         """Only return certificate-key pairs with keys""" | ||||||
|         return queryset.exclude(key_data__exact="") |         return queryset.exclude(key_data__exact="") | ||||||
| @ -209,6 +208,13 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         parameters=[ |         parameters=[ | ||||||
|  |             # Override the type for `has_key` above | ||||||
|  |             OpenApiParameter( | ||||||
|  |                 "has_key", | ||||||
|  |                 bool, | ||||||
|  |                 required=False, | ||||||
|  |                 description="Only return certificate-key pairs with keys", | ||||||
|  |             ), | ||||||
|             OpenApiParameter("include_details", bool, default=True), |             OpenApiParameter("include_details", bool, default=True), | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
| @ -229,10 +235,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|         data = CertificateGenerationSerializer(data=request.data) |         data = CertificateGenerationSerializer(data=request.data) | ||||||
|         if not data.is_valid(): |         if not data.is_valid(): | ||||||
|             return Response(data.errors, status=400) |             return Response(data.errors, status=400) | ||||||
|         builder = CertificateBuilder() |         raw_san = data.validated_data.get("subject_alt_name", "") | ||||||
|         builder.common_name = data.validated_data["common_name"] |         sans = raw_san.split(",") if raw_san != "" else [] | ||||||
|  |         builder = CertificateBuilder(data.validated_data["common_name"]) | ||||||
|         builder.build( |         builder.build( | ||||||
|             subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","), |             subject_alt_names=sans, | ||||||
|             validity_days=int(data.validated_data["validity_days"]), |             validity_days=int(data.validated_data["validity_days"]), | ||||||
|         ) |         ) | ||||||
|         instance = builder.save() |         instance = builder.save() | ||||||
| @ -250,7 +257,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|         responses={200: CertificateDataSerializer(many=False)}, |         responses={200: CertificateDataSerializer(many=False)}, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def view_certificate(self, request: Request, pk: str) -> Response: |     def view_certificate(self, request: Request, pk: str) -> Response: | ||||||
|         """Return certificate-key pairs certificate and log access""" |         """Return certificate-key pairs certificate and log access""" | ||||||
|         certificate: CertificateKeyPair = self.get_object() |         certificate: CertificateKeyPair = self.get_object() | ||||||
| @ -281,7 +287,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|         responses={200: CertificateDataSerializer(many=False)}, |         responses={200: CertificateDataSerializer(many=False)}, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def view_private_key(self, request: Request, pk: str) -> Response: |     def view_private_key(self, request: Request, pk: str) -> Response: | ||||||
|         """Return certificate-key pairs private key and log access""" |         """Return certificate-key pairs private key and log access""" | ||||||
|         certificate: CertificateKeyPair = self.get_object() |         certificate: CertificateKeyPair = self.get_object() | ||||||
|  | |||||||
| @ -27,20 +27,16 @@ class AuthentikCryptoConfig(ManagedAppConfig): | |||||||
|         from authentik.crypto.builder import CertificateBuilder |         from authentik.crypto.builder import CertificateBuilder | ||||||
|         from authentik.crypto.models import CertificateKeyPair |         from authentik.crypto.models import CertificateKeyPair | ||||||
|  |  | ||||||
|         builder = CertificateBuilder() |         builder = CertificateBuilder("authentik Internal JWT Certificate") | ||||||
|         builder.common_name = "goauthentik.io" |  | ||||||
|         builder.build( |         builder.build( | ||||||
|             subject_alt_names=["goauthentik.io"], |             subject_alt_names=["goauthentik.io"], | ||||||
|             validity_days=360, |             validity_days=360, | ||||||
|         ) |         ) | ||||||
|         if not cert: |         if not cert: | ||||||
|  |  | ||||||
|             cert = CertificateKeyPair() |             cert = CertificateKeyPair() | ||||||
|         cert.certificate_data = builder.certificate |         builder.cert = cert | ||||||
|         cert.key_data = builder.private_key |         builder.cert.managed = MANAGED_KEY | ||||||
|         cert.name = "authentik Internal JWT Certificate" |         builder.save() | ||||||
|         cert.managed = MANAGED_KEY |  | ||||||
|         cert.save() |  | ||||||
|  |  | ||||||
|     def reconcile_managed_jwt_cert(self): |     def reconcile_managed_jwt_cert(self): | ||||||
|         """Ensure managed JWT certificate""" |         """Ensure managed JWT certificate""" | ||||||
| @ -63,10 +59,6 @@ class AuthentikCryptoConfig(ManagedAppConfig): | |||||||
|         name = "authentik Self-signed Certificate" |         name = "authentik Self-signed Certificate" | ||||||
|         if CertificateKeyPair.objects.filter(name=name).exists(): |         if CertificateKeyPair.objects.filter(name=name).exists(): | ||||||
|             return |             return | ||||||
|         builder = CertificateBuilder() |         builder = CertificateBuilder(name) | ||||||
|         builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"]) |         builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"]) | ||||||
|         CertificateKeyPair.objects.create( |         builder.save() | ||||||
|             name="authentik Self-signed Certificate", |  | ||||||
|             certificate_data=builder.certificate, |  | ||||||
|             key_data=builder.private_key, |  | ||||||
|         ) |  | ||||||
|  | |||||||
| @ -21,13 +21,13 @@ class CertificateBuilder: | |||||||
|  |  | ||||||
|     _use_ec_private_key: bool |     _use_ec_private_key: bool | ||||||
|  |  | ||||||
|     def __init__(self, use_ec_private_key=False): |     def __init__(self, name: str, use_ec_private_key=False): | ||||||
|         self._use_ec_private_key = use_ec_private_key |         self._use_ec_private_key = use_ec_private_key | ||||||
|         self.__public_key = None |         self.__public_key = None | ||||||
|         self.__private_key = None |         self.__private_key = None | ||||||
|         self.__builder = None |         self.__builder = None | ||||||
|         self.__certificate = None |         self.__certificate = None | ||||||
|         self.common_name = "authentik Self-signed Certificate" |         self.common_name = name | ||||||
|         self.cert = CertificateKeyPair() |         self.cert = CertificateKeyPair() | ||||||
|  |  | ||||||
|     def save(self) -> CertificateKeyPair: |     def save(self) -> CertificateKeyPair: | ||||||
| @ -57,7 +57,10 @@ class CertificateBuilder: | |||||||
|         one_day = datetime.timedelta(1, 0, 0) |         one_day = datetime.timedelta(1, 0, 0) | ||||||
|         self.__private_key = self.generate_private_key() |         self.__private_key = self.generate_private_key() | ||||||
|         self.__public_key = self.__private_key.public_key() |         self.__public_key = self.__private_key.public_key() | ||||||
|         alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []] |         alt_names: list[x509.GeneralName] = [] | ||||||
|  |         for alt_name in subject_alt_names or []: | ||||||
|  |             if alt_name.strip() != "": | ||||||
|  |                 alt_names.append(x509.DNSName(alt_name)) | ||||||
|         self.__builder = ( |         self.__builder = ( | ||||||
|             x509.CertificateBuilder() |             x509.CertificateBuilder() | ||||||
|             .subject_name( |             .subject_name( | ||||||
| @ -76,12 +79,15 @@ class CertificateBuilder: | |||||||
|                     ] |                     ] | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             .add_extension(x509.SubjectAlternativeName(alt_names), critical=True) |  | ||||||
|             .not_valid_before(datetime.datetime.today() - one_day) |             .not_valid_before(datetime.datetime.today() - one_day) | ||||||
|             .not_valid_after(datetime.datetime.today() + datetime.timedelta(days=validity_days)) |             .not_valid_after(datetime.datetime.today() + datetime.timedelta(days=validity_days)) | ||||||
|             .serial_number(int(uuid.uuid4())) |             .serial_number(int(uuid.uuid4())) | ||||||
|             .public_key(self.__public_key) |             .public_key(self.__public_key) | ||||||
|         ) |         ) | ||||||
|  |         if alt_names: | ||||||
|  |             self.__builder = self.__builder.add_extension( | ||||||
|  |                 x509.SubjectAlternativeName(alt_names), critical=True | ||||||
|  |             ) | ||||||
|         self.__certificate = self.__builder.sign( |         self.__certificate = self.__builder.sign( | ||||||
|             private_key=self.__private_key, |             private_key=self.__private_key, | ||||||
|             algorithm=hashes.SHA256(), |             algorithm=hashes.SHA256(), | ||||||
|  | |||||||
| @ -4,6 +4,8 @@ from json import loads | |||||||
| from os import makedirs | from os import makedirs | ||||||
| from tempfile import TemporaryDirectory | from tempfile import TemporaryDirectory | ||||||
|  |  | ||||||
|  | from cryptography.x509.extensions import SubjectAlternativeName | ||||||
|  | from cryptography.x509.general_name import DNSName | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| @ -14,7 +16,7 @@ from authentik.crypto.builder import CertificateBuilder | |||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery | from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.generators import generate_key | from authentik.lib.generators import generate_id, generate_key | ||||||
| from authentik.providers.oauth2.models import OAuth2Provider | from authentik.providers.oauth2.models import OAuth2Provider | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -54,8 +56,8 @@ class TestCrypto(APITestCase): | |||||||
|  |  | ||||||
|     def test_builder(self): |     def test_builder(self): | ||||||
|         """Test Builder""" |         """Test Builder""" | ||||||
|         builder = CertificateBuilder() |         name = generate_id() | ||||||
|         builder.common_name = "test-cert" |         builder = CertificateBuilder(name) | ||||||
|         with self.assertRaises(ValueError): |         with self.assertRaises(ValueError): | ||||||
|             builder.save() |             builder.save() | ||||||
|         builder.build( |         builder.build( | ||||||
| @ -64,17 +66,49 @@ class TestCrypto(APITestCase): | |||||||
|         ) |         ) | ||||||
|         instance = builder.save() |         instance = builder.save() | ||||||
|         now = datetime.datetime.today() |         now = datetime.datetime.today() | ||||||
|         self.assertEqual(instance.name, "test-cert") |         self.assertEqual(instance.name, name) | ||||||
|         self.assertEqual((instance.certificate.not_valid_after - now).days, 2) |         self.assertEqual((instance.certificate.not_valid_after - now).days, 2) | ||||||
|  |  | ||||||
|     def test_builder_api(self): |     def test_builder_api(self): | ||||||
|         """Test Builder (via API)""" |         """Test Builder (via API)""" | ||||||
|         self.client.force_login(create_test_admin_user()) |         self.client.force_login(create_test_admin_user()) | ||||||
|  |         name = generate_id() | ||||||
|         self.client.post( |         self.client.post( | ||||||
|             reverse("authentik_api:certificatekeypair-generate"), |             reverse("authentik_api:certificatekeypair-generate"), | ||||||
|             data={"common_name": "foo", "subject_alt_name": "bar,baz", "validity_days": 3}, |             data={"common_name": name, "subject_alt_name": "bar,baz", "validity_days": 3}, | ||||||
|         ) |         ) | ||||||
|         self.assertTrue(CertificateKeyPair.objects.filter(name="foo").exists()) |         key = CertificateKeyPair.objects.filter(name=name).first() | ||||||
|  |         self.assertIsNotNone(key) | ||||||
|  |         ext: SubjectAlternativeName = key.certificate.extensions[0].value | ||||||
|  |         self.assertIsInstance(ext, SubjectAlternativeName) | ||||||
|  |         self.assertIsInstance(ext[0], DNSName) | ||||||
|  |         self.assertEqual(ext[0].value, "bar") | ||||||
|  |         self.assertIsInstance(ext[1], DNSName) | ||||||
|  |         self.assertEqual(ext[1].value, "baz") | ||||||
|  |  | ||||||
|  |     def test_builder_api_empty_san(self): | ||||||
|  |         """Test Builder (via API)""" | ||||||
|  |         self.client.force_login(create_test_admin_user()) | ||||||
|  |         name = generate_id() | ||||||
|  |         self.client.post( | ||||||
|  |             reverse("authentik_api:certificatekeypair-generate"), | ||||||
|  |             data={"common_name": name, "subject_alt_name": "", "validity_days": 3}, | ||||||
|  |         ) | ||||||
|  |         key = CertificateKeyPair.objects.filter(name=name).first() | ||||||
|  |         self.assertIsNotNone(key) | ||||||
|  |         self.assertEqual(len(key.certificate.extensions), 0) | ||||||
|  |  | ||||||
|  |     def test_builder_api_empty_san_multiple(self): | ||||||
|  |         """Test Builder (via API)""" | ||||||
|  |         self.client.force_login(create_test_admin_user()) | ||||||
|  |         name = generate_id() | ||||||
|  |         self.client.post( | ||||||
|  |             reverse("authentik_api:certificatekeypair-generate"), | ||||||
|  |             data={"common_name": name, "subject_alt_name": ", ", "validity_days": 3}, | ||||||
|  |         ) | ||||||
|  |         key = CertificateKeyPair.objects.filter(name=name).first() | ||||||
|  |         self.assertIsNotNone(key) | ||||||
|  |         self.assertEqual(len(key.certificate.extensions), 0) | ||||||
|  |  | ||||||
|     def test_builder_api_invalid(self): |     def test_builder_api_invalid(self): | ||||||
|         """Test Builder (via API) (invalid)""" |         """Test Builder (via API) (invalid)""" | ||||||
| @ -193,8 +227,8 @@ class TestCrypto(APITestCase): | |||||||
|  |  | ||||||
|     def test_discovery(self): |     def test_discovery(self): | ||||||
|         """Test certificate discovery""" |         """Test certificate discovery""" | ||||||
|         builder = CertificateBuilder() |         name = generate_id() | ||||||
|         builder.common_name = "test-cert" |         builder = CertificateBuilder(name) | ||||||
|         with self.assertRaises(ValueError): |         with self.assertRaises(ValueError): | ||||||
|             builder.save() |             builder.save() | ||||||
|         builder.build( |         builder.build( | ||||||
|  | |||||||
| @ -1,9 +1,11 @@ | |||||||
| """Events API Views""" | """Events API Views""" | ||||||
|  | from datetime import timedelta | ||||||
| from json import loads | from json import loads | ||||||
|  |  | ||||||
| import django_filters | import django_filters | ||||||
| from django.db.models.aggregates import Count | from django.db.models.aggregates import Count | ||||||
| from django.db.models.fields.json import KeyTextTransform | from django.db.models.fields.json import KeyTextTransform | ||||||
|  | from django.db.models.functions import ExtractDay | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import OpenApiParameter, extend_schema | from drf_spectacular.utils import OpenApiParameter, extend_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| @ -81,7 +83,6 @@ class EventsFilter(django_filters.FilterSet): | |||||||
|         label="Tenant name", |         label="Tenant name", | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def filter_context_model_pk(self, queryset, name, value): |     def filter_context_model_pk(self, queryset, name, value): | ||||||
|         """Because we store the PK as UUID.hex, |         """Because we store the PK as UUID.hex, | ||||||
|         we need to remove the dashes that a client may send. We can't use a |         we need to remove the dashes that a client may send. We can't use a | ||||||
| @ -178,7 +179,7 @@ class EventViewSet(ModelViewSet): | |||||||
|             get_objects_for_user(request.user, "authentik_events.view_event") |             get_objects_for_user(request.user, "authentik_events.view_event") | ||||||
|             .filter(action=filtered_action) |             .filter(action=filtered_action) | ||||||
|             .filter(**query) |             .filter(**query) | ||||||
|             .get_events_per_day() |             .get_events_per(timedelta(weeks=4), ExtractDay, 30) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) |     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|  | |||||||
| @ -80,7 +80,6 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet): | |||||||
|         request=OpenApiTypes.NONE, |         request=OpenApiTypes.NONE, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def test(self, request: Request, pk=None) -> Response: |     def test(self, request: Request, pk=None) -> Response: | ||||||
|         """Send example notification using selected transport. Requires |         """Send example notification using selected transport. Requires | ||||||
|         Modify permissions.""" |         Modify permissions.""" | ||||||
|  | |||||||
| @ -12,12 +12,21 @@ from django.http import HttpRequest, HttpResponse | |||||||
| from django_otp.plugins.otp_static.models import StaticToken | from django_otp.plugins.otp_static.models import StaticToken | ||||||
| from guardian.models import UserObjectPermission | from guardian.models import UserObjectPermission | ||||||
|  |  | ||||||
| from authentik.core.models import AuthenticatedSession, User | from authentik.core.models import ( | ||||||
|  |     AuthenticatedSession, | ||||||
|  |     PropertyMapping, | ||||||
|  |     Provider, | ||||||
|  |     Source, | ||||||
|  |     User, | ||||||
|  |     UserSourceConnection, | ||||||
|  | ) | ||||||
| from authentik.events.models import Event, EventAction, Notification | from authentik.events.models import Event, EventAction, Notification | ||||||
| from authentik.events.utils import model_to_dict | from authentik.events.utils import model_to_dict | ||||||
| from authentik.flows.models import FlowToken | from authentik.flows.models import FlowToken, Stage | ||||||
| from authentik.lib.sentry import before_send | from authentik.lib.sentry import before_send | ||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
|  | from authentik.outposts.models import OutpostServiceConnection | ||||||
|  | from authentik.policies.models import Policy, PolicyBindingModel | ||||||
|  |  | ||||||
| IGNORED_MODELS = ( | IGNORED_MODELS = ( | ||||||
|     Event, |     Event, | ||||||
| @ -27,6 +36,14 @@ IGNORED_MODELS = ( | |||||||
|     StaticToken, |     StaticToken, | ||||||
|     Session, |     Session, | ||||||
|     FlowToken, |     FlowToken, | ||||||
|  |     Provider, | ||||||
|  |     Source, | ||||||
|  |     PropertyMapping, | ||||||
|  |     UserSourceConnection, | ||||||
|  |     Stage, | ||||||
|  |     OutpostServiceConnection, | ||||||
|  |     Policy, | ||||||
|  |     PolicyBindingModel, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -34,7 +51,7 @@ def should_log_model(model: Model) -> bool: | |||||||
|     """Return true if operation on `model` should be logged""" |     """Return true if operation on `model` should be logged""" | ||||||
|     if model.__module__.startswith("silk"): |     if model.__module__.startswith("silk"): | ||||||
|         return False |         return False | ||||||
|     return not isinstance(model, IGNORED_MODELS) |     return model.__class__ not in IGNORED_MODELS | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventNewThread(Thread): | class EventNewThread(Thread): | ||||||
| @ -101,7 +118,6 @@ class AuditMiddleware: | |||||||
|         self.disconnect(request) |         self.disconnect(request) | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def process_exception(self, request: HttpRequest, exception: Exception): |     def process_exception(self, request: HttpRequest, exception: Exception): | ||||||
|         """Disconnect handlers in case of exception""" |         """Disconnect handlers in case of exception""" | ||||||
|         self.disconnect(request) |         self.disconnect(request) | ||||||
| @ -125,7 +141,6 @@ class AuditMiddleware: | |||||||
|             thread.run() |             thread.run() | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def post_save_handler( |     def post_save_handler( | ||||||
|         user: User, request: HttpRequest, sender, instance: Model, created: bool, **_ |         user: User, request: HttpRequest, sender, instance: Model, created: bool, **_ | ||||||
|     ): |     ): | ||||||
| @ -137,7 +152,6 @@ class AuditMiddleware: | |||||||
|         EventNewThread(action, request, user=user, model=model_to_dict(instance)).run() |         EventNewThread(action, request, user=user, model=model_to_dict(instance)).run() | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_): |     def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_): | ||||||
|         """Signal handler for all object's pre_delete""" |         """Signal handler for all object's pre_delete""" | ||||||
|         if not should_log_model(instance):  # pragma: no cover |         if not should_log_model(instance):  # pragma: no cover | ||||||
|  | |||||||
| @ -11,8 +11,7 @@ from django.conf import settings | |||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models import Count, ExpressionWrapper, F | from django.db.models import Count, ExpressionWrapper, F | ||||||
| from django.db.models.fields import DurationField | from django.db.models.fields import DurationField | ||||||
| from django.db.models.functions import ExtractHour | from django.db.models.functions import Extract | ||||||
| from django.db.models.functions.datetime import ExtractDay |  | ||||||
| from django.db.models.manager import Manager | from django.db.models.manager import Manager | ||||||
| from django.db.models.query import QuerySet | from django.db.models.query import QuerySet | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| @ -111,48 +110,35 @@ class EventAction(models.TextChoices): | |||||||
| class EventQuerySet(QuerySet): | class EventQuerySet(QuerySet): | ||||||
|     """Custom events query set with helper functions""" |     """Custom events query set with helper functions""" | ||||||
|  |  | ||||||
|     def get_events_per_hour(self) -> list[dict[str, int]]: |     def get_events_per( | ||||||
|  |         self, | ||||||
|  |         time_since: timedelta, | ||||||
|  |         extract: Extract, | ||||||
|  |         data_points: int, | ||||||
|  |     ) -> list[dict[str, int]]: | ||||||
|         """Get event count by hour in the last day, fill with zeros""" |         """Get event count by hour in the last day, fill with zeros""" | ||||||
|         date_from = now() - timedelta(days=1) |         _now = now() | ||||||
|  |         max_since = timedelta(days=60) | ||||||
|  |         # Allow maximum of 60 days to limit load | ||||||
|  |         if time_since.total_seconds() > max_since.total_seconds(): | ||||||
|  |             time_since = max_since | ||||||
|  |         date_from = _now - time_since | ||||||
|         result = ( |         result = ( | ||||||
|             self.filter(created__gte=date_from) |             self.filter(created__gte=date_from) | ||||||
|             .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField())) |             .annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField())) | ||||||
|             .annotate(age_hours=ExtractHour("age")) |             .annotate(age_interval=extract("age")) | ||||||
|             .values("age_hours") |             .values("age_interval") | ||||||
|             .annotate(count=Count("pk")) |             .annotate(count=Count("pk")) | ||||||
|             .order_by("age_hours") |             .order_by("age_interval") | ||||||
|         ) |         ) | ||||||
|         data = Counter({int(d["age_hours"]): d["count"] for d in result}) |         data = Counter({int(d["age_interval"]): d["count"] for d in result}) | ||||||
|         results = [] |         results = [] | ||||||
|         _now = now() |         interval_delta = time_since / data_points | ||||||
|         for hour in range(0, -24, -1): |         for interval in range(1, -data_points, -1): | ||||||
|             results.append( |             results.append( | ||||||
|                 { |                 { | ||||||
|                     "x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000, |                     "x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000, | ||||||
|                     "y_cord": data[hour * -1], |                     "y_cord": data[interval * -1], | ||||||
|                 } |  | ||||||
|             ) |  | ||||||
|         return results |  | ||||||
|  |  | ||||||
|     def get_events_per_day(self) -> list[dict[str, int]]: |  | ||||||
|         """Get event count by hour in the last day, fill with zeros""" |  | ||||||
|         date_from = now() - timedelta(weeks=4) |  | ||||||
|         result = ( |  | ||||||
|             self.filter(created__gte=date_from) |  | ||||||
|             .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField())) |  | ||||||
|             .annotate(age_days=ExtractDay("age")) |  | ||||||
|             .values("age_days") |  | ||||||
|             .annotate(count=Count("pk")) |  | ||||||
|             .order_by("age_days") |  | ||||||
|         ) |  | ||||||
|         data = Counter({int(d["age_days"]): d["count"] for d in result}) |  | ||||||
|         results = [] |  | ||||||
|         _now = now() |  | ||||||
|         for day in range(0, -30, -1): |  | ||||||
|             results.append( |  | ||||||
|                 { |  | ||||||
|                     "x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000, |  | ||||||
|                     "y_cord": data[day * -1], |  | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         return results |         return results | ||||||
| @ -165,13 +151,14 @@ class EventManager(Manager): | |||||||
|         """use custom queryset""" |         """use custom queryset""" | ||||||
|         return EventQuerySet(self.model, using=self._db) |         return EventQuerySet(self.model, using=self._db) | ||||||
|  |  | ||||||
|     def get_events_per_hour(self) -> list[dict[str, int]]: |     def get_events_per( | ||||||
|  |         self, | ||||||
|  |         time_since: timedelta, | ||||||
|  |         extract: Extract, | ||||||
|  |         data_points: int, | ||||||
|  |     ) -> list[dict[str, int]]: | ||||||
|         """Wrap method from queryset""" |         """Wrap method from queryset""" | ||||||
|         return self.get_queryset().get_events_per_hour() |         return self.get_queryset().get_events_per(time_since, extract, data_points) | ||||||
|  |  | ||||||
|     def get_events_per_day(self) -> list[dict[str, int]]: |  | ||||||
|         """Wrap method from queryset""" |  | ||||||
|         return self.get_queryset().get_events_per_day() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Event(SerializerModel, ExpiringModel): | class Event(SerializerModel, ExpiringModel): | ||||||
| @ -461,7 +448,7 @@ class NotificationTransport(SerializerModel): | |||||||
|             # pyright: reportGeneralTypeIssues=false |             # pyright: reportGeneralTypeIssues=false | ||||||
|             return send_mail(mail.__dict__)  # pylint: disable=no-value-for-parameter |             return send_mail(mail.__dict__)  # pylint: disable=no-value-for-parameter | ||||||
|         except (SMTPException, ConnectionError, OSError) as exc: |         except (SMTPException, ConnectionError, OSError) as exc: | ||||||
|             raise NotificationTransportError from exc |             raise NotificationTransportError(exc) from exc | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> "Serializer": |     def serializer(self) -> "Serializer": | ||||||
|  | |||||||
| @ -102,7 +102,7 @@ class TaskInfo: | |||||||
|         key = CACHE_KEY_PREFIX + self.task_name |         key = CACHE_KEY_PREFIX + self.task_name | ||||||
|         if self.result.uid: |         if self.result.uid: | ||||||
|             key += f"/{self.result.uid}" |             key += f"/{self.result.uid}" | ||||||
|             self.task_name += f"_{self.result.uid}" |             self.task_name += f"/{self.result.uid}" | ||||||
|         self.set_prom_metrics() |         self.set_prom_metrics() | ||||||
|         cache.set(key, self, timeout=timeout_hours * 60 * 60) |         cache.set(key, self, timeout=timeout_hours * 60 * 60) | ||||||
|  |  | ||||||
|  | |||||||
| @ -22,7 +22,6 @@ SESSION_LOGIN_EVENT = "login_event" | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_in) | @receiver(user_logged_in) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def on_user_logged_in(sender, request: HttpRequest, user: User, **_): | def on_user_logged_in(sender, request: HttpRequest, user: User, **_): | ||||||
|     """Log successful login""" |     """Log successful login""" | ||||||
|     kwargs = {} |     kwargs = {} | ||||||
| @ -39,15 +38,18 @@ def on_user_logged_in(sender, request: HttpRequest, user: User, **_): | |||||||
|     request.session[SESSION_LOGIN_EVENT] = event |     request.session[SESSION_LOGIN_EVENT] = event | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_login_event(request: HttpRequest) -> Optional[Event]: | ||||||
|  |     """Wrapper to get login event that can be mocked in tests""" | ||||||
|  |     return request.session.get(SESSION_LOGIN_EVENT, None) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_out) | @receiver(user_logged_out) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def on_user_logged_out(sender, request: HttpRequest, user: User, **_): | def on_user_logged_out(sender, request: HttpRequest, user: User, **_): | ||||||
|     """Log successfully logout""" |     """Log successfully logout""" | ||||||
|     Event.new(EventAction.LOGOUT).from_http(request, user=user) |     Event.new(EventAction.LOGOUT).from_http(request, user=user) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_write) | @receiver(user_write) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs): | def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs): | ||||||
|     """Log User write""" |     """Log User write""" | ||||||
|     data["created"] = kwargs.get("created", False) |     data["created"] = kwargs.get("created", False) | ||||||
| @ -55,7 +57,6 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any] | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(login_failed) | @receiver(login_failed) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def on_login_failed( | def on_login_failed( | ||||||
|     signal, |     signal, | ||||||
|     sender, |     sender, | ||||||
| @ -69,7 +70,6 @@ def on_login_failed( | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(invitation_used) | @receiver(invitation_used) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_): | def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_): | ||||||
|     """Log Invitation usage""" |     """Log Invitation usage""" | ||||||
|     Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http( |     Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http( | ||||||
| @ -78,21 +78,18 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_ | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(password_changed) | @receiver(password_changed) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def on_password_changed(sender, user: User, password: str, **_): | def on_password_changed(sender, user: User, password: str, **_): | ||||||
|     """Log password change""" |     """Log password change""" | ||||||
|     Event.new(EventAction.PASSWORD_SET).from_http(None, user=user) |     Event.new(EventAction.PASSWORD_SET).from_http(None, user=user) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save, sender=Event) | @receiver(post_save, sender=Event) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def event_post_save_notification(sender, instance: Event, **_): | def event_post_save_notification(sender, instance: Event, **_): | ||||||
|     """Start task to check if any policies trigger an notification on this event""" |     """Start task to check if any policies trigger an notification on this event""" | ||||||
|     event_notification_handler.delay(instance.event_uuid.hex) |     event_notification_handler.delay(instance.event_uuid.hex) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_delete, sender=User) | @receiver(pre_delete, sender=User) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def event_user_pre_delete_cleanup(sender, instance: User, **_): | def event_user_pre_delete_cleanup(sender, instance: User, **_): | ||||||
|     """If gdpr_compliance is enabled, remove all the user's events""" |     """If gdpr_compliance is enabled, remove all the user's events""" | ||||||
|     gdpr_cleanup.delay(instance.pk) |     gdpr_cleanup.delay(instance.pk) | ||||||
|  | |||||||
| @ -210,7 +210,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def export(self, request: Request, slug: str) -> Response: |     def export(self, request: Request, slug: str) -> Response: | ||||||
|         """Export flow to .yaml file""" |         """Export flow to .yaml file""" | ||||||
|         flow = self.get_object() |         flow = self.get_object() | ||||||
| @ -221,7 +220,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     @extend_schema(responses={200: FlowDiagramSerializer()}) |     @extend_schema(responses={200: FlowDiagramSerializer()}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"]) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def diagram(self, request: Request, slug: str) -> Response: |     def diagram(self, request: Request, slug: str) -> Response: | ||||||
|         """Return diagram for flow with slug `slug`, in the format used by flowchart.js""" |         """Return diagram for flow with slug `slug`, in the format used by flowchart.js""" | ||||||
|         diagram = FlowDiagram(self.get_object(), request.user) |         diagram = FlowDiagram(self.get_object(), request.user) | ||||||
| @ -245,7 +243,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|         methods=["POST"], |         methods=["POST"], | ||||||
|         parser_classes=(MultiPartParser,), |         parser_classes=(MultiPartParser,), | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_background(self, request: Request, slug: str): |     def set_background(self, request: Request, slug: str): | ||||||
|         """Set Flow background""" |         """Set Flow background""" | ||||||
|         flow: Flow = self.get_object() |         flow: Flow = self.get_object() | ||||||
| @ -265,7 +262,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|         filter_backends=[], |         filter_backends=[], | ||||||
|         methods=["POST"], |         methods=["POST"], | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_background_url(self, request: Request, slug: str): |     def set_background_url(self, request: Request, slug: str): | ||||||
|         """Set Flow background (as URL)""" |         """Set Flow background (as URL)""" | ||||||
|         flow: Flow = self.get_object() |         flow: Flow = self.get_object() | ||||||
| @ -278,7 +274,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def execute(self, request: Request, slug: str): |     def execute(self, request: Request, slug: str): | ||||||
|         """Execute flow for current user""" |         """Execute flow for current user""" | ||||||
|         # Because we pre-plan the flow here, and not in the planner, we need to manually clear |         # Because we pre-plan the flow here, and not in the planner, we need to manually clear | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """Challenge helpers""" | """Challenge helpers""" | ||||||
| from dataclasses import asdict, is_dataclass | from dataclasses import asdict, is_dataclass | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from traceback import format_tb |  | ||||||
| from typing import TYPE_CHECKING, Optional, TypedDict | from typing import TYPE_CHECKING, Optional, TypedDict | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| @ -9,8 +8,10 @@ from django.core.serializers.json import DjangoJSONEncoder | |||||||
| from django.db import models | from django.db import models | ||||||
| from django.http import JsonResponse | from django.http import JsonResponse | ||||||
| from rest_framework.fields import CharField, ChoiceField, DictField | from rest_framework.fields import CharField, ChoiceField, DictField | ||||||
|  | from rest_framework.request import Request | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from authentik.flows.stage import StageView |     from authentik.flows.stage import StageView | ||||||
| @ -90,32 +91,31 @@ class WithUserInfoChallenge(Challenge): | |||||||
|     pending_user_avatar = CharField() |     pending_user_avatar = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowErrorChallenge(WithUserInfoChallenge): | class FlowErrorChallenge(Challenge): | ||||||
|     """Challenge class when an unhandled error occurs during a stage. Normal users |     """Challenge class when an unhandled error occurs during a stage. Normal users | ||||||
|     are shown an error message, superusers are shown a full stacktrace.""" |     are shown an error message, superusers are shown a full stacktrace.""" | ||||||
|  |  | ||||||
|     component = CharField(default="xak-flow-error") |     type = CharField(default=ChallengeTypes.NATIVE.value) | ||||||
|  |     component = CharField(default="ak-stage-flow-error") | ||||||
|  |  | ||||||
|     request_id = CharField() |     request_id = CharField() | ||||||
|  |  | ||||||
|     error = CharField(required=False) |     error = CharField(required=False) | ||||||
|     traceback = CharField(required=False) |     traceback = CharField(required=False) | ||||||
|  |  | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, request: Optional[Request] = None, error: Optional[Exception] = None): | ||||||
|         request = kwargs.pop("request", None) |         super().__init__(data={}) | ||||||
|         error = kwargs.pop("error", None) |  | ||||||
|         super().__init__(*args, **kwargs) |  | ||||||
|         if not request or not error: |         if not request or not error: | ||||||
|             return |             return | ||||||
|         self.request_id = request.request_id |         self.initial_data["request_id"] = request.request_id | ||||||
|         from authentik.core.models import USER_ATTRIBUTE_DEBUG |         from authentik.core.models import USER_ATTRIBUTE_DEBUG | ||||||
|  |  | ||||||
|         if request.user and request.user.is_authenticated: |         if request.user and request.user.is_authenticated: | ||||||
|             if request.user.is_superuser or request.user.group_attributes(request).get( |             if request.user.is_superuser or request.user.group_attributes(request).get( | ||||||
|                 USER_ATTRIBUTE_DEBUG, False |                 USER_ATTRIBUTE_DEBUG, False | ||||||
|             ): |             ): | ||||||
|                 self.error = error |                 self.initial_data["error"] = str(error) | ||||||
|                 self.traceback = "".join(format_tb(self.error.__traceback__)) |                 self.initial_data["traceback"] = exception_to_string(error) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AccessDeniedChallenge(WithUserInfoChallenge): | class AccessDeniedChallenge(WithUserInfoChallenge): | ||||||
|  | |||||||
| @ -19,7 +19,6 @@ LOGGER = get_logger() | |||||||
| class StageMarker: | class StageMarker: | ||||||
|     """Base stage marker class, no extra attributes, and has no special handler.""" |     """Base stage marker class, no extra attributes, and has no special handler.""" | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def process( |     def process( | ||||||
|         self, |         self, | ||||||
|         plan: "FlowPlan", |         plan: "FlowPlan", | ||||||
|  | |||||||
| @ -19,7 +19,6 @@ def delete_cache_prefix(prefix: str) -> int: | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(monitoring_set) | @receiver(monitoring_set) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def monitoring_set_flows(sender, **kwargs): | def monitoring_set_flows(sender, **kwargs): | ||||||
|     """set flow gauges""" |     """set flow gauges""" | ||||||
|     GAUGE_FLOWS_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or [])) |     GAUGE_FLOWS_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or [])) | ||||||
| @ -27,7 +26,6 @@ def monitoring_set_flows(sender, **kwargs): | |||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| @receiver(pre_delete) | @receiver(pre_delete) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def invalidate_flow_cache(sender, instance, **_): | def invalidate_flow_cache(sender, instance, **_): | ||||||
|     """Invalidate flow cache when flow is updated""" |     """Invalidate flow cache when flow is updated""" | ||||||
|     from authentik.flows.models import Flow, FlowStageBinding, Stage |     from authentik.flows.models import Flow, FlowStageBinding, Stage | ||||||
|  | |||||||
| @ -91,7 +91,6 @@ class ChallengeStageView(StageView): | |||||||
|             ) |             ) | ||||||
|         return HttpChallengeResponse(challenge) |         return HttpChallengeResponse(challenge) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def post(self, request: Request, *args, **kwargs) -> HttpResponse: |     def post(self, request: Request, *args, **kwargs) -> HttpResponse: | ||||||
|         """Handle challenge response""" |         """Handle challenge response""" | ||||||
|         challenge: ChallengeResponse = self.get_response_instance(data=request.data) |         challenge: ChallengeResponse = self.get_response_instance(data=request.data) | ||||||
|  | |||||||
| @ -113,7 +113,7 @@ class InvalidStageError(SentryIgnoredException): | |||||||
|  |  | ||||||
| @method_decorator(xframe_options_sameorigin, name="dispatch") | @method_decorator(xframe_options_sameorigin, name="dispatch") | ||||||
| class FlowExecutorView(APIView): | class FlowExecutorView(APIView): | ||||||
|     """Stage 1 Flow executor, passing requests to Stage Views""" |     """Flow executor, passing requests to Stage Views""" | ||||||
|  |  | ||||||
|     permission_classes = [AllowAny] |     permission_classes = [AllowAny] | ||||||
|  |  | ||||||
| @ -166,7 +166,7 @@ class FlowExecutorView(APIView): | |||||||
|         self._logger.debug("f(exec): restored flow plan from token", plan=plan) |         self._logger.debug("f(exec): restored flow plan from token", plan=plan) | ||||||
|         return plan |         return plan | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument, too-many-return-statements |     # pylint: disable=too-many-return-statements | ||||||
|     def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: |     def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: | ||||||
|         with Hub.current.start_span( |         with Hub.current.start_span( | ||||||
|             op="authentik.flow.executor.dispatch", description=self.flow.slug |             op="authentik.flow.executor.dispatch", description=self.flow.slug | ||||||
| @ -255,7 +255,7 @@ class FlowExecutorView(APIView): | |||||||
|             message=exception_to_string(exc), |             message=exception_to_string(exc), | ||||||
|         ).from_http(self.request) |         ).from_http(self.request) | ||||||
|         challenge = FlowErrorChallenge(self.request, exc) |         challenge = FlowErrorChallenge(self.request, exc) | ||||||
|         challenge.is_valid() |         challenge.is_valid(raise_exception=True) | ||||||
|         return to_stage_response(self.request, HttpChallengeResponse(challenge)) |         return to_stage_response(self.request, HttpChallengeResponse(challenge)) | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|  | |||||||
| @ -47,7 +47,6 @@ class FlowInspectorPlanSerializer(PassiveSerializer): | |||||||
|         """Get the plan's context, sanitized""" |         """Get the plan's context, sanitized""" | ||||||
|         return sanitize_dict(plan.context) |         return sanitize_dict(plan.context) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def get_session_id(self, plan: FlowPlan) -> str: |     def get_session_id(self, plan: FlowPlan) -> str: | ||||||
|         """Get a unique session ID""" |         """Get a unique session ID""" | ||||||
|         request: Request = self.context["request"] |         request: Request = self.context["request"] | ||||||
|  | |||||||
| @ -93,7 +93,7 @@ class ConfigLoader: | |||||||
|         if url.scheme == "file": |         if url.scheme == "file": | ||||||
|             try: |             try: | ||||||
|                 with open(url.path, "r", encoding="utf8") as _file: |                 with open(url.path, "r", encoding="utf8") as _file: | ||||||
|                     value = _file.read() |                     value = _file.read().strip() | ||||||
|             except OSError as exc: |             except OSError as exc: | ||||||
|                 self.log("error", f"Failed to read config value from {url.path}: {exc}") |                 self.log("error", f"Failed to read config value from {url.path}: {exc}") | ||||||
|                 value = url.query |                 value = url.query | ||||||
|  | |||||||
| @ -64,6 +64,7 @@ outposts: | |||||||
|   disable_embedded_outpost: false |   disable_embedded_outpost: false | ||||||
|  |  | ||||||
| ldap: | ldap: | ||||||
|  |   task_timeout_hours: 2 | ||||||
|   tls: |   tls: | ||||||
|     ciphers: null |     ciphers: null | ||||||
|  |  | ||||||
|  | |||||||
| @ -159,7 +159,6 @@ class BaseEvaluator: | |||||||
|                 raise exc |                 raise exc | ||||||
|             return result |             return result | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def handle_error(self, exc: Exception, expression_source: str):  # pragma: no cover |     def handle_error(self, exc: Exception, expression_source: str):  # pragma: no cover | ||||||
|         """Exception Handler""" |         """Exception Handler""" | ||||||
|         LOGGER.warning("Expression error", exc=exc) |         LOGGER.warning("Expression error", exc=exc) | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ from logging import Logger | |||||||
| from os import getpid | from os import getpid | ||||||
|  |  | ||||||
|  |  | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def add_process_id(logger: Logger, method_name: str, event_dict): | def add_process_id(logger: Logger, method_name: str, event_dict): | ||||||
|     """Add the current process ID""" |     """Add the current process ID""" | ||||||
|     event_dict["pid"] = getpid() |     event_dict["pid"] = getpid() | ||||||
|  | |||||||
| @ -19,7 +19,7 @@ def model_tester_factory(test_model: type[Stage]) -> Callable: | |||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|         try: |         try: | ||||||
|             model_class = None |             model_class = None | ||||||
|             if test_model._meta.abstract: |             if test_model._meta.abstract:  # pragma: no cover | ||||||
|                 return |                 return | ||||||
|             model_class = test_model() |             model_class = test_model() | ||||||
|             self.assertTrue(issubclass(model_class.serializer, BaseSerializer)) |             self.assertTrue(issubclass(model_class.serializer, BaseSerializer)) | ||||||
|  | |||||||
| @ -48,14 +48,14 @@ def get_apps(): | |||||||
|  |  | ||||||
| def get_env() -> str: | def get_env() -> str: | ||||||
|     """Get environment in which authentik is currently running""" |     """Get environment in which authentik is currently running""" | ||||||
|     if SERVICE_HOST_ENV_NAME in os.environ: |  | ||||||
|         return "kubernetes" |  | ||||||
|     if "CI" in os.environ: |     if "CI" in os.environ: | ||||||
|         return "ci" |         return "ci" | ||||||
|     if Path("/tmp/authentik-mode").exists():  # nosec |  | ||||||
|         return "compose" |  | ||||||
|     if CONFIG.y_bool("debug"): |     if CONFIG.y_bool("debug"): | ||||||
|         return "dev" |         return "dev" | ||||||
|  |     if SERVICE_HOST_ENV_NAME in os.environ: | ||||||
|  |         return "kubernetes" | ||||||
|  |     if Path("/tmp/authentik-mode").exists():  # nosec | ||||||
|  |         return "compose" | ||||||
|     if "AK_APPLIANCE" in os.environ: |     if "AK_APPLIANCE" in os.environ: | ||||||
|         return os.environ["AK_APPLIANCE"] |         return os.environ["AK_APPLIANCE"] | ||||||
|     return "custom" |     return "custom" | ||||||
|  | |||||||
| @ -148,7 +148,6 @@ class OutpostViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     @extend_schema(responses={200: OutpostHealthSerializer(many=True)}) |     @extend_schema(responses={200: OutpostHealthSerializer(many=True)}) | ||||||
|     @action(methods=["GET"], detail=True, pagination_class=None) |     @action(methods=["GET"], detail=True, pagination_class=None) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def health(self, request: Request, pk: int) -> Response: |     def health(self, request: Request, pk: int) -> Response: | ||||||
|         """Get outposts current health""" |         """Get outposts current health""" | ||||||
|         outpost: Outpost = self.get_object() |         outpost: Outpost = self.get_object() | ||||||
|  | |||||||
| @ -91,7 +91,6 @@ class ServiceConnectionViewSet( | |||||||
|  |  | ||||||
|     @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) |     @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument, invalid-name |  | ||||||
|     def state(self, request: Request, pk: str) -> Response: |     def state(self, request: Request, pk: str) -> Response: | ||||||
|         """Get the service connection's state""" |         """Get the service connection's state""" | ||||||
|         connection = self.get_object() |         connection = self.get_object() | ||||||
|  | |||||||
| @ -69,7 +69,6 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|         self.outpost = outpost |         self.outpost = outpost | ||||||
|         self.last_uid = self.channel_name |         self.last_uid = self.channel_name | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def disconnect(self, code): |     def disconnect(self, code): | ||||||
|         if self.outpost and self.last_uid: |         if self.outpost and self.last_uid: | ||||||
|             state = OutpostState.for_instance_uid(self.outpost, self.last_uid) |             state = OutpostState.for_instance_uid(self.outpost, self.last_uid) | ||||||
| @ -127,7 +126,6 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|         response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK) |         response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK) | ||||||
|         self.send_json(asdict(response)) |         self.send_json(asdict(response)) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def event_update(self, event):  # pragma: no cover |     def event_update(self, event):  # pragma: no cover | ||||||
|         """Event handler which is called by post_save signals, Send update instruction""" |         """Event handler which is called by post_save signals, Send update instruction""" | ||||||
|         self.send_json( |         self.send_json( | ||||||
|  | |||||||
| @ -23,7 +23,6 @@ UPDATE_TRIGGERING_MODELS = ( | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_save, sender=Outpost) | @receiver(pre_save, sender=Outpost) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def pre_save_outpost(sender, instance: Outpost, **_): | def pre_save_outpost(sender, instance: Outpost, **_): | ||||||
|     """Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes, |     """Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes, | ||||||
|     we call down and then wait for the up after save""" |     we call down and then wait for the up after save""" | ||||||
| @ -43,7 +42,6 @@ def pre_save_outpost(sender, instance: Outpost, **_): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(m2m_changed, sender=Outpost.providers.through) | @receiver(m2m_changed, sender=Outpost.providers.through) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def m2m_changed_update(sender, instance: Model, action: str, **_): | def m2m_changed_update(sender, instance: Model, action: str, **_): | ||||||
|     """Update outpost on m2m change, when providers are added or removed""" |     """Update outpost on m2m change, when providers are added or removed""" | ||||||
|     if action in ["post_add", "post_remove", "post_clear"]: |     if action in ["post_add", "post_remove", "post_clear"]: | ||||||
| @ -51,7 +49,6 @@ def m2m_changed_update(sender, instance: Model, action: str, **_): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def post_save_update(sender, instance: Model, created: bool, **_): | def post_save_update(sender, instance: Model, created: bool, **_): | ||||||
|     """If an Outpost is saved, Ensure that token is created/updated |     """If an Outpost is saved, Ensure that token is created/updated | ||||||
|  |  | ||||||
| @ -70,7 +67,6 @@ def post_save_update(sender, instance: Model, created: bool, **_): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_delete, sender=Outpost) | @receiver(pre_delete, sender=Outpost) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def pre_delete_cleanup(sender, instance: Outpost, **_): | def pre_delete_cleanup(sender, instance: Outpost, **_): | ||||||
|     """Ensure that Outpost's user is deleted (which will delete the token through cascade)""" |     """Ensure that Outpost's user is deleted (which will delete the token through cascade)""" | ||||||
|     instance.user.delete() |     instance.user.delete() | ||||||
|  | |||||||
| @ -144,7 +144,6 @@ class PolicyViewSet( | |||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) | ||||||
|     # pylint: disable=unused-argument, invalid-name |  | ||||||
|     def test(self, request: Request, pk: str) -> Response: |     def test(self, request: Request, pk: str) -> Response: | ||||||
|         """Test policy""" |         """Test policy""" | ||||||
|         policy = self.get_object() |         policy = self.get_object() | ||||||
|  | |||||||
| @ -52,6 +52,8 @@ class PolicyEngine: | |||||||
|         self.empty_result = True |         self.empty_result = True | ||||||
|         if not isinstance(pbm, PolicyBindingModel):  # pragma: no cover |         if not isinstance(pbm, PolicyBindingModel):  # pragma: no cover | ||||||
|             raise ValueError(f"{pbm} is not instance of PolicyBindingModel") |             raise ValueError(f"{pbm} is not instance of PolicyBindingModel") | ||||||
|  |         if not user: | ||||||
|  |             raise ValueError("User must be set") | ||||||
|         self.__pbm = pbm |         self.__pbm = pbm | ||||||
|         self.request = PolicyRequest(user) |         self.request = PolicyRequest(user) | ||||||
|         self.request.obj = pbm |         self.request.obj = pbm | ||||||
|  | |||||||
| @ -1,14 +1,25 @@ | |||||||
| """Event Matcher Policy API""" | """Event Matcher Policy API""" | ||||||
|  | from django.utils.translation import gettext as _ | ||||||
|  | from rest_framework.fields import ChoiceField | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.policies.api.policies import PolicySerializer | from authentik.policies.api.policies import PolicySerializer | ||||||
| from authentik.policies.event_matcher.models import EventMatcherPolicy | from authentik.policies.event_matcher.models import EventMatcherPolicy, app_choices | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventMatcherPolicySerializer(PolicySerializer): | class EventMatcherPolicySerializer(PolicySerializer): | ||||||
|     """Event Matcher Policy Serializer""" |     """Event Matcher Policy Serializer""" | ||||||
|  |  | ||||||
|  |     app = ChoiceField( | ||||||
|  |         choices=app_choices(), | ||||||
|  |         required=False, | ||||||
|  |         help_text=_( | ||||||
|  |             "Match events created by selected application. When left empty, " | ||||||
|  |             "all applications are matched." | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = EventMatcherPolicy |         model = EventMatcherPolicy | ||||||
|         fields = PolicySerializer.Meta.fields + [ |         fields = PolicySerializer.Meta.fields + [ | ||||||
|  | |||||||
| @ -0,0 +1,25 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-01-05 09:24 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ( | ||||||
|  |             "authentik_policies_event_matcher", | ||||||
|  |             "0020_eventmatcherpolicy_authentik_p_policy__e605cf_idx", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="eventmatcherpolicy", | ||||||
|  |             name="app", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 blank=True, | ||||||
|  |                 default="", | ||||||
|  |                 help_text="Match events created by selected application. When left empty, all applications are matched.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -33,7 +33,6 @@ class EventMatcherPolicy(Policy): | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|     app = models.TextField( |     app = models.TextField( | ||||||
|         choices=app_choices(), |  | ||||||
|         blank=True, |         blank=True, | ||||||
|         default="", |         default="", | ||||||
|         help_text=_( |         help_text=_( | ||||||
|  | |||||||
| @ -1,24 +0,0 @@ | |||||||
| """Source API Views""" |  | ||||||
| from rest_framework.viewsets import ModelViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.policies.api.policies import PolicySerializer |  | ||||||
| from authentik.policies.hibp.models import HaveIBeenPwendPolicy |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class HaveIBeenPwendPolicySerializer(PolicySerializer): |  | ||||||
|     """Have I Been Pwned Policy Serializer""" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = HaveIBeenPwendPolicy |  | ||||||
|         fields = PolicySerializer.Meta.fields + ["password_field", "allowed_count"] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class HaveIBeenPwendPolicyViewSet(UsedByMixin, ModelViewSet): |  | ||||||
|     """Source Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = HaveIBeenPwendPolicy.objects.all() |  | ||||||
|     serializer_class = HaveIBeenPwendPolicySerializer |  | ||||||
|     filterset_fields = "__all__" |  | ||||||
|     search_fields = ["name", "password_field"] |  | ||||||
|     ordering = ["name"] |  | ||||||
| @ -1,11 +0,0 @@ | |||||||
| """Authentik hibp app config""" |  | ||||||
|  |  | ||||||
| from django.apps import AppConfig |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikPolicyHIBPConfig(AppConfig): |  | ||||||
|     """Authentik hibp app config""" |  | ||||||
|  |  | ||||||
|     name = "authentik.policies.hibp" |  | ||||||
|     label = "authentik_policies_hibp" |  | ||||||
|     verbose_name = "authentik Policies.HaveIBeenPwned" |  | ||||||
| @ -1,38 +0,0 @@ | |||||||
| # Generated by Django 3.0.6 on 2020-05-19 22:08 |  | ||||||
|  |  | ||||||
| import django.db.models.deletion |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     initial = True |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_policies", "0001_initial"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="HaveIBeenPwendPolicy", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "policy_ptr", |  | ||||||
|                     models.OneToOneField( |  | ||||||
|                         auto_created=True, |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         parent_link=True, |  | ||||||
|                         primary_key=True, |  | ||||||
|                         serialize=False, |  | ||||||
|                         to="authentik_policies.Policy", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("allowed_count", models.IntegerField(default=0)), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Have I Been Pwned Policy", |  | ||||||
|                 "verbose_name_plural": "Have I Been Pwned Policies", |  | ||||||
|             }, |  | ||||||
|             bases=("authentik_policies.policy",), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,21 +0,0 @@ | |||||||
| # Generated by Django 3.0.8 on 2020-07-10 18:45 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_policies_hibp", "0001_initial"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="haveibeenpwendpolicy", |  | ||||||
|             name="password_field", |  | ||||||
|             field=models.TextField( |  | ||||||
|                 default="password", |  | ||||||
|                 help_text="Field key to check, field keys defined in Prompt stages are available.", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,17 +0,0 @@ | |||||||
| # Generated by Django 4.1.2 on 2022-10-19 19:24 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_policies_hibp", "0002_haveibeenpwendpolicy_password_field"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddIndex( |  | ||||||
|             model_name="haveibeenpwendpolicy", |  | ||||||
|             index=models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__6957d7_idx"), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,71 +0,0 @@ | |||||||
| """authentik HIBP Models""" |  | ||||||
| from hashlib import sha1 |  | ||||||
|  |  | ||||||
| from django.db import models |  | ||||||
| from django.utils.translation import gettext as _ |  | ||||||
| from rest_framework.serializers import BaseSerializer |  | ||||||
| from structlog.stdlib import get_logger |  | ||||||
|  |  | ||||||
| from authentik.lib.utils.http import get_http_session |  | ||||||
| from authentik.policies.models import Policy, PolicyResult |  | ||||||
| from authentik.policies.types import PolicyRequest |  | ||||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class HaveIBeenPwendPolicy(Policy): |  | ||||||
|     """DEPRECATED. Check if password is on HaveIBeenPwned's list by uploading the first |  | ||||||
|     5 characters of the SHA1 Hash.""" |  | ||||||
|  |  | ||||||
|     password_field = models.TextField( |  | ||||||
|         default="password", |  | ||||||
|         help_text=_("Field key to check, field keys defined in Prompt stages are available."), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     allowed_count = models.IntegerField(default=0) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[BaseSerializer]: |  | ||||||
|         from authentik.policies.hibp.api import HaveIBeenPwendPolicySerializer |  | ||||||
|  |  | ||||||
|         return HaveIBeenPwendPolicySerializer |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def component(self) -> str: |  | ||||||
|         return "ak-policy-hibp-form" |  | ||||||
|  |  | ||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: |  | ||||||
|         """Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5 |  | ||||||
|         characters of Password in request and checks if full hash is in response. Returns 0 |  | ||||||
|         if Password is not in result otherwise the count of how many times it was used.""" |  | ||||||
|         password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get( |  | ||||||
|             self.password_field, request.context.get(self.password_field) |  | ||||||
|         ) |  | ||||||
|         if not password: |  | ||||||
|             LOGGER.warning( |  | ||||||
|                 "Password field not set in Policy Request", |  | ||||||
|                 field=self.password_field, |  | ||||||
|                 fields=request.context.keys(), |  | ||||||
|             ) |  | ||||||
|             return PolicyResult(False, _("Password not set in context")) |  | ||||||
|         password = str(password) |  | ||||||
|  |  | ||||||
|         pw_hash = sha1(password.encode("utf-8")).hexdigest()  # nosec |  | ||||||
|         url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}" |  | ||||||
|         result = get_http_session().get(url).text |  | ||||||
|         final_count = 0 |  | ||||||
|         for line in result.split("\r\n"): |  | ||||||
|             full_hash, count = line.split(":") |  | ||||||
|             if pw_hash[5:] == full_hash.lower(): |  | ||||||
|                 final_count = int(count) |  | ||||||
|         LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5]) |  | ||||||
|         if final_count > self.allowed_count: |  | ||||||
|             message = _("Password exists on %(count)d online lists." % {"count": final_count}) |  | ||||||
|             return PolicyResult(False, message) |  | ||||||
|         return PolicyResult(True) |  | ||||||
|  |  | ||||||
|     class Meta(Policy.PolicyMeta): |  | ||||||
|  |  | ||||||
|         verbose_name = _("Have I Been Pwned Policy") |  | ||||||
|         verbose_name_plural = _("Have I Been Pwned Policies") |  | ||||||
| @ -1,44 +0,0 @@ | |||||||
| """HIBP Policy tests""" |  | ||||||
| from django.test import TestCase |  | ||||||
| from guardian.shortcuts import get_anonymous_user |  | ||||||
|  |  | ||||||
| from authentik.lib.generators import generate_key |  | ||||||
| from authentik.policies.hibp.models import HaveIBeenPwendPolicy |  | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult |  | ||||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestHIBPPolicy(TestCase): |  | ||||||
|     """Test HIBP Policy""" |  | ||||||
|  |  | ||||||
|     def test_invalid(self): |  | ||||||
|         """Test without password""" |  | ||||||
|         policy = HaveIBeenPwendPolicy.objects.create( |  | ||||||
|             name="test_invalid", |  | ||||||
|         ) |  | ||||||
|         request = PolicyRequest(get_anonymous_user()) |  | ||||||
|         result: PolicyResult = policy.passes(request) |  | ||||||
|         self.assertFalse(result.passing) |  | ||||||
|         self.assertEqual(result.messages[0], "Password not set in context") |  | ||||||
|  |  | ||||||
|     def test_false(self): |  | ||||||
|         """Failing password case""" |  | ||||||
|         policy = HaveIBeenPwendPolicy.objects.create( |  | ||||||
|             name="test_false", |  | ||||||
|         ) |  | ||||||
|         request = PolicyRequest(get_anonymous_user()) |  | ||||||
|         request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"}  # nosec |  | ||||||
|         result: PolicyResult = policy.passes(request) |  | ||||||
|         self.assertFalse(result.passing) |  | ||||||
|         self.assertTrue(result.messages[0].startswith("Password exists on ")) |  | ||||||
|  |  | ||||||
|     def test_true(self): |  | ||||||
|         """Positive password case""" |  | ||||||
|         policy = HaveIBeenPwendPolicy.objects.create( |  | ||||||
|             name="test_true", |  | ||||||
|         ) |  | ||||||
|         request = PolicyRequest(get_anonymous_user()) |  | ||||||
|         request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()} |  | ||||||
|         result: PolicyResult = policy.passes(request) |  | ||||||
|         self.assertTrue(result.passing) |  | ||||||
|         self.assertEqual(result.messages, tuple()) |  | ||||||
| @ -1,34 +1,10 @@ | |||||||
| # Generated by Django 4.1.3 on 2022-11-14 09:23 | # Generated by Django 4.1.3 on 2022-11-14 09:23 | ||||||
| from django.apps.registry import Apps |  | ||||||
| from django.db import migrations, models | from django.db import migrations, models | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def migrate_hibp_policy(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): |  | ||||||
|     db_alias = schema_editor.connection.alias |  | ||||||
|  |  | ||||||
|     HaveIBeenPwendPolicy = apps.get_model("authentik_policies_hibp", "HaveIBeenPwendPolicy") |  | ||||||
|     PasswordPolicy = apps.get_model("authentik_policies_password", "PasswordPolicy") |  | ||||||
|  |  | ||||||
|     PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") |  | ||||||
|  |  | ||||||
|     for old_policy in HaveIBeenPwendPolicy.objects.using(db_alias).all(): |  | ||||||
|         new_policy = PasswordPolicy.objects.using(db_alias).create( |  | ||||||
|             name=old_policy.name, |  | ||||||
|             hibp_allowed_count=old_policy.allowed_count, |  | ||||||
|             password_field=old_policy.password_field, |  | ||||||
|             execution_logging=old_policy.execution_logging, |  | ||||||
|             check_static_rules=False, |  | ||||||
|             check_have_i_been_pwned=True, |  | ||||||
|         ) |  | ||||||
|         PolicyBinding.objects.using(db_alias).filter(policy=old_policy).update(policy=new_policy) |  | ||||||
|         old_policy.delete() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("authentik_policies_hibp", "0003_haveibeenpwendpolicy_authentik_p_policy__6957d7_idx"), |  | ||||||
|         ("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"), |         ("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"), | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
| @ -69,5 +45,4 @@ class Migration(migrations.Migration): | |||||||
|             name="error_message", |             name="error_message", | ||||||
|             field=models.TextField(blank=True), |             field=models.TextField(blank=True), | ||||||
|         ), |         ), | ||||||
|         migrations.RunPython(migrate_hibp_policy), |  | ||||||
|     ] |     ] | ||||||
|  | |||||||
| @ -138,5 +138,5 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|         try: |         try: | ||||||
|             self.connection.send(self.profiling_wrapper()) |             self.connection.send(self.profiling_wrapper()) | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|             LOGGER.warning("Policy failed to run", exc=exc) |             LOGGER.warning("Policy failed to run", exc=exception_to_string(exc)) | ||||||
|             self.connection.send(PolicyResult(False, str(exc))) |             self.connection.send(PolicyResult(False, str(exc))) | ||||||
|  | |||||||
| @ -37,7 +37,6 @@ def update_score(request: HttpRequest, identifier: str, amount: int): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(login_failed) | @receiver(login_failed) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def handle_failed_login(sender, request, credentials, **_): | def handle_failed_login(sender, request, credentials, **_): | ||||||
|     """Lower Score for failed login attempts""" |     """Lower Score for failed login attempts""" | ||||||
|     if "username" in credentials: |     if "username" in credentials: | ||||||
| @ -45,14 +44,12 @@ def handle_failed_login(sender, request, credentials, **_): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(identification_failed) | @receiver(identification_failed) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def handle_identification_failed(sender, request, uid_field: str, **_): | def handle_identification_failed(sender, request, uid_field: str, **_): | ||||||
|     """Lower Score for failed identification attempts""" |     """Lower Score for failed identification attempts""" | ||||||
|     update_score(request, uid_field, -1) |     update_score(request, uid_field, -1) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_in) | @receiver(user_logged_in) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def handle_successful_login(sender, request, user, **_): | def handle_successful_login(sender, request, user, **_): | ||||||
|     """Raise score for successful attempts""" |     """Raise score for successful attempts""" | ||||||
|     update_score(request, user.username, 1) |     update_score(request, user.username, 1) | ||||||
|  | |||||||
| @ -13,14 +13,12 @@ LOGGER = get_logger() | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(monitoring_set) | @receiver(monitoring_set) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def monitoring_set_policies(sender, **kwargs): | def monitoring_set_policies(sender, **kwargs): | ||||||
|     """set policy gauges""" |     """set policy gauges""" | ||||||
|     GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or [])) |     GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or [])) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def invalidate_policy_cache(sender, instance, **_): | def invalidate_policy_cache(sender, instance, **_): | ||||||
|     """Invalidate Policy cache when policy is updated""" |     """Invalidate Policy cache when policy is updated""" | ||||||
|     from authentik.policies.models import Policy, PolicyBinding |     from authentik.policies.models import Policy, PolicyBinding | ||||||
|  | |||||||
| @ -83,7 +83,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet): | |||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
|     @action(methods=["GET"], detail=True) |     @action(methods=["GET"], detail=True) | ||||||
|     # pylint: disable=invalid-name |  | ||||||
|     def setup_urls(self, request: Request, pk: int) -> str: |     def setup_urls(self, request: Request, pk: int) -> str: | ||||||
|         """Get Providers setup URLs""" |         """Get Providers setup URLs""" | ||||||
|         provider = get_object_or_404(OAuth2Provider, pk=pk) |         provider = get_object_or_404(OAuth2Provider, pk=pk) | ||||||
| @ -140,7 +139,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet): | |||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["GET"]) |     @action(detail=True, methods=["GET"]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |  | ||||||
|     def preview_user(self, request: Request, pk: int) -> Response: |     def preview_user(self, request: Request, pk: int) -> Response: | ||||||
|         """Preview user data for provider""" |         """Preview user data for provider""" | ||||||
|         provider: OAuth2Provider = self.get_object() |         provider: OAuth2Provider = self.get_object() | ||||||
|  | |||||||
| @ -3,6 +3,8 @@ 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 extend_schema_field | from drf_spectacular.utils import extend_schema_field | ||||||
|  | from rest_framework.fields import CharField | ||||||
|  | from rest_framework.serializers import ValidationError | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.propertymappings import PropertyMappingSerializer | from authentik.core.api.propertymappings import PropertyMappingSerializer | ||||||
| @ -10,9 +12,18 @@ from authentik.core.api.used_by import UsedByMixin | |||||||
| from authentik.providers.oauth2.models import ScopeMapping | from authentik.providers.oauth2.models import ScopeMapping | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def no_space(value: str) -> str: | ||||||
|  |     """Ensure value contains no spaces""" | ||||||
|  |     if " " in value: | ||||||
|  |         raise ValidationError("Value must not contain spaces.") | ||||||
|  |     return value | ||||||
|  |  | ||||||
|  |  | ||||||
| class ScopeMappingSerializer(PropertyMappingSerializer): | class ScopeMappingSerializer(PropertyMappingSerializer): | ||||||
|     """ScopeMapping Serializer""" |     """ScopeMapping Serializer""" | ||||||
|  |  | ||||||
|  |     scope_name = CharField(help_text="Scope name requested by the client", validators=[no_space]) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = ScopeMapping |         model = ScopeMapping | ||||||
|  | |||||||
| @ -16,7 +16,7 @@ class Migration(migrations.Migration): | |||||||
|             model_name="oauth2provider", |             model_name="oauth2provider", | ||||||
|             name="verification_keys", |             name="verification_keys", | ||||||
|             field=models.ManyToManyField( |             field=models.ManyToManyField( | ||||||
|                 help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.", |                 help_text="JWTs created with the configured certificates can authenticate with this provider.", | ||||||
|                 related_name="+", |                 related_name="+", | ||||||
|                 to="authentik_crypto.certificatekeypair", |                 to="authentik_crypto.certificatekeypair", | ||||||
|                 verbose_name="Allowed certificates for JWT-based client_credentials", |                 verbose_name="Allowed certificates for JWT-based client_credentials", | ||||||
|  | |||||||
| @ -32,7 +32,7 @@ class Migration(migrations.Migration): | |||||||
|             field=models.ManyToManyField( |             field=models.ManyToManyField( | ||||||
|                 blank=True, |                 blank=True, | ||||||
|                 default=None, |                 default=None, | ||||||
|                 help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.", |                 help_text="JWTs created with the configured certificates can authenticate with this provider.", | ||||||
|                 related_name="oauth2_providers", |                 related_name="oauth2_providers", | ||||||
|                 to="authentik_crypto.certificatekeypair", |                 to="authentik_crypto.certificatekeypair", | ||||||
|                 verbose_name="Allowed certificates for JWT-based client_credentials", |                 verbose_name="Allowed certificates for JWT-based client_credentials", | ||||||
|  | |||||||
| @ -22,8 +22,7 @@ from rest_framework.serializers import Serializer | |||||||
|  |  | ||||||
| from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User | from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.events.models import Event | from authentik.events.signals import get_login_event | ||||||
| from authentik.events.signals import SESSION_LOGIN_EVENT |  | ||||||
| from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key | from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
| from authentik.lib.utils.time import timedelta_string_validator | from authentik.lib.utils.time import timedelta_string_validator | ||||||
| @ -419,6 +418,8 @@ class IDToken: | |||||||
|             id_dict.pop("nonce") |             id_dict.pop("nonce") | ||||||
|         if not self.c_hash: |         if not self.c_hash: | ||||||
|             id_dict.pop("c_hash") |             id_dict.pop("c_hash") | ||||||
|  |         if not self.amr: | ||||||
|  |             id_dict.pop("amr") | ||||||
|         id_dict.pop("claims") |         id_dict.pop("claims") | ||||||
|         id_dict.update(self.claims) |         id_dict.update(self.claims) | ||||||
|         return id_dict |         return id_dict | ||||||
| @ -503,8 +504,8 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | |||||||
|         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time |         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time | ||||||
|         # Fallback in case we can't find any login events |         # Fallback in case we can't find any login events | ||||||
|         auth_time = now |         auth_time = now | ||||||
|         if SESSION_LOGIN_EVENT in request.session: |         auth_event = get_login_event(request) | ||||||
|             auth_event: Event = request.session[SESSION_LOGIN_EVENT] |         if auth_event: | ||||||
|             auth_time = auth_event.created |             auth_time = auth_event.created | ||||||
|             # Also check which method was used for authentication |             # Also check which method was used for authentication | ||||||
|             method = auth_event.context.get(PLAN_CONTEXT_METHOD, "") |             method = auth_event.context.get(PLAN_CONTEXT_METHOD, "") | ||||||
| @ -526,6 +527,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | |||||||
|             exp=exp_time, |             exp=exp_time, | ||||||
|             iat=iat_time, |             iat=iat_time, | ||||||
|             auth_time=auth_timestamp, |             auth_time=auth_timestamp, | ||||||
|  |             amr=amr if amr else None, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Include (or not) user standard claims in the id_token. |         # Include (or not) user standard claims in the id_token. | ||||||
|  | |||||||
| @ -1,10 +1,13 @@ | |||||||
| """Test authorize view""" | """Test authorize view""" | ||||||
|  | from unittest.mock import MagicMock, patch | ||||||
|  |  | ||||||
| from django.test import RequestFactory | from django.test import RequestFactory | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.flows.challenge import ChallengeTypes | from authentik.flows.challenge import ChallengeTypes | ||||||
| from authentik.lib.generators import generate_id, generate_key | from authentik.lib.generators import generate_id, generate_key | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| @ -17,6 +20,7 @@ from authentik.providers.oauth2.models import ( | |||||||
| ) | ) | ||||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||||
| from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams | from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams | ||||||
|  | from authentik.stages.password.stage import PLAN_CONTEXT_METHOD | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestAuthorize(OAuthTestCase): | class TestAuthorize(OAuthTestCase): | ||||||
| @ -302,6 +306,16 @@ class TestAuthorize(OAuthTestCase): | |||||||
|         state = generate_id() |         state = generate_id() | ||||||
|         user = create_test_admin_user() |         user = create_test_admin_user() | ||||||
|         self.client.force_login(user) |         self.client.force_login(user) | ||||||
|  |         with patch( | ||||||
|  |             "authentik.providers.oauth2.models.get_login_event", | ||||||
|  |             MagicMock( | ||||||
|  |                 return_value=Event( | ||||||
|  |                     action=EventAction.LOGIN, | ||||||
|  |                     context={PLAN_CONTEXT_METHOD: "password"}, | ||||||
|  |                     created=now(), | ||||||
|  |                 ) | ||||||
|  |             ), | ||||||
|  |         ): | ||||||
|             # Step 1, initiate params and get redirect to flow |             # Step 1, initiate params and get redirect to flow | ||||||
|             self.client.get( |             self.client.get( | ||||||
|                 reverse("authentik_providers_oauth2:authorize"), |                 reverse("authentik_providers_oauth2:authorize"), | ||||||
| @ -331,6 +345,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|             jwt = self.validate_jwt(token, provider) |             jwt = self.validate_jwt(token, provider) | ||||||
|  |             self.assertEqual(jwt["amr"], ["pwd"]) | ||||||
|             self.assertAlmostEqual( |             self.assertAlmostEqual( | ||||||
|                 jwt["exp"] - now().timestamp(), |                 jwt["exp"] - now().timestamp(), | ||||||
|                 expires, |                 expires, | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	