Compare commits
	
		
			250 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9397598376 | |||
| 91ffe4e7f9 | |||
| 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]
 | 
			
		||||
current_version = 2022.12.1
 | 
			
		||||
current_version = 2023.1.2
 | 
			
		||||
tag = True
 | 
			
		||||
commit = True
 | 
			
		||||
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
 | 
			
		||||
            ```
 | 
			
		||||
 | 
			
		||||
            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.
 | 
			
		||||
          </details>
 | 
			
		||||
          <details>
 | 
			
		||||
@ -54,6 +62,17 @@ runs:
 | 
			
		||||
                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.
 | 
			
		||||
          </details>
 | 
			
		||||
        edit-mode: replace
 | 
			
		||||
 | 
			
		||||
@ -17,6 +17,9 @@ outputs:
 | 
			
		||||
  sha:
 | 
			
		||||
    description: "sha"
 | 
			
		||||
    value: ${{ steps.ev.outputs.sha }}
 | 
			
		||||
  shortHash:
 | 
			
		||||
    description: "shortHash"
 | 
			
		||||
    value: ${{ steps.ev.outputs.shortHash }}
 | 
			
		||||
  version:
 | 
			
		||||
    description: "version"
 | 
			
		||||
    value: ${{ steps.ev.outputs.version }}
 | 
			
		||||
@ -53,6 +56,7 @@ runs:
 | 
			
		||||
            print("branchNameContainer=%s" % safe_branch_name, file=_output)
 | 
			
		||||
            print("timestamp=%s" % int(time()), 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("version=%s" % version, 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
 | 
			
		||||
      - name: run integration
 | 
			
		||||
        run: |
 | 
			
		||||
          poetry run make test-integration
 | 
			
		||||
          poetry run coverage run manage.py test tests/integration
 | 
			
		||||
          poetry run coverage xml
 | 
			
		||||
      - if: ${{ always() }}
 | 
			
		||||
        uses: codecov/codecov-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          flags: integration
 | 
			
		||||
  test-e2e-provider:
 | 
			
		||||
  test-e2e:
 | 
			
		||||
    name: test-e2e (${{ matrix.job.name }})
 | 
			
		||||
    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:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - name: Setup authentik env
 | 
			
		||||
@ -131,36 +148,7 @@ jobs:
 | 
			
		||||
          npm run build
 | 
			
		||||
      - name: run e2e
 | 
			
		||||
        run: |
 | 
			
		||||
          poetry run make test-e2e-provider
 | 
			
		||||
          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 run manage.py test ${{ matrix.job.glob }}
 | 
			
		||||
          poetry run coverage xml
 | 
			
		||||
      - if: ${{ always() }}
 | 
			
		||||
        uses: codecov/codecov-action@v3
 | 
			
		||||
@ -173,8 +161,7 @@ jobs:
 | 
			
		||||
      - test-migrations-from-stable
 | 
			
		||||
      - test-unittest
 | 
			
		||||
      - test-integration
 | 
			
		||||
      - test-e2e-rest
 | 
			
		||||
      - test-e2e-provider
 | 
			
		||||
      - test-e2e
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - run: echo mark
 | 
			
		||||
@ -182,11 +169,6 @@ jobs:
 | 
			
		||||
    needs: ci-core-mark
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    timeout-minutes: 120
 | 
			
		||||
    strategy:
 | 
			
		||||
      fail-fast: false
 | 
			
		||||
      matrix:
 | 
			
		||||
        arch:
 | 
			
		||||
          - 'linux/amd64'
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - name: Set up QEMU
 | 
			
		||||
@ -205,7 +187,7 @@ jobs:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          secrets: |
 | 
			
		||||
@ -214,14 +196,49 @@ jobs:
 | 
			
		||||
          push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            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: |
 | 
			
		||||
            GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
 | 
			
		||||
            VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
 | 
			
		||||
          platforms: ${{ matrix.arch }}
 | 
			
		||||
      - name: Comment on PR
 | 
			
		||||
        if: github.event_name == 'pull_request'
 | 
			
		||||
        continue-on-error: true
 | 
			
		||||
        uses: ./.github/actions/comment-pr-instructions
 | 
			
		||||
        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
 | 
			
		||||
      - name: golangci-lint
 | 
			
		||||
        uses: golangci/golangci-lint-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          args: --timeout 5000s
 | 
			
		||||
  test-unittest:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
@ -80,13 +82,12 @@ jobs:
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Generate API
 | 
			
		||||
        run: make gen-client-go
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            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 }}
 | 
			
		||||
          file: ${{ matrix.type }}.Dockerfile
 | 
			
		||||
          build-args: |
 | 
			
		||||
@ -111,7 +112,7 @@ jobs:
 | 
			
		||||
      - uses: actions/setup-go@v3
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: "^1.17"
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          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
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
@ -27,11 +27,27 @@ jobs:
 | 
			
		||||
      - name: Eslint
 | 
			
		||||
        working-directory: web/
 | 
			
		||||
        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:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
@ -47,7 +63,7 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
@ -69,6 +85,7 @@ jobs:
 | 
			
		||||
      - lint-eslint
 | 
			
		||||
      - lint-prettier
 | 
			
		||||
      - lint-lit-analyse
 | 
			
		||||
      - lint-build
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - run: echo mark
 | 
			
		||||
@ -78,7 +95,7 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          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
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          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
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Delete 'dev' containers older than a week
 | 
			
		||||
        uses: sondrelg/container-retention-policy@v1
 | 
			
		||||
        uses: snok/container-retention-policy@v1
 | 
			
		||||
        with:
 | 
			
		||||
          image-names: dev-server,dev-ldap,dev-proxy
 | 
			
		||||
          cut-off: One week ago UTC
 | 
			
		||||
          account-type: org
 | 
			
		||||
          org-name: goauthentik
 | 
			
		||||
          untagged-only: false
 | 
			
		||||
          token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          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
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ github.event_name == 'release' }}
 | 
			
		||||
          secrets:
 | 
			
		||||
          secrets: |
 | 
			
		||||
            GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
 | 
			
		||||
            GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
 | 
			
		||||
          tags: |
 | 
			
		||||
@ -75,7 +75,7 @@ jobs:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ github.event_name == 'release' }}
 | 
			
		||||
@ -88,9 +88,6 @@ jobs:
 | 
			
		||||
            ghcr.io/goauthentik/${{ matrix.type }}:latest
 | 
			
		||||
          file: ${{ matrix.type }}.Dockerfile
 | 
			
		||||
          platforms: linux/amd64,linux/arm64
 | 
			
		||||
          secrets: |
 | 
			
		||||
            GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
 | 
			
		||||
            GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
 | 
			
		||||
          build-args: |
 | 
			
		||||
            VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
 | 
			
		||||
  build-outpost-binary:
 | 
			
		||||
@ -109,7 +106,7 @@ jobs:
 | 
			
		||||
      - uses: actions/setup-go@v3
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: "^1.17"
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							@ -26,14 +26,14 @@ jobs:
 | 
			
		||||
        id: get_version
 | 
			
		||||
        uses: actions/github-script@v6
 | 
			
		||||
        with:
 | 
			
		||||
          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          github-token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          script: |
 | 
			
		||||
            return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
 | 
			
		||||
      - name: Create Release
 | 
			
		||||
        id: create_release
 | 
			
		||||
        uses: actions/create-release@v1.1.4
 | 
			
		||||
        env:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
        with:
 | 
			
		||||
          tag_name: ${{ github.ref }}
 | 
			
		||||
          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
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
      - name: Setup authentik env
 | 
			
		||||
        uses: ./.github/actions/setup
 | 
			
		||||
      - name: run compile
 | 
			
		||||
@ -27,7 +29,7 @@ jobs:
 | 
			
		||||
        uses: peter-evans/create-pull-request@v4
 | 
			
		||||
        id: cpr
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          branch: compile-backend-translation
 | 
			
		||||
          commit-message: "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
 | 
			
		||||
    steps:
 | 
			
		||||
      - 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:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          registry-url: 'https://registry.npmjs.org'
 | 
			
		||||
@ -28,14 +30,20 @@ jobs:
 | 
			
		||||
        run: |
 | 
			
		||||
          export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").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
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          branch: update-web-api-client
 | 
			
		||||
          commit-message: "web: bump API Client version"
 | 
			
		||||
          title: "web: bump API Client version"
 | 
			
		||||
          body: "web: bump API Client version"
 | 
			
		||||
          delete-branch: 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",
 | 
			
		||||
        "traefik",
 | 
			
		||||
        "passwordless",
 | 
			
		||||
        "kubernetes"
 | 
			
		||||
        "kubernetes",
 | 
			
		||||
        "sso",
 | 
			
		||||
        "slo"
 | 
			
		||||
    ],
 | 
			
		||||
    "python.linting.pylintEnabled": true,
 | 
			
		||||
    "todo-tree.tree.showCountsInTree": true,
 | 
			
		||||
 | 
			
		||||
@ -59,19 +59,18 @@ These are the current packages:
 | 
			
		||||
authentik
 | 
			
		||||
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
 | 
			
		||||
├── 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
 | 
			
		||||
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
 | 
			
		||||
├── events - Event Log, middleware and signals to generate signals
 | 
			
		||||
├── 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.
 | 
			
		||||
├── managed - Handle managed models and their state.
 | 
			
		||||
├── outposts - Configure and deploy outposts on kubernetes and docker.
 | 
			
		||||
├── policies - General PolicyEngine
 | 
			
		||||
│   ├── dummy - A Dummy policy used for testing
 | 
			
		||||
│   ├── event_matcher - Match events based on different criteria
 | 
			
		||||
│   ├── expiry - Check when a user's password was last set
 | 
			
		||||
│   ├── expression - Execute any arbitrary python code
 | 
			
		||||
│   ├── hibp - Check a password against HaveIBeenPwned
 | 
			
		||||
│   ├── password - Check a password against several rules
 | 
			
		||||
│   └── reputation - Check the user's/client's reputation
 | 
			
		||||
├── providers
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \
 | 
			
		||||
    poetry export -f requirements.txt --dev --output requirements-dev.txt
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
 | 
			
		||||
@ -50,6 +50,7 @@ RUN go build -o /work/authentik ./cmd/server/
 | 
			
		||||
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
 | 
			
		||||
 | 
			
		||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
 | 
			
		||||
ENV GEOIPUPDATE_VERBOSE="true"
 | 
			
		||||
 | 
			
		||||
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
 | 
			
		||||
    --mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
 | 
			
		||||
@ -57,7 +58,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
 | 
			
		||||
    /bin/sh -c "\
 | 
			
		||||
        export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
 | 
			
		||||
        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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								Makefile
									
									
									
									
									
								
							@ -6,15 +6,6 @@ NPM_VERSION = $(shell python -m scripts.npm_version)
 | 
			
		||||
 | 
			
		||||
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:
 | 
			
		||||
	go test -timeout 0 -v -race -cover ./...
 | 
			
		||||
 | 
			
		||||
@ -126,7 +117,7 @@ gen: gen-build gen-clean gen-client-ts
 | 
			
		||||
web-build: web-install
 | 
			
		||||
	cd web && npm run build
 | 
			
		||||
 | 
			
		||||
web: web-lint-fix web-lint
 | 
			
		||||
web: web-lint-fix web-lint web-check-compile
 | 
			
		||||
 | 
			
		||||
web-install:
 | 
			
		||||
	cd web && npm ci
 | 
			
		||||
@ -144,6 +135,9 @@ web-lint:
 | 
			
		||||
	cd web && npm run lint
 | 
			
		||||
	cd web && npm run lit-analyse
 | 
			
		||||
 | 
			
		||||
web-check-compile:
 | 
			
		||||
	cd web && npm run tsc
 | 
			
		||||
 | 
			
		||||
web-extract:
 | 
			
		||||
	cd web && npm run extract
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,8 +6,8 @@ Authentik takes security very seriously. We follow the rules of [responsible dis
 | 
			
		||||
 | 
			
		||||
| Version   | Supported          |
 | 
			
		||||
| --------- | ------------------ |
 | 
			
		||||
| 2022.11.x | :white_check_mark: |
 | 
			
		||||
| 2022.12.x | :white_check_mark: |
 | 
			
		||||
| 2023.1.x  | :white_check_mark: |
 | 
			
		||||
 | 
			
		||||
## Reporting a Vulnerability
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,16 +2,14 @@
 | 
			
		||||
from os import environ
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
__version__ = "2022.12.1"
 | 
			
		||||
__version__ = "2023.1.2"
 | 
			
		||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_build_hash(fallback: Optional[str] = None) -> str:
 | 
			
		||||
    """Get build hash"""
 | 
			
		||||
    build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
 | 
			
		||||
    if build_hash == "" and fallback:
 | 
			
		||||
        return fallback
 | 
			
		||||
    return build_hash
 | 
			
		||||
    return fallback if build_hash == "" and fallback else build_hash
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_full_version() -> str:
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,7 @@
 | 
			
		||||
"""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 guardian.shortcuts import get_objects_for_user
 | 
			
		||||
from rest_framework.fields import IntegerField, SerializerMethodField
 | 
			
		||||
@ -21,38 +24,44 @@ class CoordinateSerializer(PassiveSerializer):
 | 
			
		||||
class LoginMetricsSerializer(PassiveSerializer):
 | 
			
		||||
    """Login Metrics per 1h"""
 | 
			
		||||
 | 
			
		||||
    logins_per_1h = SerializerMethodField()
 | 
			
		||||
    logins_failed_per_1h = SerializerMethodField()
 | 
			
		||||
    authorizations_per_1h = SerializerMethodField()
 | 
			
		||||
    logins = SerializerMethodField()
 | 
			
		||||
    logins_failed = SerializerMethodField()
 | 
			
		||||
    authorizations = SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_logins_per_1h(self, _):
 | 
			
		||||
        """Get successful logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_logins(self, _):
 | 
			
		||||
        """Get successful logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.LOGIN)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.LOGIN
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_logins_failed_per_1h(self, _):
 | 
			
		||||
        """Get failed logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_logins_failed(self, _):
 | 
			
		||||
        """Get failed logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.LOGIN_FAILED)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.LOGIN_FAILED
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_authorizations_per_1h(self, _):
 | 
			
		||||
        """Get successful authorizations per hour for the last 24 hours"""
 | 
			
		||||
    def get_authorizations(self, _):
 | 
			
		||||
        """Get successful authorizations per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.AUTHORIZE_APPLICATION)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.AUTHORIZE_APPLICATION
 | 
			
		||||
            )
 | 
			
		||||
            # 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 drf_spectacular.utils import extend_schema
 | 
			
		||||
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.permissions import IsAdminUser
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
@ -16,6 +15,7 @@ from rest_framework.response import Response
 | 
			
		||||
from rest_framework.views import APIView
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.lib.utils.reflection import get_env
 | 
			
		||||
from authentik.outposts.apps import MANAGED_OUTPOST
 | 
			
		||||
from authentik.outposts.models import Outpost
 | 
			
		||||
 | 
			
		||||
@ -69,7 +69,7 @@ class SystemSerializer(PassiveSerializer):
 | 
			
		||||
        return {
 | 
			
		||||
            "python_version": python_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(),
 | 
			
		||||
            "platform": platform.platform(),
 | 
			
		||||
            "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.utils import OpenApiParameter, OpenApiResponse, extend_schema
 | 
			
		||||
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.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
@ -26,6 +32,7 @@ class TaskSerializer(PassiveSerializer):
 | 
			
		||||
    task_name = CharField()
 | 
			
		||||
    task_description = CharField()
 | 
			
		||||
    task_finish_timestamp = DateTimeField(source="finish_time")
 | 
			
		||||
    task_duration = SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    status = ChoiceField(
 | 
			
		||||
        source="result.status.name",
 | 
			
		||||
@ -33,7 +40,11 @@ class TaskSerializer(PassiveSerializer):
 | 
			
		||||
    )
 | 
			
		||||
    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,
 | 
			
		||||
        the API will fail with an AttributeError, as the classes
 | 
			
		||||
        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:
 | 
			
		||||
        """Get a single system task"""
 | 
			
		||||
        task = TaskInfo.by_name(pk)
 | 
			
		||||
@ -99,7 +109,6 @@ class TaskViewSet(ViewSet):
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["post"])
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    def retry(self, request: Request, pk=None) -> Response:
 | 
			
		||||
        """Retry task"""
 | 
			
		||||
        task = TaskInfo.by_name(pk)
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ from authentik.root.monitoring import monitoring_set
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(monitoring_set)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def monitoring_set_workers(sender, **kwargs):
 | 
			
		||||
    """Set worker gauge"""
 | 
			
		||||
    count = len(CELERY_APP.control.ping(timeout=0.5))
 | 
			
		||||
@ -16,7 +15,6 @@ def monitoring_set_workers(sender, **kwargs):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(monitoring_set)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def monitoring_set_tasks(sender, **kwargs):
 | 
			
		||||
    """Set task gauges"""
 | 
			
		||||
    for task in TaskInfo.all().values():
 | 
			
		||||
 | 
			
		||||
@ -62,7 +62,7 @@ window.addEventListener('DOMContentLoaded', (event) => {
 | 
			
		||||
    allow-spec-url-load="false"
 | 
			
		||||
    allow-spec-file-load="false">
 | 
			
		||||
    <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>
 | 
			
		||||
</rapi-doc>
 | 
			
		||||
<script>
 | 
			
		||||
 | 
			
		||||
@ -68,7 +68,7 @@ class ConfigView(APIView):
 | 
			
		||||
            caps.append(Capabilities.CAN_GEO_IP)
 | 
			
		||||
        if CONFIG.y_bool("impersonation"):
 | 
			
		||||
            caps.append(Capabilities.CAN_IMPERSONATE)
 | 
			
		||||
        if settings.DEBUG:
 | 
			
		||||
        if settings.DEBUG:  # pragma: no cover
 | 
			
		||||
            caps.append(Capabilities.CAN_DEBUG)
 | 
			
		||||
        return caps
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -45,7 +45,6 @@ from authentik.policies.dummy.api import DummyPolicyViewSet
 | 
			
		||||
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
 | 
			
		||||
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
 | 
			
		||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
 | 
			
		||||
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
 | 
			
		||||
from authentik.policies.password.api import PasswordPolicyViewSet
 | 
			
		||||
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
 | 
			
		||||
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/expression", ExpressionPolicyViewSet)
 | 
			
		||||
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
 | 
			
		||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
 | 
			
		||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
 | 
			
		||||
router.register("policies/password", PasswordPolicyViewSet)
 | 
			
		||||
router.register("policies/reputation/scores", ReputationViewSet)
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
"""Serializer mixin for managed models"""
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from drf_spectacular.utils import extend_schema, inline_serializer
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
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.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.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
@ -40,6 +42,21 @@ class BlueprintInstanceSerializer(ModelSerializer):
 | 
			
		||||
            raise ValidationError(exc) from exc
 | 
			
		||||
        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:
 | 
			
		||||
 | 
			
		||||
        model = BlueprintInstance
 | 
			
		||||
@ -54,6 +71,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
 | 
			
		||||
            "enabled",
 | 
			
		||||
            "managed_models",
 | 
			
		||||
            "metadata",
 | 
			
		||||
            "content",
 | 
			
		||||
        ]
 | 
			
		||||
        extra_kwargs = {
 | 
			
		||||
            "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"""
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from django.contrib.postgres.fields import ArrayField
 | 
			
		||||
from django.db import models
 | 
			
		||||
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 structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.blueprints.v1.oci import BlueprintOCIClient, OCIException
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
 | 
			
		||||
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()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -74,7 +62,8 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
 | 
			
		||||
 | 
			
		||||
    name = models.TextField()
 | 
			
		||||
    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)
 | 
			
		||||
    last_applied = models.DateTimeField(auto_now=True)
 | 
			
		||||
    last_applied_hash = models.TextField()
 | 
			
		||||
@ -86,60 +75,29 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
 | 
			
		||||
 | 
			
		||||
    def retrieve_oci(self) -> str:
 | 
			
		||||
        """Get blueprint from an OCI registry"""
 | 
			
		||||
        url = urlparse(self.path)
 | 
			
		||||
        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")
 | 
			
		||||
        client = BlueprintOCIClient(self.path.replace("oci://", "https://"))
 | 
			
		||||
        try:
 | 
			
		||||
            manifest_response = client.Do(manifest_request)
 | 
			
		||||
            manifest_response.raise_for_status()
 | 
			
		||||
        except RequestException as exc:
 | 
			
		||||
            manifests = client.fetch_manifests()
 | 
			
		||||
            return client.fetch_blobs(manifests)
 | 
			
		||||
        except OCIException as exc:
 | 
			
		||||
            raise BlueprintRetrievalFailed(exc) from exc
 | 
			
		||||
        manifest = manifest_response.json()
 | 
			
		||||
        if "errors" in manifest:
 | 
			
		||||
            raise BlueprintRetrievalFailed(manifest["errors"])
 | 
			
		||||
 | 
			
		||||
        blob = None
 | 
			
		||||
        for layer in manifest.get("layers", []):
 | 
			
		||||
            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),
 | 
			
		||||
        )
 | 
			
		||||
    def retrieve_file(self) -> str:
 | 
			
		||||
        """Get blueprint from path"""
 | 
			
		||||
        try:
 | 
			
		||||
            blob_response = client.Do(blob_request)
 | 
			
		||||
            blob_response.raise_for_status()
 | 
			
		||||
            return blob_response.text
 | 
			
		||||
        except RequestException as exc:
 | 
			
		||||
            full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
 | 
			
		||||
            with full_path.open("r", encoding="utf-8") as _file:
 | 
			
		||||
                return _file.read()
 | 
			
		||||
        except (IOError, OSError) as exc:
 | 
			
		||||
            raise BlueprintRetrievalFailed(exc) from exc
 | 
			
		||||
 | 
			
		||||
    def retrieve(self) -> str:
 | 
			
		||||
        """Retrieve blueprint contents"""
 | 
			
		||||
        if self.path.startswith("oci://"):
 | 
			
		||||
            return self.retrieve_oci()
 | 
			
		||||
        full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
 | 
			
		||||
        with full_path.open("r", encoding="utf-8") as _file:
 | 
			
		||||
            return _file.read()
 | 
			
		||||
        if self.path != "":
 | 
			
		||||
            return self.retrieve_file()
 | 
			
		||||
        return self.content
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    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
 | 
			
		||||
    policy_property: name
 | 
			
		||||
    policy_property_value: foo-bar-baz-qux
 | 
			
		||||
    sequence:
 | 
			
		||||
    - foo
 | 
			
		||||
    - bar
 | 
			
		||||
    mapping:
 | 
			
		||||
      key1: value
 | 
			
		||||
      key2: 2
 | 
			
		||||
entries:
 | 
			
		||||
    - model: !Format ["%s", authentik_sources_oauth.oauthsource]
 | 
			
		||||
      state: !Format ["%s", present]
 | 
			
		||||
@ -19,7 +25,7 @@ entries:
 | 
			
		||||
                  [slug, default-source-authentication],
 | 
			
		||||
              ]
 | 
			
		||||
          enrollment_flow:
 | 
			
		||||
              !Find [authentik_flows.Flow, [slug, default-source-enrollment]]
 | 
			
		||||
              !Find [!Format  ["%s", authentik_flows.Flow], [slug, default-source-enrollment]]
 | 
			
		||||
    - attrs:
 | 
			
		||||
          expression: return True
 | 
			
		||||
      identifiers:
 | 
			
		||||
@ -92,6 +98,49 @@ entries:
 | 
			
		||||
                  ]
 | 
			
		||||
              if_true_simple: !If [!Context foo, true, text]
 | 
			
		||||
              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:
 | 
			
		||||
          name: test
 | 
			
		||||
      conditions:
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
 | 
			
		||||
    """Test serializer"""
 | 
			
		||||
 | 
			
		||||
    def tester(self: TestModels):
 | 
			
		||||
        if test_model._meta.abstract:
 | 
			
		||||
        if test_model._meta.abstract:  # pragma: no cover
 | 
			
		||||
            return
 | 
			
		||||
        model_class = test_model()
 | 
			
		||||
        self.assertTrue(isinstance(model_class, SerializerModel))
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,8 @@
 | 
			
		||||
from django.test import TransactionTestCase
 | 
			
		||||
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):
 | 
			
		||||
@ -26,8 +27,8 @@ class TestBlueprintOCI(TransactionTestCase):
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(
 | 
			
		||||
                BlueprintInstance(
 | 
			
		||||
                    path="https://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                ).retrieve_oci(),
 | 
			
		||||
                    path="oci://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                ).retrieve(),
 | 
			
		||||
                "foo",
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
@ -40,7 +41,7 @@ class TestBlueprintOCI(TransactionTestCase):
 | 
			
		||||
 | 
			
		||||
            with self.assertRaises(BlueprintRetrievalFailed):
 | 
			
		||||
                BlueprintInstance(
 | 
			
		||||
                    path="https://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                    path="oci://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                ).retrieve_oci()
 | 
			
		||||
 | 
			
		||||
    def test_manifests_error_response(self):
 | 
			
		||||
@ -53,7 +54,7 @@ class TestBlueprintOCI(TransactionTestCase):
 | 
			
		||||
 | 
			
		||||
            with self.assertRaises(BlueprintRetrievalFailed):
 | 
			
		||||
                BlueprintInstance(
 | 
			
		||||
                    path="https://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                    path="oci://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                ).retrieve_oci()
 | 
			
		||||
 | 
			
		||||
    def test_no_matching_blob(self):
 | 
			
		||||
@ -72,7 +73,7 @@ class TestBlueprintOCI(TransactionTestCase):
 | 
			
		||||
            )
 | 
			
		||||
            with self.assertRaises(BlueprintRetrievalFailed):
 | 
			
		||||
                BlueprintInstance(
 | 
			
		||||
                    path="https://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                    path="oci://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                ).retrieve_oci()
 | 
			
		||||
 | 
			
		||||
    def test_blob_error(self):
 | 
			
		||||
@ -93,5 +94,5 @@ class TestBlueprintOCI(TransactionTestCase):
 | 
			
		||||
 | 
			
		||||
            with self.assertRaises(BlueprintRetrievalFailed):
 | 
			
		||||
                BlueprintInstance(
 | 
			
		||||
                    path="https://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                    path="oci://ghcr.io/goauthentik/blueprints/test:latest"
 | 
			
		||||
                ).retrieve_oci()
 | 
			
		||||
 | 
			
		||||
@ -162,6 +162,61 @@ class TestBlueprintsV1(TransactionTestCase):
 | 
			
		||||
                    "if_false_complex": ["list", "with", "items", "foo-bar"],
 | 
			
		||||
                    "if_true_simple": True,
 | 
			
		||||
                    "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"
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    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"""
 | 
			
		||||
from collections import OrderedDict
 | 
			
		||||
from copy import copy
 | 
			
		||||
from dataclasses import asdict, dataclass, field, is_dataclass
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from functools import reduce
 | 
			
		||||
from operator import ixor
 | 
			
		||||
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 deepmerge import always_merger
 | 
			
		||||
from django.apps import apps
 | 
			
		||||
from django.db.models import Model, Q
 | 
			
		||||
from rest_framework.fields import Field
 | 
			
		||||
@ -64,11 +66,13 @@ class BlueprintEntry:
 | 
			
		||||
    identifiers: dict[str, Any] = field(default_factory=dict)
 | 
			
		||||
    attrs: Optional[dict[str, Any]] = field(default_factory=dict)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    id: Optional[str] = None
 | 
			
		||||
 | 
			
		||||
    _state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
 | 
			
		||||
 | 
			
		||||
    def __post_init__(self, *args, **kwargs) -> None:
 | 
			
		||||
        self.__tag_contexts: list["YAMLTagContext"] = []
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
 | 
			
		||||
        """Convert a SerializerModel instance to a blueprint Entry"""
 | 
			
		||||
@ -85,17 +89,46 @@ class BlueprintEntry:
 | 
			
		||||
            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:
 | 
			
		||||
        """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):
 | 
			
		||||
            return value.resolve(self, blueprint)
 | 
			
		||||
            val = value.resolve(self, blueprint)
 | 
			
		||||
 | 
			
		||||
        if isinstance(value, dict):
 | 
			
		||||
            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):
 | 
			
		||||
            for idx, inner_value in enumerate(value):
 | 
			
		||||
                value[idx] = self.tag_resolver(inner_value, blueprint)
 | 
			
		||||
        return value
 | 
			
		||||
                val[idx] = self.tag_resolver(inner_value, blueprint)
 | 
			
		||||
 | 
			
		||||
        if isinstance(value, YAMLTagContext):
 | 
			
		||||
            self.__tag_contexts.pop()
 | 
			
		||||
 | 
			
		||||
        return val
 | 
			
		||||
 | 
			
		||||
    def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
 | 
			
		||||
        """Get attributes of this entry, with all yaml tags resolved"""
 | 
			
		||||
@ -145,12 +178,19 @@ class YAMLTag:
 | 
			
		||||
        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):
 | 
			
		||||
    """Reference another object by their ID"""
 | 
			
		||||
 | 
			
		||||
    id_from: str
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.id_from = node.value
 | 
			
		||||
@ -177,7 +217,6 @@ class Env(YAMLTag):
 | 
			
		||||
    key: str
 | 
			
		||||
    default: Optional[Any]
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.default = None
 | 
			
		||||
@ -197,7 +236,6 @@ class Context(YAMLTag):
 | 
			
		||||
    key: str
 | 
			
		||||
    default: Optional[Any]
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.default = None
 | 
			
		||||
@ -220,7 +258,6 @@ class Format(YAMLTag):
 | 
			
		||||
    format_string: str
 | 
			
		||||
    args: list[Any]
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.format_string = node.value[0].value
 | 
			
		||||
@ -245,15 +282,12 @@ class Format(YAMLTag):
 | 
			
		||||
class Find(YAMLTag):
 | 
			
		||||
    """Find any object"""
 | 
			
		||||
 | 
			
		||||
    model_name: str
 | 
			
		||||
    model_name: str | YAMLTag
 | 
			
		||||
    conditions: list[list]
 | 
			
		||||
 | 
			
		||||
    model_class: type[Model]
 | 
			
		||||
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.model_name = node.value[0].value
 | 
			
		||||
        self.model_class = apps.get_model(*self.model_name.split("."))
 | 
			
		||||
        self.model_name = loader.construct_object(node.value[0])
 | 
			
		||||
        self.conditions = []
 | 
			
		||||
        for raw_node in node.value[1:]:
 | 
			
		||||
            values = []
 | 
			
		||||
@ -262,6 +296,13 @@ class Find(YAMLTag):
 | 
			
		||||
            self.conditions.append(values)
 | 
			
		||||
 | 
			
		||||
    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()
 | 
			
		||||
        for cond in self.conditions:
 | 
			
		||||
            if isinstance(cond[0], YAMLTag):
 | 
			
		||||
@ -273,7 +314,7 @@ class Find(YAMLTag):
 | 
			
		||||
            else:
 | 
			
		||||
                query_value = cond[1]
 | 
			
		||||
            query &= Q(**{query_key: query_value})
 | 
			
		||||
        instance = self.model_class.objects.filter(query).first()
 | 
			
		||||
        instance = model_class.objects.filter(query).first()
 | 
			
		||||
        if instance:
 | 
			
		||||
            return instance.pk
 | 
			
		||||
        return None
 | 
			
		||||
@ -296,7 +337,6 @@ class Condition(YAMLTag):
 | 
			
		||||
        "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:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.mode = node.value[0].value
 | 
			
		||||
@ -329,7 +369,6 @@ class If(YAMLTag):
 | 
			
		||||
    when_true: Any
 | 
			
		||||
    when_false: Any
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.condition = loader.construct_object(node.value[0])
 | 
			
		||||
@ -351,6 +390,133 @@ class If(YAMLTag):
 | 
			
		||||
            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):
 | 
			
		||||
    """Dump dataclasses to yaml"""
 | 
			
		||||
 | 
			
		||||
@ -394,6 +560,9 @@ class BlueprintLoader(SafeLoader):
 | 
			
		||||
        self.add_constructor("!Condition", Condition)
 | 
			
		||||
        self.add_constructor("!If", If)
 | 
			
		||||
        self.add_constructor("!Env", Env)
 | 
			
		||||
        self.add_constructor("!Enumerate", Enumerate)
 | 
			
		||||
        self.add_constructor("!Value", Value)
 | 
			
		||||
        self.add_constructor("!Index", Index)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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"""
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.db.models import QuerySet
 | 
			
		||||
from django.db.models.functions import ExtractHour
 | 
			
		||||
from django.http.response import HttpResponseBadRequest
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
@ -225,7 +227,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
        parser_classes=(MultiPartParser,),
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_icon(self, request: Request, slug: str):
 | 
			
		||||
        """Set application icon"""
 | 
			
		||||
        app: Application = self.get_object()
 | 
			
		||||
@ -245,7 +246,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        filter_backends=[],
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_icon_url(self, request: Request, slug: str):
 | 
			
		||||
        """Set application icon (as URL)"""
 | 
			
		||||
        app: Application = self.get_object()
 | 
			
		||||
@ -254,15 +254,14 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
    @permission_required("authentik_core.view_application", ["authentik_events.view_event"])
 | 
			
		||||
    @extend_schema(responses={200: CoordinateSerializer(many=True)})
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def metrics(self, request: Request, slug: str):
 | 
			
		||||
        """Metrics for application logins"""
 | 
			
		||||
        app = self.get_object()
 | 
			
		||||
        return Response(
 | 
			
		||||
            get_objects_for_user(request.user, "authentik_events.view_event")
 | 
			
		||||
            .filter(
 | 
			
		||||
            get_objects_for_user(request.user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.AUTHORIZE_APPLICATION,
 | 
			
		||||
                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_filters.filters import CharFilter, ModelMultipleChoiceFilter
 | 
			
		||||
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 rest_framework.decorators import action
 | 
			
		||||
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.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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -96,7 +96,6 @@ class GroupFilter(FilterSet):
 | 
			
		||||
        queryset=User.objects.all(),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def filter_attributes(self, queryset, name, value):
 | 
			
		||||
        """Filter attributes by query args"""
 | 
			
		||||
        try:
 | 
			
		||||
@ -120,6 +119,12 @@ class GroupFilter(FilterSet):
 | 
			
		||||
        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):
 | 
			
		||||
    """Group Viewset"""
 | 
			
		||||
 | 
			
		||||
@ -144,19 +149,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    @permission_required(None, ["authentik_core.add_user"])
 | 
			
		||||
    @extend_schema(
 | 
			
		||||
        request=inline_serializer(
 | 
			
		||||
            "UserAccountSerializer",
 | 
			
		||||
            {
 | 
			
		||||
                "pk": IntegerField(required=True),
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        request=UserAccountSerializer,
 | 
			
		||||
        responses={
 | 
			
		||||
            204: OpenApiResponse(description="User added"),
 | 
			
		||||
            404: OpenApiResponse(description="User not found"),
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @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:
 | 
			
		||||
        """Add user to group"""
 | 
			
		||||
        group: Group = self.get_object()
 | 
			
		||||
@ -174,19 +173,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    @permission_required(None, ["authentik_core.add_user"])
 | 
			
		||||
    @extend_schema(
 | 
			
		||||
        request=inline_serializer(
 | 
			
		||||
            "UserAccountSerializer",
 | 
			
		||||
            {
 | 
			
		||||
                "pk": IntegerField(required=True),
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        request=UserAccountSerializer,
 | 
			
		||||
        responses={
 | 
			
		||||
            204: OpenApiResponse(description="User added"),
 | 
			
		||||
            404: OpenApiResponse(description="User not found"),
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @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:
 | 
			
		||||
        """Add user to group"""
 | 
			
		||||
        group: Group = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -117,7 +117,6 @@ class PropertyMappingViewSet(
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
			
		||||
    # pylint: disable=unused-argument, invalid-name
 | 
			
		||||
    def test(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Test Property Mapping"""
 | 
			
		||||
        mapping: PropertyMapping = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -102,7 +102,6 @@ class SourceViewSet(
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
        parser_classes=(MultiPartParser,),
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_icon(self, request: Request, slug: str):
 | 
			
		||||
        """Set source icon"""
 | 
			
		||||
        source: Source = self.get_object()
 | 
			
		||||
@ -122,7 +121,6 @@ class SourceViewSet(
 | 
			
		||||
        filter_backends=[],
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_icon_url(self, request: Request, slug: str):
 | 
			
		||||
        """Set source icon (as URL)"""
 | 
			
		||||
        source: Source = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -112,7 +112,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def view_key(self, request: Request, identifier: str) -> Response:
 | 
			
		||||
        """Return token key and log access"""
 | 
			
		||||
        token: Token = self.get_object()
 | 
			
		||||
@ -134,7 +133,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_key(self, request: Request, identifier: str) -> Response:
 | 
			
		||||
        """Return token key and log access"""
 | 
			
		||||
        token: Token = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -53,7 +53,7 @@ class UsedByMixin:
 | 
			
		||||
        responses={200: UsedBySerializer(many=True)},
 | 
			
		||||
    )
 | 
			
		||||
    @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:
 | 
			
		||||
        """Get a list of all objects that use this object"""
 | 
			
		||||
        # pyright: reportGeneralTypeIssues=false
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ from json import loads
 | 
			
		||||
from typing import Any, Optional
 | 
			
		||||
 | 
			
		||||
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.transaction import atomic
 | 
			
		||||
from django.db.utils import IntegrityError
 | 
			
		||||
@ -199,38 +200,44 @@ class SessionUserSerializer(PassiveSerializer):
 | 
			
		||||
class UserMetricsSerializer(PassiveSerializer):
 | 
			
		||||
    """User Metrics"""
 | 
			
		||||
 | 
			
		||||
    logins_per_1h = SerializerMethodField()
 | 
			
		||||
    logins_failed_per_1h = SerializerMethodField()
 | 
			
		||||
    authorizations_per_1h = SerializerMethodField()
 | 
			
		||||
    logins = SerializerMethodField()
 | 
			
		||||
    logins_failed = SerializerMethodField()
 | 
			
		||||
    authorizations = SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_logins_per_1h(self, _):
 | 
			
		||||
        """Get successful logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_logins(self, _):
 | 
			
		||||
        """Get successful logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.LOGIN, user__pk=user.pk)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.LOGIN, user__pk=user.pk
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_logins_failed_per_1h(self, _):
 | 
			
		||||
        """Get failed logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_logins_failed(self, _):
 | 
			
		||||
        """Get failed logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.LOGIN_FAILED, context__username=user.username
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_authorizations_per_1h(self, _):
 | 
			
		||||
        """Get failed logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_authorizations(self, _):
 | 
			
		||||
        """Get failed logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
 | 
			
		||||
            )
 | 
			
		||||
            # 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(),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def filter_attributes(self, queryset, name, value):
 | 
			
		||||
        """Filter attributes by query args"""
 | 
			
		||||
        try:
 | 
			
		||||
@ -397,9 +403,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
                return Response(data={"non_field_errors": [str(exc)]}, status=400)
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: SessionUserSerializer(many=False)})
 | 
			
		||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    def me(self, request: Request) -> Response:
 | 
			
		||||
    @action(url_path="me", url_name="me", detail=False, pagination_class=None, filter_backends=[])
 | 
			
		||||
    def user_me(self, request: Request) -> Response:
 | 
			
		||||
        """Get information about current user"""
 | 
			
		||||
        context = {"request": request}
 | 
			
		||||
        serializer = SessionUserSerializer(
 | 
			
		||||
@ -427,7 +432,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["POST"])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def set_password(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Set password for user"""
 | 
			
		||||
        user: User = self.get_object()
 | 
			
		||||
@ -445,7 +449,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
    @permission_required("authentik_core.view_user", ["authentik_events.view_event"])
 | 
			
		||||
    @extend_schema(responses={200: UserMetricsSerializer(many=False)})
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def metrics(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """User metrics per 1h"""
 | 
			
		||||
        user: User = self.get_object()
 | 
			
		||||
@ -461,7 +464,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def recovery(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Create a temporary link that a user can use to recover their accounts"""
 | 
			
		||||
        link, _ = self._create_recovery_link()
 | 
			
		||||
@ -486,7 +488,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def recovery_email(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Create a temporary link that a user can use to recover their accounts"""
 | 
			
		||||
        for_user: User = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
"""Property Mapping Evaluator"""
 | 
			
		||||
from traceback import format_tb
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
@ -8,6 +7,7 @@ from django.http import HttpRequest
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.lib.expression.evaluator import BaseEvaluator
 | 
			
		||||
from authentik.lib.utils.errors import exception_to_string
 | 
			
		||||
from authentik.policies.types import PolicyRequest
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -38,7 +38,7 @@ class PropertyMappingEvaluator(BaseEvaluator):
 | 
			
		||||
 | 
			
		||||
    def handle_error(self, exc: Exception, expression_source: str):
 | 
			
		||||
        """Exception Handler"""
 | 
			
		||||
        error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
 | 
			
		||||
        error_string = exception_to_string(exc)
 | 
			
		||||
        event = Event.new(
 | 
			
		||||
            EventAction.PROPERTY_MAPPING_EXCEPTION,
 | 
			
		||||
            expression=expression_source,
 | 
			
		||||
 | 
			
		||||
@ -49,7 +49,6 @@ class Command(BaseCommand):
 | 
			
		||||
        return namespace
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def post_save_handler(sender, instance: Model, created: bool, **_):
 | 
			
		||||
        """Signal handler for all object's post_save"""
 | 
			
		||||
        if not should_log_model(instance):
 | 
			
		||||
@ -65,7 +64,6 @@ class Command(BaseCommand):
 | 
			
		||||
        ).save()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def pre_delete_handler(sender, instance: Model, **_):
 | 
			
		||||
        """Signal handler for all object's pre_delete"""
 | 
			
		||||
        if not should_log_model(instance):  # pragma: no cover
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,6 @@ if TYPE_CHECKING:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
 | 
			
		||||
    """Clear user's application cache upon application creation"""
 | 
			
		||||
    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)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
 | 
			
		||||
    """Create an AuthenticatedSession from request"""
 | 
			
		||||
    from authentik.core.models import AuthenticatedSession
 | 
			
		||||
@ -47,7 +45,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_logged_out)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
 | 
			
		||||
    """Delete AuthenticatedSession if it exists"""
 | 
			
		||||
    from authentik.core.models import AuthenticatedSession
 | 
			
		||||
 | 
			
		||||
@ -48,7 +48,6 @@ class Action(Enum):
 | 
			
		||||
class MessageStage(StageView):
 | 
			
		||||
    """Show a pre-configured message after the flow is done"""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Show a pre-configured message after the flow is done"""
 | 
			
		||||
        message = getattr(self.executor.current_stage, "message", "")
 | 
			
		||||
@ -209,7 +208,6 @@ class SourceFlowManager:
 | 
			
		||||
            response.error_message = error.messages
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get_stages_to_append(self, flow: Flow) -> list[Stage]:
 | 
			
		||||
        """Hook to override stages which are appended to the flow"""
 | 
			
		||||
        if not self.source.enrollment_flow:
 | 
			
		||||
@ -264,7 +262,6 @@ class SourceFlowManager:
 | 
			
		||||
            flow_slug=flow.slug,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def handle_auth(
 | 
			
		||||
        self,
 | 
			
		||||
        connection: UserSourceConnection,
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,6 @@ class PostUserEnrollmentStage(StageView):
 | 
			
		||||
    """Dynamically injected stage which saves the Connection after
 | 
			
		||||
    the user has been enrolled."""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Stage used after the user has been enrolled"""
 | 
			
		||||
        connection: UserSourceConnection = self.executor.plan.context[
 | 
			
		||||
 | 
			
		||||
@ -60,7 +60,7 @@
 | 
			
		||||
    <div class="ak-login-container">
 | 
			
		||||
        <header class="pf-c-login__header">
 | 
			
		||||
            <div class="pf-c-brand ak-brand">
 | 
			
		||||
                <img src="{{ tenant.branding_logo }}" alt="authentik icon" />
 | 
			
		||||
                <img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
 | 
			
		||||
            </div>
 | 
			
		||||
        </header>
 | 
			
		||||
        {% block main_container %}
 | 
			
		||||
 | 
			
		||||
@ -35,7 +35,7 @@ def source_tester_factory(test_model: type[Stage]) -> Callable:
 | 
			
		||||
 | 
			
		||||
    def tester(self: TestModels):
 | 
			
		||||
        model_class = None
 | 
			
		||||
        if test_model._meta.abstract:
 | 
			
		||||
        if test_model._meta.abstract:  # pragma: no cover
 | 
			
		||||
            model_class = test_model.__bases__[0]()
 | 
			
		||||
        else:
 | 
			
		||||
            model_class = test_model()
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
"""Test Source flow_manager"""
 | 
			
		||||
from django.contrib.auth.models import AnonymousUser
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test.client import RequestFactory
 | 
			
		||||
from guardian.utils import get_anonymous_user
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import SourceUserMatchingModes, User
 | 
			
		||||
@ -22,7 +21,6 @@ class TestSourceFlowManager(TestCase):
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.source: OAuthSource = OAuthSource.objects.create(name="test")
 | 
			
		||||
        self.factory = RequestFactory()
 | 
			
		||||
        self.identifier = generate_id()
 | 
			
		||||
 | 
			
		||||
    def test_unauthenticated_enroll(self):
 | 
			
		||||
 | 
			
		||||
@ -47,11 +47,11 @@ def create_test_tenant() -> Tenant:
 | 
			
		||||
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
 | 
			
		||||
    """Generate a certificate for testing"""
 | 
			
		||||
    builder = CertificateBuilder(
 | 
			
		||||
        name=f"{generate_id()}.self-signed.goauthentik.io",
 | 
			
		||||
        use_ec_private_key=use_ec_private_key,
 | 
			
		||||
    )
 | 
			
		||||
    builder.common_name = "goauthentik.io"
 | 
			
		||||
    builder.build(
 | 
			
		||||
        subject_alt_names=["goauthentik.io"],
 | 
			
		||||
        subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"],
 | 
			
		||||
        validity_days=360,
 | 
			
		||||
    )
 | 
			
		||||
    builder.common_name = generate_id()
 | 
			
		||||
 | 
			
		||||
@ -187,7 +187,6 @@ class CertificateKeyPairFilter(FilterSet):
 | 
			
		||||
        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
 | 
			
		||||
        """Only return certificate-key pairs with keys"""
 | 
			
		||||
        return queryset.exclude(key_data__exact="")
 | 
			
		||||
@ -209,6 +208,13 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    @extend_schema(
 | 
			
		||||
        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),
 | 
			
		||||
        ]
 | 
			
		||||
    )
 | 
			
		||||
@ -229,10 +235,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        data = CertificateGenerationSerializer(data=request.data)
 | 
			
		||||
        if not data.is_valid():
 | 
			
		||||
            return Response(data.errors, status=400)
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = data.validated_data["common_name"]
 | 
			
		||||
        raw_san = data.validated_data.get("subject_alt_name", "")
 | 
			
		||||
        sans = raw_san.split(",") if raw_san != "" else []
 | 
			
		||||
        builder = CertificateBuilder(data.validated_data["common_name"])
 | 
			
		||||
        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"]),
 | 
			
		||||
        )
 | 
			
		||||
        instance = builder.save()
 | 
			
		||||
@ -250,7 +257,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        responses={200: CertificateDataSerializer(many=False)},
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def view_certificate(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Return certificate-key pairs certificate and log access"""
 | 
			
		||||
        certificate: CertificateKeyPair = self.get_object()
 | 
			
		||||
@ -281,7 +287,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        responses={200: CertificateDataSerializer(many=False)},
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def view_private_key(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Return certificate-key pairs private key and log access"""
 | 
			
		||||
        certificate: CertificateKeyPair = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -27,20 +27,16 @@ class AuthentikCryptoConfig(ManagedAppConfig):
 | 
			
		||||
        from authentik.crypto.builder import CertificateBuilder
 | 
			
		||||
        from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = "goauthentik.io"
 | 
			
		||||
        builder = CertificateBuilder("authentik Internal JWT Certificate")
 | 
			
		||||
        builder.build(
 | 
			
		||||
            subject_alt_names=["goauthentik.io"],
 | 
			
		||||
            validity_days=360,
 | 
			
		||||
        )
 | 
			
		||||
        if not cert:
 | 
			
		||||
 | 
			
		||||
            cert = CertificateKeyPair()
 | 
			
		||||
        cert.certificate_data = builder.certificate
 | 
			
		||||
        cert.key_data = builder.private_key
 | 
			
		||||
        cert.name = "authentik Internal JWT Certificate"
 | 
			
		||||
        cert.managed = MANAGED_KEY
 | 
			
		||||
        cert.save()
 | 
			
		||||
        builder.cert = cert
 | 
			
		||||
        builder.cert.managed = MANAGED_KEY
 | 
			
		||||
        builder.save()
 | 
			
		||||
 | 
			
		||||
    def reconcile_managed_jwt_cert(self):
 | 
			
		||||
        """Ensure managed JWT certificate"""
 | 
			
		||||
@ -63,10 +59,6 @@ class AuthentikCryptoConfig(ManagedAppConfig):
 | 
			
		||||
        name = "authentik Self-signed Certificate"
 | 
			
		||||
        if CertificateKeyPair.objects.filter(name=name).exists():
 | 
			
		||||
            return
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder = CertificateBuilder(name)
 | 
			
		||||
        builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
 | 
			
		||||
        CertificateKeyPair.objects.create(
 | 
			
		||||
            name="authentik Self-signed Certificate",
 | 
			
		||||
            certificate_data=builder.certificate,
 | 
			
		||||
            key_data=builder.private_key,
 | 
			
		||||
        )
 | 
			
		||||
        builder.save()
 | 
			
		||||
 | 
			
		||||
@ -21,13 +21,13 @@ class CertificateBuilder:
 | 
			
		||||
 | 
			
		||||
    _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.__public_key = None
 | 
			
		||||
        self.__private_key = None
 | 
			
		||||
        self.__builder = None
 | 
			
		||||
        self.__certificate = None
 | 
			
		||||
        self.common_name = "authentik Self-signed Certificate"
 | 
			
		||||
        self.common_name = name
 | 
			
		||||
        self.cert = CertificateKeyPair()
 | 
			
		||||
 | 
			
		||||
    def save(self) -> CertificateKeyPair:
 | 
			
		||||
@ -57,7 +57,10 @@ class CertificateBuilder:
 | 
			
		||||
        one_day = datetime.timedelta(1, 0, 0)
 | 
			
		||||
        self.__private_key = self.generate_private_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 = (
 | 
			
		||||
            x509.CertificateBuilder()
 | 
			
		||||
            .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_after(datetime.datetime.today() + datetime.timedelta(days=validity_days))
 | 
			
		||||
            .serial_number(int(uuid.uuid4()))
 | 
			
		||||
            .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(
 | 
			
		||||
            private_key=self.__private_key,
 | 
			
		||||
            algorithm=hashes.SHA256(),
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,8 @@ from json import loads
 | 
			
		||||
from os import makedirs
 | 
			
		||||
from tempfile import TemporaryDirectory
 | 
			
		||||
 | 
			
		||||
from cryptography.x509.extensions import SubjectAlternativeName
 | 
			
		||||
from cryptography.x509.general_name import DNSName
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
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.tasks import MANAGED_DISCOVERED, certificate_discovery
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -54,8 +56,8 @@ class TestCrypto(APITestCase):
 | 
			
		||||
 | 
			
		||||
    def test_builder(self):
 | 
			
		||||
        """Test Builder"""
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = "test-cert"
 | 
			
		||||
        name = generate_id()
 | 
			
		||||
        builder = CertificateBuilder(name)
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            builder.save()
 | 
			
		||||
        builder.build(
 | 
			
		||||
@ -64,17 +66,49 @@ class TestCrypto(APITestCase):
 | 
			
		||||
        )
 | 
			
		||||
        instance = builder.save()
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
    def test_builder_api(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": "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):
 | 
			
		||||
        """Test Builder (via API) (invalid)"""
 | 
			
		||||
@ -193,8 +227,8 @@ class TestCrypto(APITestCase):
 | 
			
		||||
 | 
			
		||||
    def test_discovery(self):
 | 
			
		||||
        """Test certificate discovery"""
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = "test-cert"
 | 
			
		||||
        name = generate_id()
 | 
			
		||||
        builder = CertificateBuilder(name)
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            builder.save()
 | 
			
		||||
        builder.build(
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,11 @@
 | 
			
		||||
"""Events API Views"""
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from json import loads
 | 
			
		||||
 | 
			
		||||
import django_filters
 | 
			
		||||
from django.db.models.aggregates import Count
 | 
			
		||||
from django.db.models.fields.json import KeyTextTransform
 | 
			
		||||
from django.db.models.functions import ExtractDay
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
 | 
			
		||||
from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
@ -81,7 +83,6 @@ class EventsFilter(django_filters.FilterSet):
 | 
			
		||||
        label="Tenant name",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def filter_context_model_pk(self, queryset, name, value):
 | 
			
		||||
        """Because we store the PK as UUID.hex,
 | 
			
		||||
        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")
 | 
			
		||||
            .filter(action=filtered_action)
 | 
			
		||||
            .filter(**query)
 | 
			
		||||
            .get_events_per_day()
 | 
			
		||||
            .get_events_per(timedelta(weeks=4), ExtractDay, 30)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
			
		||||
 | 
			
		||||
@ -80,7 +80,6 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        request=OpenApiTypes.NONE,
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def test(self, request: Request, pk=None) -> Response:
 | 
			
		||||
        """Send example notification using selected transport. Requires
 | 
			
		||||
        Modify permissions."""
 | 
			
		||||
 | 
			
		||||
@ -12,12 +12,21 @@ from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django_otp.plugins.otp_static.models import StaticToken
 | 
			
		||||
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.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.utils.errors import exception_to_string
 | 
			
		||||
from authentik.outposts.models import OutpostServiceConnection
 | 
			
		||||
from authentik.policies.models import Policy, PolicyBindingModel
 | 
			
		||||
 | 
			
		||||
IGNORED_MODELS = (
 | 
			
		||||
    Event,
 | 
			
		||||
@ -27,6 +36,14 @@ IGNORED_MODELS = (
 | 
			
		||||
    StaticToken,
 | 
			
		||||
    Session,
 | 
			
		||||
    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"""
 | 
			
		||||
    if model.__module__.startswith("silk"):
 | 
			
		||||
        return False
 | 
			
		||||
    return not isinstance(model, IGNORED_MODELS)
 | 
			
		||||
    return model.__class__ not in IGNORED_MODELS
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EventNewThread(Thread):
 | 
			
		||||
@ -101,7 +118,6 @@ class AuditMiddleware:
 | 
			
		||||
        self.disconnect(request)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def process_exception(self, request: HttpRequest, exception: Exception):
 | 
			
		||||
        """Disconnect handlers in case of exception"""
 | 
			
		||||
        self.disconnect(request)
 | 
			
		||||
@ -125,7 +141,6 @@ class AuditMiddleware:
 | 
			
		||||
            thread.run()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def post_save_handler(
 | 
			
		||||
        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()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
 | 
			
		||||
        """Signal handler for all object's pre_delete"""
 | 
			
		||||
        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.models import Count, ExpressionWrapper, F
 | 
			
		||||
from django.db.models.fields import DurationField
 | 
			
		||||
from django.db.models.functions import ExtractHour
 | 
			
		||||
from django.db.models.functions.datetime import ExtractDay
 | 
			
		||||
from django.db.models.functions import Extract
 | 
			
		||||
from django.db.models.manager import Manager
 | 
			
		||||
from django.db.models.query import QuerySet
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
@ -111,48 +110,35 @@ class EventAction(models.TextChoices):
 | 
			
		||||
class EventQuerySet(QuerySet):
 | 
			
		||||
    """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"""
 | 
			
		||||
        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 = (
 | 
			
		||||
            self.filter(created__gte=date_from)
 | 
			
		||||
            .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
 | 
			
		||||
            .annotate(age_hours=ExtractHour("age"))
 | 
			
		||||
            .values("age_hours")
 | 
			
		||||
            .annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
 | 
			
		||||
            .annotate(age_interval=extract("age"))
 | 
			
		||||
            .values("age_interval")
 | 
			
		||||
            .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 = []
 | 
			
		||||
        _now = now()
 | 
			
		||||
        for hour in range(0, -24, -1):
 | 
			
		||||
        interval_delta = time_since / data_points
 | 
			
		||||
        for interval in range(1, -data_points, -1):
 | 
			
		||||
            results.append(
 | 
			
		||||
                {
 | 
			
		||||
                    "x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
 | 
			
		||||
                    "y_cord": data[hour * -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],
 | 
			
		||||
                    "x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
 | 
			
		||||
                    "y_cord": data[interval * -1],
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        return results
 | 
			
		||||
@ -165,13 +151,14 @@ class EventManager(Manager):
 | 
			
		||||
        """use custom queryset"""
 | 
			
		||||
        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"""
 | 
			
		||||
        return self.get_queryset().get_events_per_hour()
 | 
			
		||||
 | 
			
		||||
    def get_events_per_day(self) -> list[dict[str, int]]:
 | 
			
		||||
        """Wrap method from queryset"""
 | 
			
		||||
        return self.get_queryset().get_events_per_day()
 | 
			
		||||
        return self.get_queryset().get_events_per(time_since, extract, data_points)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Event(SerializerModel, ExpiringModel):
 | 
			
		||||
@ -461,7 +448,7 @@ class NotificationTransport(SerializerModel):
 | 
			
		||||
            # pyright: reportGeneralTypeIssues=false
 | 
			
		||||
            return send_mail(mail.__dict__)  # pylint: disable=no-value-for-parameter
 | 
			
		||||
        except (SMTPException, ConnectionError, OSError) as exc:
 | 
			
		||||
            raise NotificationTransportError from exc
 | 
			
		||||
            raise NotificationTransportError(exc) from exc
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> "Serializer":
 | 
			
		||||
 | 
			
		||||
@ -102,7 +102,7 @@ class TaskInfo:
 | 
			
		||||
        key = CACHE_KEY_PREFIX + self.task_name
 | 
			
		||||
        if 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()
 | 
			
		||||
        cache.set(key, self, timeout=timeout_hours * 60 * 60)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,6 @@ SESSION_LOGIN_EVENT = "login_event"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_logged_in)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
 | 
			
		||||
    """Log successful login"""
 | 
			
		||||
    kwargs = {}
 | 
			
		||||
@ -39,15 +38,18 @@ def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
 | 
			
		||||
    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)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
 | 
			
		||||
    """Log successfully logout"""
 | 
			
		||||
    Event.new(EventAction.LOGOUT).from_http(request, user=user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_write)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs):
 | 
			
		||||
    """Log User write"""
 | 
			
		||||
    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)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_login_failed(
 | 
			
		||||
    signal,
 | 
			
		||||
    sender,
 | 
			
		||||
@ -69,7 +70,6 @@ def on_login_failed(
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(invitation_used)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
 | 
			
		||||
    """Log Invitation usage"""
 | 
			
		||||
    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)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_password_changed(sender, user: User, password: str, **_):
 | 
			
		||||
    """Log password change"""
 | 
			
		||||
    Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save, sender=Event)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def event_post_save_notification(sender, instance: Event, **_):
 | 
			
		||||
    """Start task to check if any policies trigger an notification on this event"""
 | 
			
		||||
    event_notification_handler.delay(instance.event_uuid.hex)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(pre_delete, sender=User)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def event_user_pre_delete_cleanup(sender, instance: User, **_):
 | 
			
		||||
    """If gdpr_compliance is enabled, remove all the user's events"""
 | 
			
		||||
    gdpr_cleanup.delay(instance.pk)
 | 
			
		||||
 | 
			
		||||
@ -210,7 +210,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def export(self, request: Request, slug: str) -> Response:
 | 
			
		||||
        """Export flow to .yaml file"""
 | 
			
		||||
        flow = self.get_object()
 | 
			
		||||
@ -221,7 +220,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: FlowDiagramSerializer()})
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def diagram(self, request: Request, slug: str) -> Response:
 | 
			
		||||
        """Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
 | 
			
		||||
        diagram = FlowDiagram(self.get_object(), request.user)
 | 
			
		||||
@ -245,7 +243,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
        parser_classes=(MultiPartParser,),
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_background(self, request: Request, slug: str):
 | 
			
		||||
        """Set Flow background"""
 | 
			
		||||
        flow: Flow = self.get_object()
 | 
			
		||||
@ -265,7 +262,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        filter_backends=[],
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_background_url(self, request: Request, slug: str):
 | 
			
		||||
        """Set Flow background (as URL)"""
 | 
			
		||||
        flow: Flow = self.get_object()
 | 
			
		||||
@ -278,7 +274,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def execute(self, request: Request, slug: str):
 | 
			
		||||
        """Execute flow for current user"""
 | 
			
		||||
        # Because we pre-plan the flow here, and not in the planner, we need to manually clear
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
"""Challenge helpers"""
 | 
			
		||||
from dataclasses import asdict, is_dataclass
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from traceback import format_tb
 | 
			
		||||
from typing import TYPE_CHECKING, Optional, TypedDict
 | 
			
		||||
from uuid import UUID
 | 
			
		||||
 | 
			
		||||
@ -9,8 +8,10 @@ from django.core.serializers.json import DjangoJSONEncoder
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.http import JsonResponse
 | 
			
		||||
from rest_framework.fields import CharField, ChoiceField, DictField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.lib.utils.errors import exception_to_string
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from authentik.flows.stage import StageView
 | 
			
		||||
@ -90,32 +91,31 @@ class WithUserInfoChallenge(Challenge):
 | 
			
		||||
    pending_user_avatar = CharField()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FlowErrorChallenge(WithUserInfoChallenge):
 | 
			
		||||
class FlowErrorChallenge(Challenge):
 | 
			
		||||
    """Challenge class when an unhandled error occurs during a stage. Normal users
 | 
			
		||||
    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()
 | 
			
		||||
 | 
			
		||||
    error = CharField(required=False)
 | 
			
		||||
    traceback = CharField(required=False)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        request = kwargs.pop("request", None)
 | 
			
		||||
        error = kwargs.pop("error", None)
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
    def __init__(self, request: Optional[Request] = None, error: Optional[Exception] = None):
 | 
			
		||||
        super().__init__(data={})
 | 
			
		||||
        if not request or not error:
 | 
			
		||||
            return
 | 
			
		||||
        self.request_id = request.request_id
 | 
			
		||||
        self.initial_data["request_id"] = request.request_id
 | 
			
		||||
        from authentik.core.models import USER_ATTRIBUTE_DEBUG
 | 
			
		||||
 | 
			
		||||
        if request.user and request.user.is_authenticated:
 | 
			
		||||
            if request.user.is_superuser or request.user.group_attributes(request).get(
 | 
			
		||||
                USER_ATTRIBUTE_DEBUG, False
 | 
			
		||||
            ):
 | 
			
		||||
                self.error = error
 | 
			
		||||
                self.traceback = "".join(format_tb(self.error.__traceback__))
 | 
			
		||||
                self.initial_data["error"] = str(error)
 | 
			
		||||
                self.initial_data["traceback"] = exception_to_string(error)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccessDeniedChallenge(WithUserInfoChallenge):
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,6 @@ LOGGER = get_logger()
 | 
			
		||||
class StageMarker:
 | 
			
		||||
    """Base stage marker class, no extra attributes, and has no special handler."""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def process(
 | 
			
		||||
        self,
 | 
			
		||||
        plan: "FlowPlan",
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,6 @@ def delete_cache_prefix(prefix: str) -> int:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(monitoring_set)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def monitoring_set_flows(sender, **kwargs):
 | 
			
		||||
    """set flow gauges"""
 | 
			
		||||
    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(pre_delete)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def invalidate_flow_cache(sender, instance, **_):
 | 
			
		||||
    """Invalidate flow cache when flow is updated"""
 | 
			
		||||
    from authentik.flows.models import Flow, FlowStageBinding, Stage
 | 
			
		||||
 | 
			
		||||
@ -91,7 +91,6 @@ class ChallengeStageView(StageView):
 | 
			
		||||
            )
 | 
			
		||||
        return HttpChallengeResponse(challenge)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def post(self, request: Request, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Handle challenge response"""
 | 
			
		||||
        challenge: ChallengeResponse = self.get_response_instance(data=request.data)
 | 
			
		||||
 | 
			
		||||
@ -113,7 +113,7 @@ class InvalidStageError(SentryIgnoredException):
 | 
			
		||||
 | 
			
		||||
@method_decorator(xframe_options_sameorigin, name="dispatch")
 | 
			
		||||
class FlowExecutorView(APIView):
 | 
			
		||||
    """Stage 1 Flow executor, passing requests to Stage Views"""
 | 
			
		||||
    """Flow executor, passing requests to Stage Views"""
 | 
			
		||||
 | 
			
		||||
    permission_classes = [AllowAny]
 | 
			
		||||
 | 
			
		||||
@ -166,7 +166,7 @@ class FlowExecutorView(APIView):
 | 
			
		||||
        self._logger.debug("f(exec): restored flow plan from token", plan=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:
 | 
			
		||||
        with Hub.current.start_span(
 | 
			
		||||
            op="authentik.flow.executor.dispatch", description=self.flow.slug
 | 
			
		||||
@ -255,7 +255,7 @@ class FlowExecutorView(APIView):
 | 
			
		||||
            message=exception_to_string(exc),
 | 
			
		||||
        ).from_http(self.request)
 | 
			
		||||
        challenge = FlowErrorChallenge(self.request, exc)
 | 
			
		||||
        challenge.is_valid()
 | 
			
		||||
        challenge.is_valid(raise_exception=True)
 | 
			
		||||
        return to_stage_response(self.request, HttpChallengeResponse(challenge))
 | 
			
		||||
 | 
			
		||||
    @extend_schema(
 | 
			
		||||
 | 
			
		||||
@ -47,7 +47,6 @@ class FlowInspectorPlanSerializer(PassiveSerializer):
 | 
			
		||||
        """Get the plan's context, sanitized"""
 | 
			
		||||
        return sanitize_dict(plan.context)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get_session_id(self, plan: FlowPlan) -> str:
 | 
			
		||||
        """Get a unique session ID"""
 | 
			
		||||
        request: Request = self.context["request"]
 | 
			
		||||
 | 
			
		||||
@ -93,7 +93,7 @@ class ConfigLoader:
 | 
			
		||||
        if url.scheme == "file":
 | 
			
		||||
            try:
 | 
			
		||||
                with open(url.path, "r", encoding="utf8") as _file:
 | 
			
		||||
                    value = _file.read()
 | 
			
		||||
                    value = _file.read().strip()
 | 
			
		||||
            except OSError as exc:
 | 
			
		||||
                self.log("error", f"Failed to read config value from {url.path}: {exc}")
 | 
			
		||||
                value = url.query
 | 
			
		||||
 | 
			
		||||
@ -64,6 +64,7 @@ outposts:
 | 
			
		||||
  disable_embedded_outpost: false
 | 
			
		||||
 | 
			
		||||
ldap:
 | 
			
		||||
  task_timeout_hours: 2
 | 
			
		||||
  tls:
 | 
			
		||||
    ciphers: null
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -159,7 +159,6 @@ class BaseEvaluator:
 | 
			
		||||
                raise exc
 | 
			
		||||
            return result
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def handle_error(self, exc: Exception, expression_source: str):  # pragma: no cover
 | 
			
		||||
        """Exception Handler"""
 | 
			
		||||
        LOGGER.warning("Expression error", exc=exc)
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,6 @@ from logging import Logger
 | 
			
		||||
from os import getpid
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def add_process_id(logger: Logger, method_name: str, event_dict):
 | 
			
		||||
    """Add the current process ID"""
 | 
			
		||||
    event_dict["pid"] = getpid()
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,7 @@ def model_tester_factory(test_model: type[Stage]) -> Callable:
 | 
			
		||||
    def tester(self: TestModels):
 | 
			
		||||
        try:
 | 
			
		||||
            model_class = None
 | 
			
		||||
            if test_model._meta.abstract:
 | 
			
		||||
            if test_model._meta.abstract:  # pragma: no cover
 | 
			
		||||
                return
 | 
			
		||||
            model_class = test_model()
 | 
			
		||||
            self.assertTrue(issubclass(model_class.serializer, BaseSerializer))
 | 
			
		||||
 | 
			
		||||
@ -48,14 +48,14 @@ def get_apps():
 | 
			
		||||
 | 
			
		||||
def get_env() -> str:
 | 
			
		||||
    """Get environment in which authentik is currently running"""
 | 
			
		||||
    if SERVICE_HOST_ENV_NAME in os.environ:
 | 
			
		||||
        return "kubernetes"
 | 
			
		||||
    if "CI" in os.environ:
 | 
			
		||||
        return "ci"
 | 
			
		||||
    if Path("/tmp/authentik-mode").exists():  # nosec
 | 
			
		||||
        return "compose"
 | 
			
		||||
    if CONFIG.y_bool("debug"):
 | 
			
		||||
        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:
 | 
			
		||||
        return os.environ["AK_APPLIANCE"]
 | 
			
		||||
    return "custom"
 | 
			
		||||
 | 
			
		||||
@ -148,7 +148,6 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: OutpostHealthSerializer(many=True)})
 | 
			
		||||
    @action(methods=["GET"], detail=True, pagination_class=None)
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def health(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Get outposts current health"""
 | 
			
		||||
        outpost: Outpost = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -91,7 +91,6 @@ class ServiceConnectionViewSet(
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument, invalid-name
 | 
			
		||||
    def state(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Get the service connection's state"""
 | 
			
		||||
        connection = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -69,7 +69,6 @@ class OutpostConsumer(AuthJsonConsumer):
 | 
			
		||||
        self.outpost = outpost
 | 
			
		||||
        self.last_uid = self.channel_name
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def disconnect(self, code):
 | 
			
		||||
        if self.outpost and 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)
 | 
			
		||||
        self.send_json(asdict(response))
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def event_update(self, event):  # pragma: no cover
 | 
			
		||||
        """Event handler which is called by post_save signals, Send update instruction"""
 | 
			
		||||
        self.send_json(
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,6 @@ UPDATE_TRIGGERING_MODELS = (
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(pre_save, sender=Outpost)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def pre_save_outpost(sender, instance: Outpost, **_):
 | 
			
		||||
    """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"""
 | 
			
		||||
@ -43,7 +42,6 @@ def pre_save_outpost(sender, instance: Outpost, **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(m2m_changed, sender=Outpost.providers.through)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def m2m_changed_update(sender, instance: Model, action: str, **_):
 | 
			
		||||
    """Update outpost on m2m change, when providers are added or removed"""
 | 
			
		||||
    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)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def post_save_update(sender, instance: Model, created: bool, **_):
 | 
			
		||||
    """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)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def pre_delete_cleanup(sender, instance: Outpost, **_):
 | 
			
		||||
    """Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
 | 
			
		||||
    instance.user.delete()
 | 
			
		||||
 | 
			
		||||
@ -144,7 +144,6 @@ class PolicyViewSet(
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
			
		||||
    # pylint: disable=unused-argument, invalid-name
 | 
			
		||||
    def test(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Test policy"""
 | 
			
		||||
        policy = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -52,6 +52,8 @@ class PolicyEngine:
 | 
			
		||||
        self.empty_result = True
 | 
			
		||||
        if not isinstance(pbm, PolicyBindingModel):  # pragma: no cover
 | 
			
		||||
            raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
 | 
			
		||||
        if not user:
 | 
			
		||||
            raise ValueError("User must be set")
 | 
			
		||||
        self.__pbm = pbm
 | 
			
		||||
        self.request = PolicyRequest(user)
 | 
			
		||||
        self.request.obj = pbm
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,25 @@
 | 
			
		||||
"""Event Matcher Policy API"""
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from rest_framework.fields import ChoiceField
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
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):
 | 
			
		||||
    """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:
 | 
			
		||||
        model = EventMatcherPolicy
 | 
			
		||||
        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(
 | 
			
		||||
        choices=app_choices(),
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default="",
 | 
			
		||||
        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
 | 
			
		||||
from django.apps.registry import Apps
 | 
			
		||||
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):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies_hibp", "0003_haveibeenpwendpolicy_authentik_p_policy__6957d7_idx"),
 | 
			
		||||
        ("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -69,5 +45,4 @@ class Migration(migrations.Migration):
 | 
			
		||||
            name="error_message",
 | 
			
		||||
            field=models.TextField(blank=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(migrate_hibp_policy),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -138,5 +138,5 @@ class PolicyProcess(PROCESS_CLASS):
 | 
			
		||||
        try:
 | 
			
		||||
            self.connection.send(self.profiling_wrapper())
 | 
			
		||||
        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)))
 | 
			
		||||
 | 
			
		||||
@ -37,7 +37,6 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(login_failed)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def handle_failed_login(sender, request, credentials, **_):
 | 
			
		||||
    """Lower Score for failed login attempts"""
 | 
			
		||||
    if "username" in credentials:
 | 
			
		||||
@ -45,14 +44,12 @@ def handle_failed_login(sender, request, credentials, **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(identification_failed)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def handle_identification_failed(sender, request, uid_field: str, **_):
 | 
			
		||||
    """Lower Score for failed identification attempts"""
 | 
			
		||||
    update_score(request, uid_field, -1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_logged_in)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def handle_successful_login(sender, request, user, **_):
 | 
			
		||||
    """Raise score for successful attempts"""
 | 
			
		||||
    update_score(request, user.username, 1)
 | 
			
		||||
 | 
			
		||||
@ -13,14 +13,12 @@ LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(monitoring_set)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def monitoring_set_policies(sender, **kwargs):
 | 
			
		||||
    """set policy gauges"""
 | 
			
		||||
    GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or []))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def invalidate_policy_cache(sender, instance, **_):
 | 
			
		||||
    """Invalidate Policy cache when policy is updated"""
 | 
			
		||||
    from authentik.policies.models import Policy, PolicyBinding
 | 
			
		||||
 | 
			
		||||
@ -83,7 +83,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    @action(methods=["GET"], detail=True)
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    def setup_urls(self, request: Request, pk: int) -> str:
 | 
			
		||||
        """Get Providers setup URLs"""
 | 
			
		||||
        provider = get_object_or_404(OAuth2Provider, pk=pk)
 | 
			
		||||
@ -140,7 +139,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["GET"])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def preview_user(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Preview user data for provider"""
 | 
			
		||||
        provider: OAuth2Provider = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,8 @@ from django_filters.filters import AllValuesMultipleFilter
 | 
			
		||||
from django_filters.filterset import FilterSet
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from drf_spectacular.utils import extend_schema_field
 | 
			
		||||
from rest_framework.fields import CharField
 | 
			
		||||
from rest_framework.serializers import ValidationError
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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):
 | 
			
		||||
    """ScopeMapping Serializer"""
 | 
			
		||||
 | 
			
		||||
    scope_name = CharField(help_text="Scope name requested by the client", validators=[no_space])
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = ScopeMapping
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ class Migration(migrations.Migration):
 | 
			
		||||
            model_name="oauth2provider",
 | 
			
		||||
            name="verification_keys",
 | 
			
		||||
            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="+",
 | 
			
		||||
                to="authentik_crypto.certificatekeypair",
 | 
			
		||||
                verbose_name="Allowed certificates for JWT-based client_credentials",
 | 
			
		||||
 | 
			
		||||
@ -32,7 +32,7 @@ class Migration(migrations.Migration):
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                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",
 | 
			
		||||
                to="authentik_crypto.certificatekeypair",
 | 
			
		||||
                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.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.events.models import Event
 | 
			
		||||
from authentik.events.signals import SESSION_LOGIN_EVENT
 | 
			
		||||
from authentik.events.signals import get_login_event
 | 
			
		||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
 | 
			
		||||
from authentik.lib.models import SerializerModel
 | 
			
		||||
from authentik.lib.utils.time import timedelta_string_validator
 | 
			
		||||
@ -419,6 +418,8 @@ class IDToken:
 | 
			
		||||
            id_dict.pop("nonce")
 | 
			
		||||
        if not self.c_hash:
 | 
			
		||||
            id_dict.pop("c_hash")
 | 
			
		||||
        if not self.amr:
 | 
			
		||||
            id_dict.pop("amr")
 | 
			
		||||
        id_dict.pop("claims")
 | 
			
		||||
        id_dict.update(self.claims)
 | 
			
		||||
        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
 | 
			
		||||
        # Fallback in case we can't find any login events
 | 
			
		||||
        auth_time = now
 | 
			
		||||
        if SESSION_LOGIN_EVENT in request.session:
 | 
			
		||||
            auth_event: Event = request.session[SESSION_LOGIN_EVENT]
 | 
			
		||||
        auth_event = get_login_event(request)
 | 
			
		||||
        if auth_event:
 | 
			
		||||
            auth_time = auth_event.created
 | 
			
		||||
            # Also check which method was used for authentication
 | 
			
		||||
            method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
 | 
			
		||||
@ -526,6 +527,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
 | 
			
		||||
            exp=exp_time,
 | 
			
		||||
            iat=iat_time,
 | 
			
		||||
            auth_time=auth_timestamp,
 | 
			
		||||
            amr=amr if amr else None,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Include (or not) user standard claims in the id_token.
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,13 @@
 | 
			
		||||
"""Test authorize view"""
 | 
			
		||||
from unittest.mock import MagicMock, patch
 | 
			
		||||
 | 
			
		||||
from django.test import RequestFactory
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.timezone import now
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
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.lib.generators import generate_id, generate_key
 | 
			
		||||
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.views.authorize import OAuthAuthorizationParams
 | 
			
		||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestAuthorize(OAuthTestCase):
 | 
			
		||||
@ -302,6 +306,16 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
        state = generate_id()
 | 
			
		||||
        user = create_test_admin_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
 | 
			
		||||
            self.client.get(
 | 
			
		||||
                reverse("authentik_providers_oauth2:authorize"),
 | 
			
		||||
@ -331,6 +345,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
            jwt = self.validate_jwt(token, provider)
 | 
			
		||||
            self.assertEqual(jwt["amr"], ["pwd"])
 | 
			
		||||
            self.assertAlmostEqual(
 | 
			
		||||
                jwt["exp"] - now().timestamp(),
 | 
			
		||||
                expires,
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user