Compare commits
	
		
			237 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2c42c87689 | |||
| 8262a47455 | |||
| bd56922a2f | |||
| 5b68942b23 | |||
| c8bd0fbb1c | |||
| c99798b1f2 | |||
| 316c6966b7 | |||
| 6a44695c48 | |||
| c46b2d5573 | |||
| 68b58fb73c | |||
| 97513467ad | |||
| 35678c18c5 | |||
| 5fba08c911 | |||
| 1149a61986 | |||
| 7a10872854 | |||
| 4d1bcd2e19 | |||
| 8a1b6693a7 | |||
| 90c89aec76 | |||
| b429e24392 | |||
| 3073b7d7e3 | |||
| e02b99bfbc | |||
| 6d86067cea | |||
| ce5d1fd80d | |||
| a6755bea71 | |||
| 4cce99b207 | |||
| d1287aa7c9 | |||
| bcbd6f7243 | |||
| 39424839c5 | |||
| 2d03bd5c89 | |||
| 4d527a0ac5 | |||
| b1020fde64 | |||
| ff13b4bb46 | |||
| f0e121c064 | |||
| 89a3f7d004 | |||
| e6aa4c9327 | |||
| 2b2323fae7 | |||
| a148e611f3 | |||
| b56fd5e745 | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2022.12.0 | ||||
| current_version = 2023.1.0 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) | ||||
|  | ||||
							
								
								
									
										58
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										58
									
								
								.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_!(oidc) tests/e2e/test_source_oauth* | ||||
|           - name: oauth-oidc | ||||
|             glob: tests/e2e/test_provider_oauth2_oidc* | ||||
|           - name: saml | ||||
|             glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml* | ||||
|           - name: ldap | ||||
|             glob: tests/e2e/test_provider_ldap* | ||||
|           - name: flows | ||||
|             glob: tests/e2e/test_flows* | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - name: Setup authentik env | ||||
| @ -131,36 +148,8 @@ 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 | ||||
|           shopt -s extglob | ||||
|           poetry run coverage run manage.py test ${{ matrix.job.glob }} | ||||
|           poetry run coverage xml | ||||
|       - if: ${{ always() }} | ||||
|         uses: codecov/codecov-action@v3 | ||||
| @ -173,8 +162,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 | ||||
| @ -205,7 +193,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: | | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -80,7 +80,7 @@ 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' }} | ||||
| @ -111,7 +111,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 | ||||
|  | ||||
							
								
								
									
										6
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -27,7 +27,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' }} | ||||
| @ -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' }} | ||||
| @ -109,7 +109,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' | ||||
|  | ||||
							
								
								
									
										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 | ||||
|  | ||||
|  | ||||
| @ -2,16 +2,14 @@ | ||||
| from os import environ | ||||
| from typing import Optional | ||||
|  | ||||
| __version__ = "2022.12.0" | ||||
| __version__ = "2023.1.0" | ||||
| 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(): | ||||
|  | ||||
| @ -1,9 +1,15 @@ | ||||
| """API Authorization""" | ||||
| from django.conf import settings | ||||
| from django.db.models import Model | ||||
| from django.db.models.query import QuerySet | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from rest_framework.authentication import get_authorization_header | ||||
| from rest_framework.filters import BaseFilterBackend | ||||
| from rest_framework.permissions import BasePermission | ||||
| from rest_framework.request import Request | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
|  | ||||
| from authentik.api.authentication import validate_auth | ||||
|  | ||||
|  | ||||
| class OwnerFilter(BaseFilterBackend): | ||||
| @ -17,6 +23,20 @@ class OwnerFilter(BaseFilterBackend): | ||||
|         return queryset.filter(**{self.owner_key: request.user}) | ||||
|  | ||||
|  | ||||
| class SecretKeyFilter(DjangoFilterBackend): | ||||
|     """Allow access to all objects when authenticated with secret key as token. | ||||
|  | ||||
|     Replaces both DjangoFilterBackend and ObjectPermissionsFilter""" | ||||
|  | ||||
|     def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: | ||||
|         auth_header = get_authorization_header(request) | ||||
|         token = validate_auth(auth_header) | ||||
|         if token and token == settings.SECRET_KEY: | ||||
|             return queryset | ||||
|         queryset = ObjectPermissionsFilter().filter_queryset(request, queryset, view) | ||||
|         return super().filter_queryset(request, queryset, view) | ||||
|  | ||||
|  | ||||
| class OwnerPermissions(BasePermission): | ||||
|     """Authorize requests by an object's owner matching the requesting user""" | ||||
|  | ||||
|  | ||||
| @ -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: | ||||
|  | ||||
| @ -5,3 +5,9 @@ entries: | ||||
|           slug: "%(id)s" | ||||
|       model: authentik_flows.flow | ||||
|       state: absent | ||||
|     - identifiers: | ||||
|           name: "%(id)s" | ||||
|           expression: | | ||||
|             return True | ||||
|       model: authentik_policies_expression.expressionpolicy | ||||
|       state: absent | ||||
|  | ||||
							
								
								
									
										54
									
								
								authentik/blueprints/tests/fixtures/tags.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										54
									
								
								authentik/blueprints/tests/fixtures/tags.yaml
									
									
									
									
										vendored
									
									
								
							| @ -3,8 +3,15 @@ context: | ||||
|     foo: bar | ||||
|     policy_property: name | ||||
|     policy_property_value: foo-bar-baz-qux | ||||
|     sequence: | ||||
|     - foo | ||||
|     - bar | ||||
|     mapping: | ||||
|       key1: value | ||||
|       key2: 2 | ||||
| entries: | ||||
|     - model: authentik_sources_oauth.oauthsource | ||||
|     - model: !Format ["%s", authentik_sources_oauth.oauthsource] | ||||
|       state: !Format ["%s", present] | ||||
|       identifiers: | ||||
|           slug: test | ||||
|       attrs: | ||||
| @ -18,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: | ||||
| @ -91,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 | ||||
| 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 | ||||
| @ -56,17 +58,21 @@ class BlueprintEntryDesiredState(Enum): | ||||
| class BlueprintEntry: | ||||
|     """Single entry of a blueprint""" | ||||
|  | ||||
|     model: str | ||||
|     state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT) | ||||
|     model: Union[str, "YAMLTag"] | ||||
|     state: Union[BlueprintEntryDesiredState, "YAMLTag"] = field( | ||||
|         default=BlueprintEntryDesiredState.PRESENT | ||||
|     ) | ||||
|     conditions: list[Any] = field(default_factory=list) | ||||
|     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""" | ||||
| @ -83,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""" | ||||
| @ -103,6 +138,14 @@ class BlueprintEntry: | ||||
|         """Get attributes of this entry, with all yaml tags resolved""" | ||||
|         return self.tag_resolver(self.identifiers, blueprint) | ||||
|  | ||||
|     def get_state(self, blueprint: "Blueprint") -> BlueprintEntryDesiredState: | ||||
|         """Get the blueprint state, with yaml tags resolved if present""" | ||||
|         return BlueprintEntryDesiredState(self.tag_resolver(self.state, blueprint)) | ||||
|  | ||||
|     def get_model(self, blueprint: "Blueprint") -> str: | ||||
|         """Get the blueprint model, with yaml tags resolved if present""" | ||||
|         return str(self.tag_resolver(self.model, blueprint)) | ||||
|  | ||||
|     def check_all_conditions_match(self, blueprint: "Blueprint") -> bool: | ||||
|         """Check all conditions of this entry match (evaluate to True)""" | ||||
|         return all(self.tag_resolver(self.conditions, blueprint)) | ||||
| @ -135,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 | ||||
| @ -167,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 | ||||
| @ -187,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 | ||||
| @ -210,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 | ||||
| @ -235,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 = [] | ||||
| @ -252,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): | ||||
| @ -263,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 | ||||
| @ -286,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 | ||||
| @ -319,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]) | ||||
| @ -341,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""" | ||||
|  | ||||
| @ -384,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): | ||||
|  | ||||
| @ -34,7 +34,7 @@ from authentik.core.models import ( | ||||
|     Source, | ||||
|     UserSourceConnection, | ||||
| ) | ||||
| from authentik.flows.models import Stage | ||||
| from authentik.flows.models import FlowToken, Stage | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.outposts.models import OutpostServiceConnection | ||||
| from authentik.policies.models import Policy, PolicyBindingModel | ||||
| @ -60,6 +60,8 @@ def is_model_allowed(model: type[Model]) -> bool: | ||||
|         PolicyBindingModel, | ||||
|         # Classes that have other dependencies | ||||
|         AuthenticatedSession, | ||||
|         # Classes which are only internally managed | ||||
|         FlowToken, | ||||
|     ) | ||||
|     return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel)) | ||||
|  | ||||
| @ -148,7 +150,7 @@ class Importer: | ||||
|             self.logger.debug("One or more conditions of this entry are not fulfilled, skipping") | ||||
|             return None | ||||
|  | ||||
|         model_app_label, model_name = entry.model.split(".") | ||||
|         model_app_label, model_name = entry.get_model(self.__import).split(".") | ||||
|         model: type[SerializerModel] = registry.get_model(model_app_label, model_name) | ||||
|         # Don't use isinstance since we don't want to check for inheritance | ||||
|         if not is_model_allowed(model): | ||||
| @ -184,7 +186,7 @@ class Importer: | ||||
|         serializer_kwargs = {} | ||||
|         model_instance = existing_models.first() | ||||
|         if not isinstance(model(), BaseMetaModel) and model_instance: | ||||
|             if entry.state == BlueprintEntryDesiredState.CREATED: | ||||
|             if entry.get_state(self.__import) == BlueprintEntryDesiredState.CREATED: | ||||
|                 self.logger.debug("instance exists, skipping") | ||||
|                 return None | ||||
|             self.logger.debug( | ||||
| @ -237,7 +239,7 @@ class Importer: | ||||
|         """Apply (create/update) models yaml""" | ||||
|         self.__pk_map = {} | ||||
|         for entry in self.__import.entries: | ||||
|             model_app_label, model_name = entry.model.split(".") | ||||
|             model_app_label, model_name = entry.get_model(self.__import).split(".") | ||||
|             try: | ||||
|                 model: type[SerializerModel] = registry.get_model(model_app_label, model_name) | ||||
|             except LookupError: | ||||
| @ -254,7 +256,8 @@ class Importer: | ||||
|             if not serializer: | ||||
|                 continue | ||||
|  | ||||
|             if entry.state in [ | ||||
|             state = entry.get_state(self.__import) | ||||
|             if state in [ | ||||
|                 BlueprintEntryDesiredState.PRESENT, | ||||
|                 BlueprintEntryDesiredState.CREATED, | ||||
|             ]: | ||||
| @ -263,9 +266,9 @@ class Importer: | ||||
|                     self.__pk_map[entry.identifiers["pk"]] = model.pk | ||||
|                 entry._state = BlueprintEntryState(model) | ||||
|                 self.logger.debug("updated model", model=model) | ||||
|             elif entry.state == BlueprintEntryDesiredState.ABSENT: | ||||
|             elif state == BlueprintEntryDesiredState.ABSENT: | ||||
|                 instance: Optional[Model] = serializer.instance | ||||
|                 if instance: | ||||
|                 if instance.pk: | ||||
|                     instance.delete() | ||||
|                     self.logger.debug("deleted model", mode=instance) | ||||
|                     continue | ||||
|  | ||||
							
								
								
									
										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 | ||||
| @ -10,6 +10,13 @@ from django.utils.text import slugify | ||||
| from django.utils.timezone import now | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from structlog.stdlib import get_logger | ||||
| from watchdog.events import ( | ||||
|     FileCreatedEvent, | ||||
|     FileModifiedEvent, | ||||
|     FileSystemEvent, | ||||
|     FileSystemEventHandler, | ||||
| ) | ||||
| from watchdog.observers import Observer | ||||
| from yaml import load | ||||
| from yaml.error import YAMLError | ||||
|  | ||||
| @ -32,6 +39,7 @@ from authentik.lib.config import CONFIG | ||||
| from authentik.root.celery import CELERY_APP | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| _file_watcher_started = False | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| @ -45,6 +53,39 @@ class BlueprintFile: | ||||
|     meta: Optional[BlueprintMetadata] = field(default=None) | ||||
|  | ||||
|  | ||||
| def start_blueprint_watcher(): | ||||
|     """Start blueprint watcher, if it's not running already.""" | ||||
|     # This function might be called twice since it's called on celery startup | ||||
|     # pylint: disable=global-statement | ||||
|     global _file_watcher_started | ||||
|     if _file_watcher_started: | ||||
|         return | ||||
|     observer = Observer() | ||||
|     observer.schedule(BlueprintEventHandler(), CONFIG.y("blueprints_dir"), recursive=True) | ||||
|     observer.start() | ||||
|     _file_watcher_started = True | ||||
|  | ||||
|  | ||||
| class BlueprintEventHandler(FileSystemEventHandler): | ||||
|     """Event handler for blueprint events""" | ||||
|  | ||||
|     def on_any_event(self, event: FileSystemEvent): | ||||
|         if not isinstance(event, (FileCreatedEvent, FileModifiedEvent)): | ||||
|             return | ||||
|         if event.is_directory: | ||||
|             return | ||||
|         if isinstance(event, FileCreatedEvent): | ||||
|             LOGGER.debug("new blueprint file created, starting discovery") | ||||
|             blueprints_discover.delay() | ||||
|         if isinstance(event, FileModifiedEvent): | ||||
|             path = Path(event.src_path) | ||||
|             root = Path(CONFIG.y("blueprints_dir")).absolute() | ||||
|             rel_path = str(path.relative_to(root)) | ||||
|             for instance in BlueprintInstance.objects.filter(path=rel_path): | ||||
|                 LOGGER.debug("modified blueprint file, starting apply", instance=instance) | ||||
|                 apply_blueprint.delay(instance.pk.hex) | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task( | ||||
|     throws=(DatabaseError, ProgrammingError, InternalError), | ||||
| ) | ||||
| @ -60,8 +101,7 @@ def blueprints_find(): | ||||
|     """Find blueprints and return valid ones""" | ||||
|     blueprints = [] | ||||
|     root = Path(CONFIG.y("blueprints_dir")) | ||||
|     for file in root.glob("**/*.yaml"): | ||||
|         path = Path(file) | ||||
|     for path in root.glob("**/*.yaml"): | ||||
|         LOGGER.debug("found blueprint", path=str(path)) | ||||
|         with open(path, "r", encoding="utf-8") as blueprint_file: | ||||
|             try: | ||||
|  | ||||
| @ -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[ | ||||
|  | ||||
| @ -13,6 +13,7 @@ | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/dropdown.css' %}"> | ||||
|         {% block head_before %} | ||||
|         {% endblock %} | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> | ||||
|  | ||||
| @ -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() | ||||
|  | ||||
| @ -15,12 +15,14 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.fields import CharField, DateTimeField, IntegerField, SerializerMethodField | ||||
| from rest_framework.filters import OrderingFilter, SearchFilter | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.api.authorization import SecretKeyFilter | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| @ -185,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="") | ||||
| @ -203,9 +204,17 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | ||||
|     filterset_class = CertificateKeyPairFilter | ||||
|     ordering = ["name"] | ||||
|     search_fields = ["name"] | ||||
|     filter_backends = [SecretKeyFilter, OrderingFilter, SearchFilter] | ||||
|  | ||||
|     @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), | ||||
|         ] | ||||
|     ) | ||||
| @ -226,8 +235,7 @@ 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"] | ||||
|         builder = CertificateBuilder(data.validated_data["common_name"]) | ||||
|         builder.build( | ||||
|             subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","), | ||||
|             validity_days=int(data.validated_data["validity_days"]), | ||||
| @ -247,7 +255,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() | ||||
| @ -278,7 +285,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: | ||||
|  | ||||
| @ -14,7 +14,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 +54,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,7 +64,7 @@ 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): | ||||
| @ -193,8 +193,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): | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
| @ -19,7 +19,13 @@ from authentik.core.api.utils import PassiveSerializer, is_dict | ||||
| from authentik.core.models import Provider | ||||
| from authentik.outposts.api.service_connections import ServiceConnectionSerializer | ||||
| from authentik.outposts.apps import MANAGED_OUTPOST | ||||
| from authentik.outposts.models import Outpost, OutpostConfig, OutpostType, default_outpost_config | ||||
| from authentik.outposts.models import ( | ||||
|     Outpost, | ||||
|     OutpostConfig, | ||||
|     OutpostState, | ||||
|     OutpostType, | ||||
|     default_outpost_config, | ||||
| ) | ||||
| from authentik.providers.ldap.models import LDAPProvider | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
|  | ||||
| @ -96,6 +102,7 @@ class OutpostDefaultConfigSerializer(PassiveSerializer): | ||||
| class OutpostHealthSerializer(PassiveSerializer): | ||||
|     """Outpost health status""" | ||||
|  | ||||
|     uid = CharField(read_only=True) | ||||
|     last_seen = DateTimeField(read_only=True) | ||||
|     version = CharField(read_only=True) | ||||
|     version_should = CharField(read_only=True) | ||||
| @ -105,6 +112,8 @@ class OutpostHealthSerializer(PassiveSerializer): | ||||
|     build_hash = CharField(read_only=True, required=False) | ||||
|     build_hash_should = CharField(read_only=True, required=False) | ||||
|  | ||||
|     hostname = CharField(read_only=True, required=False) | ||||
|  | ||||
|  | ||||
| class OutpostFilter(FilterSet): | ||||
|     """Filter for Outposts""" | ||||
| @ -139,19 +148,21 @@ 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() | ||||
|         states = [] | ||||
|         for state in outpost.state: | ||||
|             state: OutpostState | ||||
|             states.append( | ||||
|                 { | ||||
|                     "uid": state.uid, | ||||
|                     "last_seen": state.last_seen, | ||||
|                     "version": state.version, | ||||
|                     "version_should": state.version_should, | ||||
|                     "version_outdated": state.version_outdated, | ||||
|                     "build_hash": state.build_hash, | ||||
|                     "hostname": state.hostname, | ||||
|                     "build_hash_should": get_build_hash(), | ||||
|                 } | ||||
|             ) | ||||
|  | ||||
| @ -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) | ||||
| @ -98,6 +97,7 @@ class OutpostConsumer(AuthJsonConsumer): | ||||
|         if self.channel_name not in state.channel_ids: | ||||
|             state.channel_ids.append(self.channel_name) | ||||
|         state.last_seen = datetime.now() | ||||
|         state.hostname = msg.args.get("hostname", "") | ||||
|  | ||||
|         if not self.first_msg: | ||||
|             GAUGE_OUTPOSTS_CONNECTED.labels( | ||||
| @ -126,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( | ||||
|  | ||||
| @ -13,7 +13,7 @@ from django.utils.translation import gettext_lazy as _ | ||||
| from guardian.models import UserObjectPermission | ||||
| from guardian.shortcuts import assign_perm | ||||
| from model_utils.managers import InheritanceManager | ||||
| from packaging.version import LegacyVersion, Version, parse | ||||
| from packaging.version import Version, parse | ||||
| from rest_framework.serializers import Serializer | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| @ -429,8 +429,9 @@ class OutpostState: | ||||
|     channel_ids: list[str] = field(default_factory=list) | ||||
|     last_seen: Optional[datetime] = field(default=None) | ||||
|     version: Optional[str] = field(default=None) | ||||
|     version_should: Version | LegacyVersion = field(default=OUR_VERSION) | ||||
|     version_should: Version = field(default=OUR_VERSION) | ||||
|     build_hash: str = field(default="") | ||||
|     hostname: str = field(default="") | ||||
|  | ||||
|     _outpost: Optional[Outpost] = field(default=None) | ||||
|  | ||||
|  | ||||
| @ -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. | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	