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]
|
[bumpversion]
|
||||||
current_version = 2022.12.0
|
current_version = 2023.1.0
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||||
|
|||||||
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
|
uses: helm/kind-action@v1.5.0
|
||||||
- name: run integration
|
- name: run integration
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-integration
|
poetry run coverage run manage.py test tests/integration
|
||||||
poetry run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
flags: integration
|
flags: integration
|
||||||
test-e2e-provider:
|
test-e2e:
|
||||||
|
name: test-e2e (${{ matrix.job.name }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
job:
|
||||||
|
- name: proxy
|
||||||
|
glob: tests/e2e/test_provider_proxy*
|
||||||
|
- name: oauth
|
||||||
|
glob: tests/e2e/test_provider_oauth2_!(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:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
@ -131,36 +148,8 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-e2e-provider
|
shopt -s extglob
|
||||||
poetry run coverage xml
|
poetry run coverage run manage.py test ${{ matrix.job.glob }}
|
||||||
- if: ${{ always() }}
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
flags: e2e
|
|
||||||
test-e2e-rest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Setup authentik env
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
- name: Setup e2e env (chrome, etc)
|
|
||||||
run: |
|
|
||||||
docker-compose -f tests/e2e/docker-compose.yml up -d
|
|
||||||
- id: cache-web
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: web/dist
|
|
||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
|
|
||||||
- name: prepare web ui
|
|
||||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
|
||||||
working-directory: web/
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
make -C .. gen-client-ts
|
|
||||||
npm run build
|
|
||||||
- name: run e2e
|
|
||||||
run: |
|
|
||||||
poetry run make test-e2e-rest
|
|
||||||
poetry run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
@ -173,8 +162,7 @@ jobs:
|
|||||||
- test-migrations-from-stable
|
- test-migrations-from-stable
|
||||||
- test-unittest
|
- test-unittest
|
||||||
- test-integration
|
- test-integration
|
||||||
- test-e2e-rest
|
- test-e2e
|
||||||
- test-e2e-provider
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- run: echo mark
|
||||||
@ -205,7 +193,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
secrets: |
|
secrets: |
|
||||||
|
|||||||
4
.github/workflows/ci-outpost.yml
vendored
4
.github/workflows/ci-outpost.yml
vendored
@ -80,7 +80,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
@ -111,7 +111,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
25
.github/workflows/ci-web.yml
vendored
25
.github/workflows/ci-web.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -27,11 +27,27 @@ jobs:
|
|||||||
- name: Eslint
|
- name: Eslint
|
||||||
working-directory: web/
|
working-directory: web/
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
lint-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3.6.0
|
||||||
|
with:
|
||||||
|
node-version: '16'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- working-directory: web/
|
||||||
|
run: npm ci
|
||||||
|
- name: Generate API
|
||||||
|
run: make gen-client-ts
|
||||||
|
- name: TSC
|
||||||
|
working-directory: web/
|
||||||
|
run: npm run tsc
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -47,7 +63,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -69,6 +85,7 @@ jobs:
|
|||||||
- lint-eslint
|
- lint-eslint
|
||||||
- lint-prettier
|
- lint-prettier
|
||||||
- lint-lit-analyse
|
- lint-lit-analyse
|
||||||
|
- lint-build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- run: echo mark
|
||||||
@ -78,7 +95,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
2
.github/workflows/ci-website.yml
vendored
2
.github/workflows/ci-website.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
4
.github/workflows/ghcr-retention.yml
vendored
4
.github/workflows/ghcr-retention.yml
vendored
@ -11,12 +11,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Delete 'dev' containers older than a week
|
- name: Delete 'dev' containers older than a week
|
||||||
uses: sondrelg/container-retention-policy@v1
|
uses: snok/container-retention-policy@v1
|
||||||
with:
|
with:
|
||||||
image-names: dev-server,dev-ldap,dev-proxy
|
image-names: dev-server,dev-ldap,dev-proxy
|
||||||
cut-off: One week ago UTC
|
cut-off: One week ago UTC
|
||||||
account-type: org
|
account-type: org
|
||||||
org-name: goauthentik
|
org-name: goauthentik
|
||||||
untagged-only: false
|
untagged-only: false
|
||||||
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
skip-tags: gh-next,gh-main
|
skip-tags: gh-next,gh-main
|
||||||
|
|||||||
6
.github/workflows/release-publish.yml
vendored
6
.github/workflows/release-publish.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
@ -75,7 +75,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
@ -109,7 +109,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
16
.github/workflows/web-api-publish.yml
vendored
16
.github/workflows/web-api-publish.yml
vendored
@ -10,7 +10,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
with:
|
||||||
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
@ -28,14 +30,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION
|
||||||
- name: Create Pull Request
|
- uses: peter-evans/create-pull-request@v4
|
||||||
uses: peter-evans/create-pull-request@v4
|
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
branch: update-web-api-client
|
branch: update-web-api-client
|
||||||
commit-message: "web: bump API Client version"
|
commit-message: "web: bump API Client version"
|
||||||
title: "web: bump API Client version"
|
title: "web: bump API Client version"
|
||||||
body: "web: bump API Client version"
|
body: "web: bump API Client version"
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
signoff: true
|
signoff: true
|
||||||
|
team-reviewers: "@goauthentik/core"
|
||||||
|
author: authentik bot <github-bot@goauthentik.io>
|
||||||
|
- uses: peter-evans/enable-pull-request-automerge@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
|
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||||
|
merge-method: squash
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -14,7 +14,9 @@
|
|||||||
"webauthn",
|
"webauthn",
|
||||||
"traefik",
|
"traefik",
|
||||||
"passwordless",
|
"passwordless",
|
||||||
"kubernetes"
|
"kubernetes",
|
||||||
|
"sso",
|
||||||
|
"slo"
|
||||||
],
|
],
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
|
|||||||
@ -59,19 +59,18 @@ These are the current packages:
|
|||||||
authentik
|
authentik
|
||||||
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
||||||
├── api - General API Configuration (Routes, Schema and general API utilities)
|
├── api - General API Configuration (Routes, Schema and general API utilities)
|
||||||
|
├── blueprints - Handle managed models and their state.
|
||||||
├── core - Core authentik functionality, central routes, core Models
|
├── core - Core authentik functionality, central routes, core Models
|
||||||
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
||||||
├── events - Event Log, middleware and signals to generate signals
|
├── events - Event Log, middleware and signals to generate signals
|
||||||
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
||||||
├── lib - Generic library of functions, few dependencies on other packages.
|
├── lib - Generic library of functions, few dependencies on other packages.
|
||||||
├── managed - Handle managed models and their state.
|
|
||||||
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
||||||
├── policies - General PolicyEngine
|
├── policies - General PolicyEngine
|
||||||
│ ├── dummy - A Dummy policy used for testing
|
│ ├── dummy - A Dummy policy used for testing
|
||||||
│ ├── event_matcher - Match events based on different criteria
|
│ ├── event_matcher - Match events based on different criteria
|
||||||
│ ├── expiry - Check when a user's password was last set
|
│ ├── expiry - Check when a user's password was last set
|
||||||
│ ├── expression - Execute any arbitrary python code
|
│ ├── expression - Execute any arbitrary python code
|
||||||
│ ├── hibp - Check a password against HaveIBeenPwned
|
|
||||||
│ ├── password - Check a password against several rules
|
│ ├── password - Check a password against several rules
|
||||||
│ └── reputation - Check the user's/client's reputation
|
│ └── reputation - Check the user's/client's reputation
|
||||||
├── providers
|
├── providers
|
||||||
|
|||||||
@ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \
|
|||||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||||
|
|
||||||
# Stage 4: Build go proxy
|
# Stage 4: Build go proxy
|
||||||
FROM docker.io/golang:1.19.4-bullseye AS go-builder
|
FROM docker.io/golang:1.19.5-bullseye AS go-builder
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
|
|
||||||
@ -50,6 +50,7 @@ RUN go build -o /work/authentik ./cmd/server/
|
|||||||
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
||||||
|
|
||||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
||||||
|
ENV GEOIPUPDATE_VERBOSE="true"
|
||||||
|
|
||||||
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||||
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
|
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
|
||||||
@ -57,7 +58,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
|||||||
/bin/sh -c "\
|
/bin/sh -c "\
|
||||||
export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
|
export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
|
||||||
export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
|
export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
|
||||||
/usr/bin/entry.sh || exit 0 \
|
/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0 \
|
||||||
"
|
"
|
||||||
|
|
||||||
# Stage 6: Run
|
# Stage 6: Run
|
||||||
|
|||||||
14
Makefile
14
Makefile
@ -6,15 +6,6 @@ NPM_VERSION = $(shell python -m scripts.npm_version)
|
|||||||
|
|
||||||
all: lint-fix lint test gen web
|
all: lint-fix lint test gen web
|
||||||
|
|
||||||
test-integration:
|
|
||||||
coverage run manage.py test tests/integration
|
|
||||||
|
|
||||||
test-e2e-provider:
|
|
||||||
coverage run manage.py test tests/e2e/test_provider*
|
|
||||||
|
|
||||||
test-e2e-rest:
|
|
||||||
coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
|
|
||||||
|
|
||||||
test-go:
|
test-go:
|
||||||
go test -timeout 0 -v -race -cover ./...
|
go test -timeout 0 -v -race -cover ./...
|
||||||
|
|
||||||
@ -126,7 +117,7 @@ gen: gen-build gen-clean gen-client-ts
|
|||||||
web-build: web-install
|
web-build: web-install
|
||||||
cd web && npm run build
|
cd web && npm run build
|
||||||
|
|
||||||
web: web-lint-fix web-lint
|
web: web-lint-fix web-lint web-check-compile
|
||||||
|
|
||||||
web-install:
|
web-install:
|
||||||
cd web && npm ci
|
cd web && npm ci
|
||||||
@ -144,6 +135,9 @@ web-lint:
|
|||||||
cd web && npm run lint
|
cd web && npm run lint
|
||||||
cd web && npm run lit-analyse
|
cd web && npm run lit-analyse
|
||||||
|
|
||||||
|
web-check-compile:
|
||||||
|
cd web && npm run tsc
|
||||||
|
|
||||||
web-extract:
|
web-extract:
|
||||||
cd web && npm run extract
|
cd web && npm run extract
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,14 @@
|
|||||||
from os import environ
|
from os import environ
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
__version__ = "2022.12.0"
|
__version__ = "2023.1.0"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
def get_build_hash(fallback: Optional[str] = None) -> str:
|
def get_build_hash(fallback: Optional[str] = None) -> str:
|
||||||
"""Get build hash"""
|
"""Get build hash"""
|
||||||
build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
|
build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
|
||||||
if build_hash == "" and fallback:
|
return fallback if build_hash == "" and fallback else build_hash
|
||||||
return fallback
|
|
||||||
return build_hash
|
|
||||||
|
|
||||||
|
|
||||||
def get_full_version() -> str:
|
def get_full_version() -> str:
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
"""authentik administration metrics"""
|
"""authentik administration metrics"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.fields import IntegerField, SerializerMethodField
|
from rest_framework.fields import IntegerField, SerializerMethodField
|
||||||
@ -21,38 +24,44 @@ class CoordinateSerializer(PassiveSerializer):
|
|||||||
class LoginMetricsSerializer(PassiveSerializer):
|
class LoginMetricsSerializer(PassiveSerializer):
|
||||||
"""Login Metrics per 1h"""
|
"""Login Metrics per 1h"""
|
||||||
|
|
||||||
logins_per_1h = SerializerMethodField()
|
logins = SerializerMethodField()
|
||||||
logins_failed_per_1h = SerializerMethodField()
|
logins_failed = SerializerMethodField()
|
||||||
authorizations_per_1h = SerializerMethodField()
|
authorizations = SerializerMethodField()
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_per_1h(self, _):
|
def get_logins(self, _):
|
||||||
"""Get successful logins per hour for the last 24 hours"""
|
"""Get successful logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.LOGIN)
|
action=EventAction.LOGIN
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed_per_1h(self, _):
|
def get_logins_failed(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.LOGIN_FAILED)
|
action=EventAction.LOGIN_FAILED
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_authorizations_per_1h(self, _):
|
def get_authorizations(self, _):
|
||||||
"""Get successful authorizations per hour for the last 24 hours"""
|
"""Get successful authorizations per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.AUTHORIZE_APPLICATION)
|
action=EventAction.AUTHORIZE_APPLICATION
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ from typing import TypedDict
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from gunicorn import version_info as gunicorn_version
|
from gunicorn import version_info as gunicorn_version
|
||||||
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
|
|
||||||
from rest_framework.fields import SerializerMethodField
|
from rest_framework.fields import SerializerMethodField
|
||||||
from rest_framework.permissions import IsAdminUser
|
from rest_framework.permissions import IsAdminUser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
@ -16,6 +15,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
from authentik.lib.utils.reflection import get_env
|
||||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||||
from authentik.outposts.models import Outpost
|
from authentik.outposts.models import Outpost
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ class SystemSerializer(PassiveSerializer):
|
|||||||
return {
|
return {
|
||||||
"python_version": python_version,
|
"python_version": python_version,
|
||||||
"gunicorn_version": ".".join(str(x) for x in gunicorn_version),
|
"gunicorn_version": ".".join(str(x) for x in gunicorn_version),
|
||||||
"environment": "kubernetes" if SERVICE_HOST_ENV_NAME in os.environ else "compose",
|
"environment": get_env(),
|
||||||
"architecture": platform.machine(),
|
"architecture": platform.machine(),
|
||||||
"platform": platform.platform(),
|
"platform": platform.platform(),
|
||||||
"uname": " ".join(platform.uname()),
|
"uname": " ".join(platform.uname()),
|
||||||
|
|||||||
@ -7,7 +7,13 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
|
from rest_framework.fields import (
|
||||||
|
CharField,
|
||||||
|
ChoiceField,
|
||||||
|
DateTimeField,
|
||||||
|
ListField,
|
||||||
|
SerializerMethodField,
|
||||||
|
)
|
||||||
from rest_framework.permissions import IsAdminUser
|
from rest_framework.permissions import IsAdminUser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -26,6 +32,7 @@ class TaskSerializer(PassiveSerializer):
|
|||||||
task_name = CharField()
|
task_name = CharField()
|
||||||
task_description = CharField()
|
task_description = CharField()
|
||||||
task_finish_timestamp = DateTimeField(source="finish_time")
|
task_finish_timestamp = DateTimeField(source="finish_time")
|
||||||
|
task_duration = SerializerMethodField()
|
||||||
|
|
||||||
status = ChoiceField(
|
status = ChoiceField(
|
||||||
source="result.status.name",
|
source="result.status.name",
|
||||||
@ -33,7 +40,11 @@ class TaskSerializer(PassiveSerializer):
|
|||||||
)
|
)
|
||||||
messages = ListField(source="result.messages")
|
messages = ListField(source="result.messages")
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def get_task_duration(self, instance: TaskInfo) -> int:
|
||||||
|
"""Get the duration a task took to run"""
|
||||||
|
return max(instance.finish_timestamp - instance.start_timestamp, 0)
|
||||||
|
|
||||||
|
def to_representation(self, instance: TaskInfo):
|
||||||
"""When a new version of authentik adds fields to TaskInfo,
|
"""When a new version of authentik adds fields to TaskInfo,
|
||||||
the API will fail with an AttributeError, as the classes
|
the API will fail with an AttributeError, as the classes
|
||||||
are pickled in cache. In that case, just delete the info"""
|
are pickled in cache. In that case, just delete the info"""
|
||||||
@ -68,7 +79,6 @@ class TaskViewSet(ViewSet):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
# pylint: disable=invalid-name
|
|
||||||
def retrieve(self, request: Request, pk=None) -> Response:
|
def retrieve(self, request: Request, pk=None) -> Response:
|
||||||
"""Get a single system task"""
|
"""Get a single system task"""
|
||||||
task = TaskInfo.by_name(pk)
|
task = TaskInfo.by_name(pk)
|
||||||
@ -99,7 +109,6 @@ class TaskViewSet(ViewSet):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
# pylint: disable=invalid-name
|
|
||||||
def retry(self, request: Request, pk=None) -> Response:
|
def retry(self, request: Request, pk=None) -> Response:
|
||||||
"""Retry task"""
|
"""Retry task"""
|
||||||
task = TaskInfo.by_name(pk)
|
task = TaskInfo.by_name(pk)
|
||||||
|
|||||||
@ -8,7 +8,6 @@ from authentik.root.monitoring import monitoring_set
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_workers(sender, **kwargs):
|
def monitoring_set_workers(sender, **kwargs):
|
||||||
"""Set worker gauge"""
|
"""Set worker gauge"""
|
||||||
count = len(CELERY_APP.control.ping(timeout=0.5))
|
count = len(CELERY_APP.control.ping(timeout=0.5))
|
||||||
@ -16,7 +15,6 @@ def monitoring_set_workers(sender, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_tasks(sender, **kwargs):
|
def monitoring_set_tasks(sender, **kwargs):
|
||||||
"""Set task gauges"""
|
"""Set task gauges"""
|
||||||
for task in TaskInfo.all().values():
|
for task in TaskInfo.all().values():
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
"""API Authorization"""
|
"""API Authorization"""
|
||||||
|
from django.conf import settings
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.db.models.query import QuerySet
|
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.filters import BaseFilterBackend
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||||
|
|
||||||
|
from authentik.api.authentication import validate_auth
|
||||||
|
|
||||||
|
|
||||||
class OwnerFilter(BaseFilterBackend):
|
class OwnerFilter(BaseFilterBackend):
|
||||||
@ -17,6 +23,20 @@ class OwnerFilter(BaseFilterBackend):
|
|||||||
return queryset.filter(**{self.owner_key: request.user})
|
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):
|
class OwnerPermissions(BasePermission):
|
||||||
"""Authorize requests by an object's owner matching the requesting user"""
|
"""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-url-load="false"
|
||||||
allow-spec-file-load="false">
|
allow-spec-file-load="false">
|
||||||
<div slot="nav-logo">
|
<div slot="nav-logo">
|
||||||
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
<img alt="authentik Logo" class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
||||||
</div>
|
</div>
|
||||||
</rapi-doc>
|
</rapi-doc>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -68,7 +68,7 @@ class ConfigView(APIView):
|
|||||||
caps.append(Capabilities.CAN_GEO_IP)
|
caps.append(Capabilities.CAN_GEO_IP)
|
||||||
if CONFIG.y_bool("impersonation"):
|
if CONFIG.y_bool("impersonation"):
|
||||||
caps.append(Capabilities.CAN_IMPERSONATE)
|
caps.append(Capabilities.CAN_IMPERSONATE)
|
||||||
if settings.DEBUG:
|
if settings.DEBUG: # pragma: no cover
|
||||||
caps.append(Capabilities.CAN_DEBUG)
|
caps.append(Capabilities.CAN_DEBUG)
|
||||||
return caps
|
return caps
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,6 @@ from authentik.policies.dummy.api import DummyPolicyViewSet
|
|||||||
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
|
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
|
||||||
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
|
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
|
||||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
||||||
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
|
|
||||||
from authentik.policies.password.api import PasswordPolicyViewSet
|
from authentik.policies.password.api import PasswordPolicyViewSet
|
||||||
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
|
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
|
||||||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
||||||
@ -150,7 +149,6 @@ router.register("policies/all", PolicyViewSet)
|
|||||||
router.register("policies/bindings", PolicyBindingViewSet)
|
router.register("policies/bindings", PolicyBindingViewSet)
|
||||||
router.register("policies/expression", ExpressionPolicyViewSet)
|
router.register("policies/expression", ExpressionPolicyViewSet)
|
||||||
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
||||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
|
||||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
||||||
router.register("policies/password", PasswordPolicyViewSet)
|
router.register("policies/password", PasswordPolicyViewSet)
|
||||||
router.register("policies/reputation/scores", ReputationViewSet)
|
router.register("policies/reputation/scores", ReputationViewSet)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"""Serializer mixin for managed models"""
|
"""Serializer mixin for managed models"""
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
@ -11,6 +12,7 @@ from rest_framework.viewsets import ModelViewSet
|
|||||||
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||||
|
from authentik.blueprints.v1.importer import Importer
|
||||||
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
@ -40,6 +42,21 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
|||||||
raise ValidationError(exc) from exc
|
raise ValidationError(exc) from exc
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> str:
|
||||||
|
"""Ensure content (if set) is a valid blueprint"""
|
||||||
|
if content == "":
|
||||||
|
return content
|
||||||
|
context = self.instance.context if self.instance else {}
|
||||||
|
valid, logs = Importer(content, context).validate()
|
||||||
|
if not valid:
|
||||||
|
raise ValidationError(_("Failed to validate blueprint"), *[x["msg"] for x in logs])
|
||||||
|
return content
|
||||||
|
|
||||||
|
def validate(self, attrs: dict) -> dict:
|
||||||
|
if attrs.get("path", "") == "" and attrs.get("content", "") == "":
|
||||||
|
raise ValidationError(_("Either path or content must be set."))
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = BlueprintInstance
|
model = BlueprintInstance
|
||||||
@ -54,6 +71,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
|||||||
"enabled",
|
"enabled",
|
||||||
"managed_models",
|
"managed_models",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"content",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"status": {"read_only": True},
|
"status": {"read_only": True},
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-10 19:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_blueprints", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="blueprintinstance",
|
||||||
|
name="content",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="blueprintinstance",
|
||||||
|
name="path",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,30 +1,18 @@
|
|||||||
"""blueprint models"""
|
"""blueprint models"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from opencontainers.distribution.reggie import (
|
|
||||||
NewClient,
|
|
||||||
WithDebug,
|
|
||||||
WithDefaultName,
|
|
||||||
WithDigest,
|
|
||||||
WithReference,
|
|
||||||
WithUserAgent,
|
|
||||||
WithUsernamePassword,
|
|
||||||
)
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from authentik.blueprints.v1.oci import BlueprintOCIClient, OCIException
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.lib.utils.http import authentik_user_agent
|
|
||||||
|
|
||||||
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@ -74,7 +62,8 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
name = models.TextField()
|
name = models.TextField()
|
||||||
metadata = models.JSONField(default=dict)
|
metadata = models.JSONField(default=dict)
|
||||||
path = models.TextField()
|
path = models.TextField(default="", blank=True)
|
||||||
|
content = models.TextField(default="", blank=True)
|
||||||
context = models.JSONField(default=dict)
|
context = models.JSONField(default=dict)
|
||||||
last_applied = models.DateTimeField(auto_now=True)
|
last_applied = models.DateTimeField(auto_now=True)
|
||||||
last_applied_hash = models.TextField()
|
last_applied_hash = models.TextField()
|
||||||
@ -86,60 +75,29 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
def retrieve_oci(self) -> str:
|
def retrieve_oci(self) -> str:
|
||||||
"""Get blueprint from an OCI registry"""
|
"""Get blueprint from an OCI registry"""
|
||||||
url = urlparse(self.path)
|
client = BlueprintOCIClient(self.path.replace("oci://", "https://"))
|
||||||
ref = "latest"
|
|
||||||
path = url.path[1:]
|
|
||||||
if ":" in url.path:
|
|
||||||
path, _, ref = path.partition(":")
|
|
||||||
client = NewClient(
|
|
||||||
f"https://{url.hostname}",
|
|
||||||
WithUserAgent(authentik_user_agent()),
|
|
||||||
WithUsernamePassword(url.username, url.password),
|
|
||||||
WithDefaultName(path),
|
|
||||||
WithDebug(True),
|
|
||||||
)
|
|
||||||
LOGGER.info("Fetching OCI manifests for blueprint", instance=self)
|
|
||||||
manifest_request = client.NewRequest(
|
|
||||||
"GET",
|
|
||||||
"/v2/<name>/manifests/<reference>",
|
|
||||||
WithReference(ref),
|
|
||||||
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
|
|
||||||
try:
|
try:
|
||||||
manifest_response = client.Do(manifest_request)
|
manifests = client.fetch_manifests()
|
||||||
manifest_response.raise_for_status()
|
return client.fetch_blobs(manifests)
|
||||||
except RequestException as exc:
|
except OCIException as exc:
|
||||||
raise BlueprintRetrievalFailed(exc) from exc
|
raise BlueprintRetrievalFailed(exc) from exc
|
||||||
manifest = manifest_response.json()
|
|
||||||
if "errors" in manifest:
|
|
||||||
raise BlueprintRetrievalFailed(manifest["errors"])
|
|
||||||
|
|
||||||
blob = None
|
def retrieve_file(self) -> str:
|
||||||
for layer in manifest.get("layers", []):
|
"""Get blueprint from path"""
|
||||||
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
|
|
||||||
blob = layer.get("digest")
|
|
||||||
LOGGER.debug("Found layer with matching media type", instance=self, blob=blob)
|
|
||||||
if not blob:
|
|
||||||
raise BlueprintRetrievalFailed("Blob not found")
|
|
||||||
|
|
||||||
blob_request = client.NewRequest(
|
|
||||||
"GET",
|
|
||||||
"/v2/<name>/blobs/<digest>",
|
|
||||||
WithDigest(blob),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
blob_response = client.Do(blob_request)
|
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
||||||
blob_response.raise_for_status()
|
with full_path.open("r", encoding="utf-8") as _file:
|
||||||
return blob_response.text
|
return _file.read()
|
||||||
except RequestException as exc:
|
except (IOError, OSError) as exc:
|
||||||
raise BlueprintRetrievalFailed(exc) from exc
|
raise BlueprintRetrievalFailed(exc) from exc
|
||||||
|
|
||||||
def retrieve(self) -> str:
|
def retrieve(self) -> str:
|
||||||
"""Retrieve blueprint contents"""
|
"""Retrieve blueprint contents"""
|
||||||
if self.path.startswith("oci://"):
|
if self.path.startswith("oci://"):
|
||||||
return self.retrieve_oci()
|
return self.retrieve_oci()
|
||||||
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
if self.path != "":
|
||||||
with full_path.open("r", encoding="utf-8") as _file:
|
return self.retrieve_file()
|
||||||
return _file.read()
|
return self.content
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> Serializer:
|
def serializer(self) -> Serializer:
|
||||||
|
|||||||
@ -5,3 +5,9 @@ entries:
|
|||||||
slug: "%(id)s"
|
slug: "%(id)s"
|
||||||
model: authentik_flows.flow
|
model: authentik_flows.flow
|
||||||
state: absent
|
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
|
foo: bar
|
||||||
policy_property: name
|
policy_property: name
|
||||||
policy_property_value: foo-bar-baz-qux
|
policy_property_value: foo-bar-baz-qux
|
||||||
|
sequence:
|
||||||
|
- foo
|
||||||
|
- bar
|
||||||
|
mapping:
|
||||||
|
key1: value
|
||||||
|
key2: 2
|
||||||
entries:
|
entries:
|
||||||
- model: authentik_sources_oauth.oauthsource
|
- model: !Format ["%s", authentik_sources_oauth.oauthsource]
|
||||||
|
state: !Format ["%s", present]
|
||||||
identifiers:
|
identifiers:
|
||||||
slug: test
|
slug: test
|
||||||
attrs:
|
attrs:
|
||||||
@ -18,7 +25,7 @@ entries:
|
|||||||
[slug, default-source-authentication],
|
[slug, default-source-authentication],
|
||||||
]
|
]
|
||||||
enrollment_flow:
|
enrollment_flow:
|
||||||
!Find [authentik_flows.Flow, [slug, default-source-enrollment]]
|
!Find [!Format ["%s", authentik_flows.Flow], [slug, default-source-enrollment]]
|
||||||
- attrs:
|
- attrs:
|
||||||
expression: return True
|
expression: return True
|
||||||
identifiers:
|
identifiers:
|
||||||
@ -91,6 +98,49 @@ entries:
|
|||||||
]
|
]
|
||||||
if_true_simple: !If [!Context foo, true, text]
|
if_true_simple: !If [!Context foo, true, text]
|
||||||
if_false_simple: !If [null, false, 2]
|
if_false_simple: !If [null, false, 2]
|
||||||
|
enumerate_mapping_to_mapping: !Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
MAP,
|
||||||
|
[!Format ["prefix-%s", !Index 0], !Format ["other-prefix-%s", !Value 0]]
|
||||||
|
]
|
||||||
|
enumerate_mapping_to_sequence: !Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
SEQ,
|
||||||
|
!Format ["prefixed-pair-%s-%s", !Index 0, !Value 0]
|
||||||
|
]
|
||||||
|
enumerate_sequence_to_sequence: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
SEQ,
|
||||||
|
!Format ["prefixed-items-%s-%s", !Index 0, !Value 0]
|
||||||
|
]
|
||||||
|
enumerate_sequence_to_mapping: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
MAP,
|
||||||
|
[!Format ["index: %d", !Index 0], !Value 0]
|
||||||
|
]
|
||||||
|
nested_complex_enumeration: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
MAP,
|
||||||
|
[
|
||||||
|
!Index 0,
|
||||||
|
!Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
MAP,
|
||||||
|
[
|
||||||
|
!Format ["%s", !Index 0],
|
||||||
|
[
|
||||||
|
!Enumerate [!Value 2, SEQ, !Format ["prefixed-%s", !Value 0]],
|
||||||
|
{
|
||||||
|
outer_value: !Value 1,
|
||||||
|
outer_index: !Index 1,
|
||||||
|
middle_value: !Value 0,
|
||||||
|
middle_index: !Index 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
identifiers:
|
identifiers:
|
||||||
name: test
|
name: test
|
||||||
conditions:
|
conditions:
|
||||||
|
|||||||
@ -16,7 +16,7 @@ def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
|
|||||||
"""Test serializer"""
|
"""Test serializer"""
|
||||||
|
|
||||||
def tester(self: TestModels):
|
def tester(self: TestModels):
|
||||||
if test_model._meta.abstract:
|
if test_model._meta.abstract: # pragma: no cover
|
||||||
return
|
return
|
||||||
model_class = test_model()
|
model_class = test_model()
|
||||||
self.assertTrue(isinstance(model_class, SerializerModel))
|
self.assertTrue(isinstance(model_class, SerializerModel))
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
from requests_mock import Mocker
|
from requests_mock import Mocker
|
||||||
|
|
||||||
from authentik.blueprints.models import OCI_MEDIA_TYPE, BlueprintInstance, BlueprintRetrievalFailed
|
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||||
|
from authentik.blueprints.v1.oci import OCI_MEDIA_TYPE
|
||||||
|
|
||||||
|
|
||||||
class TestBlueprintOCI(TransactionTestCase):
|
class TestBlueprintOCI(TransactionTestCase):
|
||||||
@ -26,8 +27,8 @@ class TestBlueprintOCI(TransactionTestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
BlueprintInstance(
|
BlueprintInstance(
|
||||||
path="https://ghcr.io/goauthentik/blueprints/test:latest"
|
path="oci://ghcr.io/goauthentik/blueprints/test:latest"
|
||||||
).retrieve_oci(),
|
).retrieve(),
|
||||||
"foo",
|
"foo",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -40,7 +41,7 @@ class TestBlueprintOCI(TransactionTestCase):
|
|||||||
|
|
||||||
with self.assertRaises(BlueprintRetrievalFailed):
|
with self.assertRaises(BlueprintRetrievalFailed):
|
||||||
BlueprintInstance(
|
BlueprintInstance(
|
||||||
path="https://ghcr.io/goauthentik/blueprints/test:latest"
|
path="oci://ghcr.io/goauthentik/blueprints/test:latest"
|
||||||
).retrieve_oci()
|
).retrieve_oci()
|
||||||
|
|
||||||
def test_manifests_error_response(self):
|
def test_manifests_error_response(self):
|
||||||
@ -53,7 +54,7 @@ class TestBlueprintOCI(TransactionTestCase):
|
|||||||
|
|
||||||
with self.assertRaises(BlueprintRetrievalFailed):
|
with self.assertRaises(BlueprintRetrievalFailed):
|
||||||
BlueprintInstance(
|
BlueprintInstance(
|
||||||
path="https://ghcr.io/goauthentik/blueprints/test:latest"
|
path="oci://ghcr.io/goauthentik/blueprints/test:latest"
|
||||||
).retrieve_oci()
|
).retrieve_oci()
|
||||||
|
|
||||||
def test_no_matching_blob(self):
|
def test_no_matching_blob(self):
|
||||||
@ -72,7 +73,7 @@ class TestBlueprintOCI(TransactionTestCase):
|
|||||||
)
|
)
|
||||||
with self.assertRaises(BlueprintRetrievalFailed):
|
with self.assertRaises(BlueprintRetrievalFailed):
|
||||||
BlueprintInstance(
|
BlueprintInstance(
|
||||||
path="https://ghcr.io/goauthentik/blueprints/test:latest"
|
path="oci://ghcr.io/goauthentik/blueprints/test:latest"
|
||||||
).retrieve_oci()
|
).retrieve_oci()
|
||||||
|
|
||||||
def test_blob_error(self):
|
def test_blob_error(self):
|
||||||
@ -93,5 +94,5 @@ class TestBlueprintOCI(TransactionTestCase):
|
|||||||
|
|
||||||
with self.assertRaises(BlueprintRetrievalFailed):
|
with self.assertRaises(BlueprintRetrievalFailed):
|
||||||
BlueprintInstance(
|
BlueprintInstance(
|
||||||
path="https://ghcr.io/goauthentik/blueprints/test:latest"
|
path="oci://ghcr.io/goauthentik/blueprints/test:latest"
|
||||||
).retrieve_oci()
|
).retrieve_oci()
|
||||||
|
|||||||
@ -162,6 +162,61 @@ class TestBlueprintsV1(TransactionTestCase):
|
|||||||
"if_false_complex": ["list", "with", "items", "foo-bar"],
|
"if_false_complex": ["list", "with", "items", "foo-bar"],
|
||||||
"if_true_simple": True,
|
"if_true_simple": True,
|
||||||
"if_false_simple": 2,
|
"if_false_simple": 2,
|
||||||
|
"enumerate_mapping_to_mapping": {
|
||||||
|
"prefix-key1": "other-prefix-value",
|
||||||
|
"prefix-key2": "other-prefix-2",
|
||||||
|
},
|
||||||
|
"enumerate_mapping_to_sequence": [
|
||||||
|
"prefixed-pair-key1-value",
|
||||||
|
"prefixed-pair-key2-2",
|
||||||
|
],
|
||||||
|
"enumerate_sequence_to_sequence": [
|
||||||
|
"prefixed-items-0-foo",
|
||||||
|
"prefixed-items-1-bar",
|
||||||
|
],
|
||||||
|
"enumerate_sequence_to_mapping": {"index: 0": "foo", "index: 1": "bar"},
|
||||||
|
"nested_complex_enumeration": {
|
||||||
|
"0": {
|
||||||
|
"key1": [
|
||||||
|
["prefixed-f", "prefixed-o", "prefixed-o"],
|
||||||
|
{
|
||||||
|
"outer_value": "foo",
|
||||||
|
"outer_index": 0,
|
||||||
|
"middle_value": "value",
|
||||||
|
"middle_index": "key1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"key2": [
|
||||||
|
["prefixed-f", "prefixed-o", "prefixed-o"],
|
||||||
|
{
|
||||||
|
"outer_value": "foo",
|
||||||
|
"outer_index": 0,
|
||||||
|
"middle_value": 2,
|
||||||
|
"middle_index": "key2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"key1": [
|
||||||
|
["prefixed-b", "prefixed-a", "prefixed-r"],
|
||||||
|
{
|
||||||
|
"outer_value": "bar",
|
||||||
|
"outer_index": 1,
|
||||||
|
"middle_value": "value",
|
||||||
|
"middle_index": "key1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"key2": [
|
||||||
|
["prefixed-b", "prefixed-a", "prefixed-r"],
|
||||||
|
{
|
||||||
|
"outer_value": "bar",
|
||||||
|
"outer_index": 1,
|
||||||
|
"middle_value": 2,
|
||||||
|
"middle_index": "key2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -43,3 +43,28 @@ class TestBlueprintsV1API(APITestCase):
|
|||||||
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_api_blank(self):
|
||||||
|
"""Test blank"""
|
||||||
|
res = self.client.post(
|
||||||
|
reverse("authentik_api:blueprintinstance-list"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_content(self):
|
||||||
|
"""Test blank"""
|
||||||
|
res = self.client.post(
|
||||||
|
reverse("authentik_api:blueprintinstance-list"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
"content": '{"version": 3}',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertJSONEqual(res.content.decode(), {"content": ["Failed to validate blueprint"]})
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
"""transfer common classes"""
|
"""transfer common classes"""
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from copy import copy
|
||||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from operator import ixor
|
from operator import ixor
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Iterable, Literal, Mapping, Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from deepmerge import always_merger
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db.models import Model, Q
|
from django.db.models import Model, Q
|
||||||
from rest_framework.fields import Field
|
from rest_framework.fields import Field
|
||||||
@ -56,17 +58,21 @@ class BlueprintEntryDesiredState(Enum):
|
|||||||
class BlueprintEntry:
|
class BlueprintEntry:
|
||||||
"""Single entry of a blueprint"""
|
"""Single entry of a blueprint"""
|
||||||
|
|
||||||
model: str
|
model: Union[str, "YAMLTag"]
|
||||||
state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT)
|
state: Union[BlueprintEntryDesiredState, "YAMLTag"] = field(
|
||||||
|
default=BlueprintEntryDesiredState.PRESENT
|
||||||
|
)
|
||||||
conditions: list[Any] = field(default_factory=list)
|
conditions: list[Any] = field(default_factory=list)
|
||||||
identifiers: dict[str, Any] = field(default_factory=dict)
|
identifiers: dict[str, Any] = field(default_factory=dict)
|
||||||
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
|
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
|
||||||
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
|
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
|
||||||
|
|
||||||
|
def __post_init__(self, *args, **kwargs) -> None:
|
||||||
|
self.__tag_contexts: list["YAMLTagContext"] = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
|
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
|
||||||
"""Convert a SerializerModel instance to a blueprint Entry"""
|
"""Convert a SerializerModel instance to a blueprint Entry"""
|
||||||
@ -83,17 +89,46 @@ class BlueprintEntry:
|
|||||||
attrs=all_attrs,
|
attrs=all_attrs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_tag_context(
|
||||||
|
self,
|
||||||
|
depth: int = 0,
|
||||||
|
context_tag_type: Optional[type["YAMLTagContext"] | tuple["YAMLTagContext", ...]] = None,
|
||||||
|
) -> "YAMLTagContext":
|
||||||
|
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("depth must be a positive number or zero")
|
||||||
|
|
||||||
|
if context_tag_type:
|
||||||
|
contexts = [x for x in self.__tag_contexts if isinstance(x, context_tag_type)]
|
||||||
|
else:
|
||||||
|
contexts = self.__tag_contexts
|
||||||
|
|
||||||
|
try:
|
||||||
|
return contexts[-(depth + 1)]
|
||||||
|
except IndexError:
|
||||||
|
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}")
|
||||||
|
|
||||||
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
|
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
|
||||||
"""Check if we have any special tags that need handling"""
|
"""Check if we have any special tags that need handling"""
|
||||||
|
val = copy(value)
|
||||||
|
|
||||||
|
if isinstance(value, YAMLTagContext):
|
||||||
|
self.__tag_contexts.append(value)
|
||||||
|
|
||||||
if isinstance(value, YAMLTag):
|
if isinstance(value, YAMLTag):
|
||||||
return value.resolve(self, blueprint)
|
val = value.resolve(self, blueprint)
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for key, inner_value in value.items():
|
for key, inner_value in value.items():
|
||||||
value[key] = self.tag_resolver(inner_value, blueprint)
|
val[key] = self.tag_resolver(inner_value, blueprint)
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
for idx, inner_value in enumerate(value):
|
for idx, inner_value in enumerate(value):
|
||||||
value[idx] = self.tag_resolver(inner_value, blueprint)
|
val[idx] = self.tag_resolver(inner_value, blueprint)
|
||||||
return value
|
|
||||||
|
if isinstance(value, YAMLTagContext):
|
||||||
|
self.__tag_contexts.pop()
|
||||||
|
|
||||||
|
return val
|
||||||
|
|
||||||
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
|
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
|
||||||
"""Get attributes of this entry, with all yaml tags resolved"""
|
"""Get attributes of this entry, with all yaml tags resolved"""
|
||||||
@ -103,6 +138,14 @@ class BlueprintEntry:
|
|||||||
"""Get attributes of this entry, with all yaml tags resolved"""
|
"""Get attributes of this entry, with all yaml tags resolved"""
|
||||||
return self.tag_resolver(self.identifiers, blueprint)
|
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:
|
def check_all_conditions_match(self, blueprint: "Blueprint") -> bool:
|
||||||
"""Check all conditions of this entry match (evaluate to True)"""
|
"""Check all conditions of this entry match (evaluate to True)"""
|
||||||
return all(self.tag_resolver(self.conditions, blueprint))
|
return all(self.tag_resolver(self.conditions, blueprint))
|
||||||
@ -135,12 +178,19 @@ class YAMLTag:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class YAMLTagContext:
|
||||||
|
"""Base class for all YAML Tag Contexts"""
|
||||||
|
|
||||||
|
def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
"""Implement yaml tag context logic"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class KeyOf(YAMLTag):
|
class KeyOf(YAMLTag):
|
||||||
"""Reference another object by their ID"""
|
"""Reference another object by their ID"""
|
||||||
|
|
||||||
id_from: str
|
id_from: str
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.id_from = node.value
|
self.id_from = node.value
|
||||||
@ -167,7 +217,6 @@ class Env(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Optional[Any]
|
default: Optional[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
@ -187,7 +236,6 @@ class Context(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Optional[Any]
|
default: Optional[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
@ -210,7 +258,6 @@ class Format(YAMLTag):
|
|||||||
format_string: str
|
format_string: str
|
||||||
args: list[Any]
|
args: list[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.format_string = node.value[0].value
|
self.format_string = node.value[0].value
|
||||||
@ -235,15 +282,12 @@ class Format(YAMLTag):
|
|||||||
class Find(YAMLTag):
|
class Find(YAMLTag):
|
||||||
"""Find any object"""
|
"""Find any object"""
|
||||||
|
|
||||||
model_name: str
|
model_name: str | YAMLTag
|
||||||
conditions: list[list]
|
conditions: list[list]
|
||||||
|
|
||||||
model_class: type[Model]
|
|
||||||
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.model_name = node.value[0].value
|
self.model_name = loader.construct_object(node.value[0])
|
||||||
self.model_class = apps.get_model(*self.model_name.split("."))
|
|
||||||
self.conditions = []
|
self.conditions = []
|
||||||
for raw_node in node.value[1:]:
|
for raw_node in node.value[1:]:
|
||||||
values = []
|
values = []
|
||||||
@ -252,6 +296,13 @@ class Find(YAMLTag):
|
|||||||
self.conditions.append(values)
|
self.conditions.append(values)
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
if isinstance(self.model_name, YAMLTag):
|
||||||
|
model_name = self.model_name.resolve(entry, blueprint)
|
||||||
|
else:
|
||||||
|
model_name = self.model_name
|
||||||
|
|
||||||
|
model_class = apps.get_model(*model_name.split("."))
|
||||||
|
|
||||||
query = Q()
|
query = Q()
|
||||||
for cond in self.conditions:
|
for cond in self.conditions:
|
||||||
if isinstance(cond[0], YAMLTag):
|
if isinstance(cond[0], YAMLTag):
|
||||||
@ -263,7 +314,7 @@ class Find(YAMLTag):
|
|||||||
else:
|
else:
|
||||||
query_value = cond[1]
|
query_value = cond[1]
|
||||||
query &= Q(**{query_key: query_value})
|
query &= Q(**{query_key: query_value})
|
||||||
instance = self.model_class.objects.filter(query).first()
|
instance = model_class.objects.filter(query).first()
|
||||||
if instance:
|
if instance:
|
||||||
return instance.pk
|
return instance.pk
|
||||||
return None
|
return None
|
||||||
@ -286,7 +337,6 @@ class Condition(YAMLTag):
|
|||||||
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
||||||
}
|
}
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.mode = node.value[0].value
|
self.mode = node.value[0].value
|
||||||
@ -319,7 +369,6 @@ class If(YAMLTag):
|
|||||||
when_true: Any
|
when_true: Any
|
||||||
when_false: Any
|
when_false: Any
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.condition = loader.construct_object(node.value[0])
|
self.condition = loader.construct_object(node.value[0])
|
||||||
@ -341,6 +390,133 @@ class If(YAMLTag):
|
|||||||
raise EntryInvalidError(exc)
|
raise EntryInvalidError(exc)
|
||||||
|
|
||||||
|
|
||||||
|
class Enumerate(YAMLTag, YAMLTagContext):
|
||||||
|
"""Iterate over an iterable."""
|
||||||
|
|
||||||
|
iterable: YAMLTag | Iterable
|
||||||
|
item_body: Any
|
||||||
|
output_body: Literal["SEQ", "MAP"]
|
||||||
|
|
||||||
|
_OUTPUT_BODIES = {
|
||||||
|
"SEQ": (list, lambda a, b: [*a, b]),
|
||||||
|
"MAP": (
|
||||||
|
dict,
|
||||||
|
lambda a, b: always_merger.merge(
|
||||||
|
a, {b[0]: b[1]} if isinstance(b, (tuple, list)) else b
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.iterable = loader.construct_object(node.value[0])
|
||||||
|
self.output_body = node.value[1].value
|
||||||
|
self.item_body = loader.construct_object(node.value[2])
|
||||||
|
self.__current_context: tuple[Any, Any] = tuple()
|
||||||
|
|
||||||
|
def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
return self.__current_context
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0:
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__} tag's iterable references this tag's context. "
|
||||||
|
"This is a noop. Check you are setting depth bigger than 0."
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(self.iterable, YAMLTag):
|
||||||
|
iterable = self.iterable.resolve(entry, blueprint)
|
||||||
|
else:
|
||||||
|
iterable = self.iterable
|
||||||
|
|
||||||
|
if not isinstance(iterable, Iterable):
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__}'s iterable must be an iterable "
|
||||||
|
"such as a sequence or a mapping"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(iterable, Mapping):
|
||||||
|
iterable = tuple(iterable.items())
|
||||||
|
else:
|
||||||
|
iterable = tuple(enumerate(iterable))
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise EntryInvalidError(exc)
|
||||||
|
|
||||||
|
result = output_class()
|
||||||
|
|
||||||
|
self.__current_context = tuple()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in iterable:
|
||||||
|
self.__current_context = item
|
||||||
|
resolved_body = entry.tag_resolver(self.item_body, blueprint)
|
||||||
|
result = add_fn(result, resolved_body)
|
||||||
|
if not isinstance(result, output_class):
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"Invalid {self.__class__.__name__} item found: {resolved_body}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.__current_context = tuple()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class EnumeratedItem(YAMLTag):
|
||||||
|
"""Get the current item value and index provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
depth: int
|
||||||
|
|
||||||
|
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
|
||||||
|
|
||||||
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.depth = int(node.value)
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
try:
|
||||||
|
context_tag: Enumerate = entry._get_tag_context(
|
||||||
|
depth=self.depth,
|
||||||
|
context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
if self.depth == 0:
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__} tags are only usable "
|
||||||
|
f"inside an {Enumerate.__name__} tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise EntryInvalidError(f"{self.__class__.__name__} tag: {exc}")
|
||||||
|
|
||||||
|
return context_tag.get_context(entry, blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
class Index(EnumeratedItem):
|
||||||
|
"""Get the current item index provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
context = super().resolve(entry, blueprint)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return context[0]
|
||||||
|
except IndexError: # pragma: no cover
|
||||||
|
raise EntryInvalidError(f"Empty/invalid context: {context}")
|
||||||
|
|
||||||
|
|
||||||
|
class Value(EnumeratedItem):
|
||||||
|
"""Get the current item value provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
context = super().resolve(entry, blueprint)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return context[1]
|
||||||
|
except IndexError: # pragma: no cover
|
||||||
|
raise EntryInvalidError(f"Empty/invalid context: {context}")
|
||||||
|
|
||||||
|
|
||||||
class BlueprintDumper(SafeDumper):
|
class BlueprintDumper(SafeDumper):
|
||||||
"""Dump dataclasses to yaml"""
|
"""Dump dataclasses to yaml"""
|
||||||
|
|
||||||
@ -384,6 +560,9 @@ class BlueprintLoader(SafeLoader):
|
|||||||
self.add_constructor("!Condition", Condition)
|
self.add_constructor("!Condition", Condition)
|
||||||
self.add_constructor("!If", If)
|
self.add_constructor("!If", If)
|
||||||
self.add_constructor("!Env", Env)
|
self.add_constructor("!Env", Env)
|
||||||
|
self.add_constructor("!Enumerate", Enumerate)
|
||||||
|
self.add_constructor("!Value", Value)
|
||||||
|
self.add_constructor("!Index", Index)
|
||||||
|
|
||||||
|
|
||||||
class EntryInvalidError(SentryIgnoredException):
|
class EntryInvalidError(SentryIgnoredException):
|
||||||
|
|||||||
@ -34,7 +34,7 @@ from authentik.core.models import (
|
|||||||
Source,
|
Source,
|
||||||
UserSourceConnection,
|
UserSourceConnection,
|
||||||
)
|
)
|
||||||
from authentik.flows.models import Stage
|
from authentik.flows.models import FlowToken, Stage
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
from authentik.outposts.models import OutpostServiceConnection
|
from authentik.outposts.models import OutpostServiceConnection
|
||||||
from authentik.policies.models import Policy, PolicyBindingModel
|
from authentik.policies.models import Policy, PolicyBindingModel
|
||||||
@ -60,6 +60,8 @@ def is_model_allowed(model: type[Model]) -> bool:
|
|||||||
PolicyBindingModel,
|
PolicyBindingModel,
|
||||||
# Classes that have other dependencies
|
# Classes that have other dependencies
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
|
# Classes which are only internally managed
|
||||||
|
FlowToken,
|
||||||
)
|
)
|
||||||
return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel))
|
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")
|
self.logger.debug("One or more conditions of this entry are not fulfilled, skipping")
|
||||||
return None
|
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)
|
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
|
||||||
# Don't use isinstance since we don't want to check for inheritance
|
# Don't use isinstance since we don't want to check for inheritance
|
||||||
if not is_model_allowed(model):
|
if not is_model_allowed(model):
|
||||||
@ -184,7 +186,7 @@ class Importer:
|
|||||||
serializer_kwargs = {}
|
serializer_kwargs = {}
|
||||||
model_instance = existing_models.first()
|
model_instance = existing_models.first()
|
||||||
if not isinstance(model(), BaseMetaModel) and model_instance:
|
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")
|
self.logger.debug("instance exists, skipping")
|
||||||
return None
|
return None
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
@ -237,7 +239,7 @@ class Importer:
|
|||||||
"""Apply (create/update) models yaml"""
|
"""Apply (create/update) models yaml"""
|
||||||
self.__pk_map = {}
|
self.__pk_map = {}
|
||||||
for entry in self.__import.entries:
|
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:
|
try:
|
||||||
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
|
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
|
||||||
except LookupError:
|
except LookupError:
|
||||||
@ -254,7 +256,8 @@ class Importer:
|
|||||||
if not serializer:
|
if not serializer:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if entry.state in [
|
state = entry.get_state(self.__import)
|
||||||
|
if state in [
|
||||||
BlueprintEntryDesiredState.PRESENT,
|
BlueprintEntryDesiredState.PRESENT,
|
||||||
BlueprintEntryDesiredState.CREATED,
|
BlueprintEntryDesiredState.CREATED,
|
||||||
]:
|
]:
|
||||||
@ -263,9 +266,9 @@ class Importer:
|
|||||||
self.__pk_map[entry.identifiers["pk"]] = model.pk
|
self.__pk_map[entry.identifiers["pk"]] = model.pk
|
||||||
entry._state = BlueprintEntryState(model)
|
entry._state = BlueprintEntryState(model)
|
||||||
self.logger.debug("updated model", model=model)
|
self.logger.debug("updated model", model=model)
|
||||||
elif entry.state == BlueprintEntryDesiredState.ABSENT:
|
elif state == BlueprintEntryDesiredState.ABSENT:
|
||||||
instance: Optional[Model] = serializer.instance
|
instance: Optional[Model] = serializer.instance
|
||||||
if instance:
|
if instance.pk:
|
||||||
instance.delete()
|
instance.delete()
|
||||||
self.logger.debug("deleted model", mode=instance)
|
self.logger.debug("deleted model", mode=instance)
|
||||||
continue
|
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.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from structlog.stdlib import get_logger
|
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 import load
|
||||||
from yaml.error import YAMLError
|
from yaml.error import YAMLError
|
||||||
|
|
||||||
@ -32,6 +39,7 @@ from authentik.lib.config import CONFIG
|
|||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
_file_watcher_started = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -45,6 +53,39 @@ class BlueprintFile:
|
|||||||
meta: Optional[BlueprintMetadata] = field(default=None)
|
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(
|
@CELERY_APP.task(
|
||||||
throws=(DatabaseError, ProgrammingError, InternalError),
|
throws=(DatabaseError, ProgrammingError, InternalError),
|
||||||
)
|
)
|
||||||
@ -60,8 +101,7 @@ def blueprints_find():
|
|||||||
"""Find blueprints and return valid ones"""
|
"""Find blueprints and return valid ones"""
|
||||||
blueprints = []
|
blueprints = []
|
||||||
root = Path(CONFIG.y("blueprints_dir"))
|
root = Path(CONFIG.y("blueprints_dir"))
|
||||||
for file in root.glob("**/*.yaml"):
|
for path in root.glob("**/*.yaml"):
|
||||||
path = Path(file)
|
|
||||||
LOGGER.debug("found blueprint", path=str(path))
|
LOGGER.debug("found blueprint", path=str(path))
|
||||||
with open(path, "r", encoding="utf-8") as blueprint_file:
|
with open(path, "r", encoding="utf-8") as blueprint_file:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
"""Application API Views"""
|
"""Application API Views"""
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
@ -225,7 +227,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
parser_classes=(MultiPartParser,),
|
parser_classes=(MultiPartParser,),
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_icon(self, request: Request, slug: str):
|
def set_icon(self, request: Request, slug: str):
|
||||||
"""Set application icon"""
|
"""Set application icon"""
|
||||||
app: Application = self.get_object()
|
app: Application = self.get_object()
|
||||||
@ -245,7 +246,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_icon_url(self, request: Request, slug: str):
|
def set_icon_url(self, request: Request, slug: str):
|
||||||
"""Set application icon (as URL)"""
|
"""Set application icon (as URL)"""
|
||||||
app: Application = self.get_object()
|
app: Application = self.get_object()
|
||||||
@ -254,15 +254,14 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
||||||
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def metrics(self, request: Request, slug: str):
|
def metrics(self, request: Request, slug: str):
|
||||||
"""Metrics for application logins"""
|
"""Metrics for application logins"""
|
||||||
app = self.get_object()
|
app = self.get_object()
|
||||||
return Response(
|
return Response(
|
||||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||||
.filter(
|
|
||||||
action=EventAction.AUTHORIZE_APPLICATION,
|
action=EventAction.AUTHORIZE_APPLICATION,
|
||||||
context__authorized_application__pk=app.pk.hex,
|
context__authorized_application__pk=app.pk.hex,
|
||||||
)
|
)
|
||||||
.get_events_per_hour()
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from django.db.models.query import QuerySet
|
|||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
|
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
|
||||||
from django_filters.filterset import FilterSet
|
from django_filters.filterset import FilterSet
|
||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import CharField, IntegerField, JSONField
|
from rest_framework.fields import CharField, IntegerField, JSONField
|
||||||
@ -17,7 +17,7 @@ from rest_framework_guardian.filters import ObjectPermissionsFilter
|
|||||||
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import is_dict
|
from authentik.core.api.utils import PassiveSerializer, is_dict
|
||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
|
|
||||||
|
|
||||||
@ -96,7 +96,6 @@ class GroupFilter(FilterSet):
|
|||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -120,6 +119,12 @@ class GroupFilter(FilterSet):
|
|||||||
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
|
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
|
||||||
|
|
||||||
|
|
||||||
|
class UserAccountSerializer(PassiveSerializer):
|
||||||
|
"""Account adding/removing operations"""
|
||||||
|
|
||||||
|
pk = IntegerField(required=True)
|
||||||
|
|
||||||
|
|
||||||
class GroupViewSet(UsedByMixin, ModelViewSet):
|
class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||||
"""Group Viewset"""
|
"""Group Viewset"""
|
||||||
|
|
||||||
@ -144,19 +149,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
@permission_required(None, ["authentik_core.add_user"])
|
@permission_required(None, ["authentik_core.add_user"])
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=inline_serializer(
|
request=UserAccountSerializer,
|
||||||
"UserAccountSerializer",
|
|
||||||
{
|
|
||||||
"pk": IntegerField(required=True),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
responses={
|
responses={
|
||||||
204: OpenApiResponse(description="User added"),
|
204: OpenApiResponse(description="User added"),
|
||||||
404: OpenApiResponse(description="User not found"),
|
404: OpenApiResponse(description="User not found"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def add_user(self, request: Request, pk: str) -> Response:
|
def add_user(self, request: Request, pk: str) -> Response:
|
||||||
"""Add user to group"""
|
"""Add user to group"""
|
||||||
group: Group = self.get_object()
|
group: Group = self.get_object()
|
||||||
@ -174,19 +173,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
@permission_required(None, ["authentik_core.add_user"])
|
@permission_required(None, ["authentik_core.add_user"])
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=inline_serializer(
|
request=UserAccountSerializer,
|
||||||
"UserAccountSerializer",
|
|
||||||
{
|
|
||||||
"pk": IntegerField(required=True),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
responses={
|
responses={
|
||||||
204: OpenApiResponse(description="User added"),
|
204: OpenApiResponse(description="User added"),
|
||||||
404: OpenApiResponse(description="User not found"),
|
404: OpenApiResponse(description="User not found"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def remove_user(self, request: Request, pk: str) -> Response:
|
def remove_user(self, request: Request, pk: str) -> Response:
|
||||||
"""Add user to group"""
|
"""Add user to group"""
|
||||||
group: Group = self.get_object()
|
group: Group = self.get_object()
|
||||||
|
|||||||
@ -117,7 +117,6 @@ class PropertyMappingViewSet(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def test(self, request: Request, pk: str) -> Response:
|
def test(self, request: Request, pk: str) -> Response:
|
||||||
"""Test Property Mapping"""
|
"""Test Property Mapping"""
|
||||||
mapping: PropertyMapping = self.get_object()
|
mapping: PropertyMapping = self.get_object()
|
||||||
|
|||||||
@ -102,7 +102,6 @@ class SourceViewSet(
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
parser_classes=(MultiPartParser,),
|
parser_classes=(MultiPartParser,),
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_icon(self, request: Request, slug: str):
|
def set_icon(self, request: Request, slug: str):
|
||||||
"""Set source icon"""
|
"""Set source icon"""
|
||||||
source: Source = self.get_object()
|
source: Source = self.get_object()
|
||||||
@ -122,7 +121,6 @@ class SourceViewSet(
|
|||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_icon_url(self, request: Request, slug: str):
|
def set_icon_url(self, request: Request, slug: str):
|
||||||
"""Set source icon (as URL)"""
|
"""Set source icon (as URL)"""
|
||||||
source: Source = self.get_object()
|
source: Source = self.get_object()
|
||||||
|
|||||||
@ -112,7 +112,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def view_key(self, request: Request, identifier: str) -> Response:
|
def view_key(self, request: Request, identifier: str) -> Response:
|
||||||
"""Return token key and log access"""
|
"""Return token key and log access"""
|
||||||
token: Token = self.get_object()
|
token: Token = self.get_object()
|
||||||
@ -134,7 +133,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_key(self, request: Request, identifier: str) -> Response:
|
def set_key(self, request: Request, identifier: str) -> Response:
|
||||||
"""Return token key and log access"""
|
"""Return token key and log access"""
|
||||||
token: Token = self.get_object()
|
token: Token = self.get_object()
|
||||||
|
|||||||
@ -53,7 +53,7 @@ class UsedByMixin:
|
|||||||
responses={200: UsedBySerializer(many=True)},
|
responses={200: UsedBySerializer(many=True)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument, too-many-locals
|
# pylint: disable=too-many-locals
|
||||||
def used_by(self, request: Request, *args, **kwargs) -> Response:
|
def used_by(self, request: Request, *args, **kwargs) -> Response:
|
||||||
"""Get a list of all objects that use this object"""
|
"""Get a list of all objects that use this object"""
|
||||||
# pyright: reportGeneralTypeIssues=false
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from json import loads
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
@ -199,38 +200,44 @@ class SessionUserSerializer(PassiveSerializer):
|
|||||||
class UserMetricsSerializer(PassiveSerializer):
|
class UserMetricsSerializer(PassiveSerializer):
|
||||||
"""User Metrics"""
|
"""User Metrics"""
|
||||||
|
|
||||||
logins_per_1h = SerializerMethodField()
|
logins = SerializerMethodField()
|
||||||
logins_failed_per_1h = SerializerMethodField()
|
logins_failed = SerializerMethodField()
|
||||||
authorizations_per_1h = SerializerMethodField()
|
authorizations = SerializerMethodField()
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_per_1h(self, _):
|
def get_logins(self, _):
|
||||||
"""Get successful logins per hour for the last 24 hours"""
|
"""Get successful logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.LOGIN, user__pk=user.pk)
|
action=EventAction.LOGIN, user__pk=user.pk
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed_per_1h(self, _):
|
def get_logins_failed(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
|
action=EventAction.LOGIN_FAILED, context__username=user.username
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_authorizations_per_1h(self, _):
|
def get_authorizations(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
|
action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -262,7 +269,6 @@ class UsersFilter(FilterSet):
|
|||||||
queryset=Group.objects.all(),
|
queryset=Group.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -397,9 +403,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
||||||
|
|
||||||
@extend_schema(responses={200: SessionUserSerializer(many=False)})
|
@extend_schema(responses={200: SessionUserSerializer(many=False)})
|
||||||
@action(detail=False, pagination_class=None, filter_backends=[])
|
@action(url_path="me", url_name="me", detail=False, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name
|
def user_me(self, request: Request) -> Response:
|
||||||
def me(self, request: Request) -> Response:
|
|
||||||
"""Get information about current user"""
|
"""Get information about current user"""
|
||||||
context = {"request": request}
|
context = {"request": request}
|
||||||
serializer = SessionUserSerializer(
|
serializer = SessionUserSerializer(
|
||||||
@ -427,7 +432,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"])
|
@action(detail=True, methods=["POST"])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def set_password(self, request: Request, pk: int) -> Response:
|
def set_password(self, request: Request, pk: int) -> Response:
|
||||||
"""Set password for user"""
|
"""Set password for user"""
|
||||||
user: User = self.get_object()
|
user: User = self.get_object()
|
||||||
@ -445,7 +449,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
||||||
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def metrics(self, request: Request, pk: int) -> Response:
|
def metrics(self, request: Request, pk: int) -> Response:
|
||||||
"""User metrics per 1h"""
|
"""User metrics per 1h"""
|
||||||
user: User = self.get_object()
|
user: User = self.get_object()
|
||||||
@ -461,7 +464,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def recovery(self, request: Request, pk: int) -> Response:
|
def recovery(self, request: Request, pk: int) -> Response:
|
||||||
"""Create a temporary link that a user can use to recover their accounts"""
|
"""Create a temporary link that a user can use to recover their accounts"""
|
||||||
link, _ = self._create_recovery_link()
|
link, _ = self._create_recovery_link()
|
||||||
@ -486,7 +488,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def recovery_email(self, request: Request, pk: int) -> Response:
|
def recovery_email(self, request: Request, pk: int) -> Response:
|
||||||
"""Create a temporary link that a user can use to recover their accounts"""
|
"""Create a temporary link that a user can use to recover their accounts"""
|
||||||
for_user: User = self.get_object()
|
for_user: User = self.get_object()
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"""Property Mapping Evaluator"""
|
"""Property Mapping Evaluator"""
|
||||||
from traceback import format_tb
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
@ -8,6 +7,7 @@ from django.http import HttpRequest
|
|||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||||
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
from authentik.policies.types import PolicyRequest
|
from authentik.policies.types import PolicyRequest
|
||||||
|
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ class PropertyMappingEvaluator(BaseEvaluator):
|
|||||||
|
|
||||||
def handle_error(self, exc: Exception, expression_source: str):
|
def handle_error(self, exc: Exception, expression_source: str):
|
||||||
"""Exception Handler"""
|
"""Exception Handler"""
|
||||||
error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
|
error_string = exception_to_string(exc)
|
||||||
event = Event.new(
|
event = Event.new(
|
||||||
EventAction.PROPERTY_MAPPING_EXCEPTION,
|
EventAction.PROPERTY_MAPPING_EXCEPTION,
|
||||||
expression=expression_source,
|
expression=expression_source,
|
||||||
|
|||||||
@ -49,7 +49,6 @@ class Command(BaseCommand):
|
|||||||
return namespace
|
return namespace
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_handler(sender, instance: Model, created: bool, **_):
|
def post_save_handler(sender, instance: Model, created: bool, **_):
|
||||||
"""Signal handler for all object's post_save"""
|
"""Signal handler for all object's post_save"""
|
||||||
if not should_log_model(instance):
|
if not should_log_model(instance):
|
||||||
@ -65,7 +64,6 @@ class Command(BaseCommand):
|
|||||||
).save()
|
).save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_delete_handler(sender, instance: Model, **_):
|
def pre_delete_handler(sender, instance: Model, **_):
|
||||||
"""Signal handler for all object's pre_delete"""
|
"""Signal handler for all object's pre_delete"""
|
||||||
if not should_log_model(instance): # pragma: no cover
|
if not should_log_model(instance): # pragma: no cover
|
||||||
|
|||||||
@ -20,7 +20,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||||
"""Clear user's application cache upon application creation"""
|
"""Clear user's application cache upon application creation"""
|
||||||
from authentik.core.api.applications import user_app_cache_key
|
from authentik.core.api.applications import user_app_cache_key
|
||||||
@ -36,7 +35,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
||||||
"""Create an AuthenticatedSession from request"""
|
"""Create an AuthenticatedSession from request"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
@ -47,7 +45,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
||||||
"""Delete AuthenticatedSession if it exists"""
|
"""Delete AuthenticatedSession if it exists"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
|
|||||||
@ -48,7 +48,6 @@ class Action(Enum):
|
|||||||
class MessageStage(StageView):
|
class MessageStage(StageView):
|
||||||
"""Show a pre-configured message after the flow is done"""
|
"""Show a pre-configured message after the flow is done"""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Show a pre-configured message after the flow is done"""
|
"""Show a pre-configured message after the flow is done"""
|
||||||
message = getattr(self.executor.current_stage, "message", "")
|
message = getattr(self.executor.current_stage, "message", "")
|
||||||
@ -209,7 +208,6 @@ class SourceFlowManager:
|
|||||||
response.error_message = error.messages
|
response.error_message = error.messages
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||||
"""Hook to override stages which are appended to the flow"""
|
"""Hook to override stages which are appended to the flow"""
|
||||||
if not self.source.enrollment_flow:
|
if not self.source.enrollment_flow:
|
||||||
@ -264,7 +262,6 @@ class SourceFlowManager:
|
|||||||
flow_slug=flow.slug,
|
flow_slug=flow.slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_auth(
|
def handle_auth(
|
||||||
self,
|
self,
|
||||||
connection: UserSourceConnection,
|
connection: UserSourceConnection,
|
||||||
|
|||||||
@ -13,7 +13,6 @@ class PostUserEnrollmentStage(StageView):
|
|||||||
"""Dynamically injected stage which saves the Connection after
|
"""Dynamically injected stage which saves the Connection after
|
||||||
the user has been enrolled."""
|
the user has been enrolled."""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Stage used after the user has been enrolled"""
|
"""Stage used after the user has been enrolled"""
|
||||||
connection: UserSourceConnection = self.executor.plan.context[
|
connection: UserSourceConnection = self.executor.plan.context[
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}">
|
<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/empty-state.css' %}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.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 %}
|
{% block head_before %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||||
|
|||||||
@ -60,7 +60,7 @@
|
|||||||
<div class="ak-login-container">
|
<div class="ak-login-container">
|
||||||
<header class="pf-c-login__header">
|
<header class="pf-c-login__header">
|
||||||
<div class="pf-c-brand ak-brand">
|
<div class="pf-c-brand ak-brand">
|
||||||
<img src="{{ tenant.branding_logo }}" alt="authentik icon" />
|
<img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{% block main_container %}
|
{% block main_container %}
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def source_tester_factory(test_model: type[Stage]) -> Callable:
|
|||||||
|
|
||||||
def tester(self: TestModels):
|
def tester(self: TestModels):
|
||||||
model_class = None
|
model_class = None
|
||||||
if test_model._meta.abstract:
|
if test_model._meta.abstract: # pragma: no cover
|
||||||
model_class = test_model.__bases__[0]()
|
model_class = test_model.__bases__[0]()
|
||||||
else:
|
else:
|
||||||
model_class = test_model()
|
model_class = test_model()
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""Test Source flow_manager"""
|
"""Test Source flow_manager"""
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.client import RequestFactory
|
|
||||||
from guardian.utils import get_anonymous_user
|
from guardian.utils import get_anonymous_user
|
||||||
|
|
||||||
from authentik.core.models import SourceUserMatchingModes, User
|
from authentik.core.models import SourceUserMatchingModes, User
|
||||||
@ -22,7 +21,6 @@ class TestSourceFlowManager(TestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.source: OAuthSource = OAuthSource.objects.create(name="test")
|
self.source: OAuthSource = OAuthSource.objects.create(name="test")
|
||||||
self.factory = RequestFactory()
|
|
||||||
self.identifier = generate_id()
|
self.identifier = generate_id()
|
||||||
|
|
||||||
def test_unauthenticated_enroll(self):
|
def test_unauthenticated_enroll(self):
|
||||||
|
|||||||
@ -47,11 +47,11 @@ def create_test_tenant() -> Tenant:
|
|||||||
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
||||||
"""Generate a certificate for testing"""
|
"""Generate a certificate for testing"""
|
||||||
builder = CertificateBuilder(
|
builder = CertificateBuilder(
|
||||||
|
name=f"{generate_id()}.self-signed.goauthentik.io",
|
||||||
use_ec_private_key=use_ec_private_key,
|
use_ec_private_key=use_ec_private_key,
|
||||||
)
|
)
|
||||||
builder.common_name = "goauthentik.io"
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
builder.common_name = generate_id()
|
builder.common_name = generate_id()
|
||||||
|
|||||||
@ -15,12 +15,14 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche
|
|||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import CharField, DateTimeField, IntegerField, SerializerMethodField
|
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.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.api.authorization import SecretKeyFilter
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
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"
|
label="Only return certificate-key pairs with keys", method="filter_has_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
||||||
"""Only return certificate-key pairs with keys"""
|
"""Only return certificate-key pairs with keys"""
|
||||||
return queryset.exclude(key_data__exact="")
|
return queryset.exclude(key_data__exact="")
|
||||||
@ -203,9 +204,17 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
filterset_class = CertificateKeyPairFilter
|
filterset_class = CertificateKeyPairFilter
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
|
filter_backends = [SecretKeyFilter, OrderingFilter, SearchFilter]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
parameters=[
|
parameters=[
|
||||||
|
# Override the type for `has_key` above
|
||||||
|
OpenApiParameter(
|
||||||
|
"has_key",
|
||||||
|
bool,
|
||||||
|
required=False,
|
||||||
|
description="Only return certificate-key pairs with keys",
|
||||||
|
),
|
||||||
OpenApiParameter("include_details", bool, default=True),
|
OpenApiParameter("include_details", bool, default=True),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -226,8 +235,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
data = CertificateGenerationSerializer(data=request.data)
|
data = CertificateGenerationSerializer(data=request.data)
|
||||||
if not data.is_valid():
|
if not data.is_valid():
|
||||||
return Response(data.errors, status=400)
|
return Response(data.errors, status=400)
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder(data.validated_data["common_name"])
|
||||||
builder.common_name = data.validated_data["common_name"]
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","),
|
subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","),
|
||||||
validity_days=int(data.validated_data["validity_days"]),
|
validity_days=int(data.validated_data["validity_days"]),
|
||||||
@ -247,7 +255,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
responses={200: CertificateDataSerializer(many=False)},
|
responses={200: CertificateDataSerializer(many=False)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def view_certificate(self, request: Request, pk: str) -> Response:
|
def view_certificate(self, request: Request, pk: str) -> Response:
|
||||||
"""Return certificate-key pairs certificate and log access"""
|
"""Return certificate-key pairs certificate and log access"""
|
||||||
certificate: CertificateKeyPair = self.get_object()
|
certificate: CertificateKeyPair = self.get_object()
|
||||||
@ -278,7 +285,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
responses={200: CertificateDataSerializer(many=False)},
|
responses={200: CertificateDataSerializer(many=False)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def view_private_key(self, request: Request, pk: str) -> Response:
|
def view_private_key(self, request: Request, pk: str) -> Response:
|
||||||
"""Return certificate-key pairs private key and log access"""
|
"""Return certificate-key pairs private key and log access"""
|
||||||
certificate: CertificateKeyPair = self.get_object()
|
certificate: CertificateKeyPair = self.get_object()
|
||||||
|
|||||||
@ -27,20 +27,16 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder("authentik Internal JWT Certificate")
|
||||||
builder.common_name = "goauthentik.io"
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=["goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
if not cert:
|
if not cert:
|
||||||
|
|
||||||
cert = CertificateKeyPair()
|
cert = CertificateKeyPair()
|
||||||
cert.certificate_data = builder.certificate
|
builder.cert = cert
|
||||||
cert.key_data = builder.private_key
|
builder.cert.managed = MANAGED_KEY
|
||||||
cert.name = "authentik Internal JWT Certificate"
|
builder.save()
|
||||||
cert.managed = MANAGED_KEY
|
|
||||||
cert.save()
|
|
||||||
|
|
||||||
def reconcile_managed_jwt_cert(self):
|
def reconcile_managed_jwt_cert(self):
|
||||||
"""Ensure managed JWT certificate"""
|
"""Ensure managed JWT certificate"""
|
||||||
@ -63,10 +59,6 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
name = "authentik Self-signed Certificate"
|
name = "authentik Self-signed Certificate"
|
||||||
if CertificateKeyPair.objects.filter(name=name).exists():
|
if CertificateKeyPair.objects.filter(name=name).exists():
|
||||||
return
|
return
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder(name)
|
||||||
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||||
CertificateKeyPair.objects.create(
|
builder.save()
|
||||||
name="authentik Self-signed Certificate",
|
|
||||||
certificate_data=builder.certificate,
|
|
||||||
key_data=builder.private_key,
|
|
||||||
)
|
|
||||||
|
|||||||
@ -21,13 +21,13 @@ class CertificateBuilder:
|
|||||||
|
|
||||||
_use_ec_private_key: bool
|
_use_ec_private_key: bool
|
||||||
|
|
||||||
def __init__(self, use_ec_private_key=False):
|
def __init__(self, name: str, use_ec_private_key=False):
|
||||||
self._use_ec_private_key = use_ec_private_key
|
self._use_ec_private_key = use_ec_private_key
|
||||||
self.__public_key = None
|
self.__public_key = None
|
||||||
self.__private_key = None
|
self.__private_key = None
|
||||||
self.__builder = None
|
self.__builder = None
|
||||||
self.__certificate = None
|
self.__certificate = None
|
||||||
self.common_name = "authentik Self-signed Certificate"
|
self.common_name = name
|
||||||
self.cert = CertificateKeyPair()
|
self.cert = CertificateKeyPair()
|
||||||
|
|
||||||
def save(self) -> CertificateKeyPair:
|
def save(self) -> CertificateKeyPair:
|
||||||
|
|||||||
@ -14,7 +14,7 @@ from authentik.crypto.builder import CertificateBuilder
|
|||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.generators import generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.providers.oauth2.models import OAuth2Provider
|
from authentik.providers.oauth2.models import OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
@ -54,8 +54,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_builder(self):
|
def test_builder(self):
|
||||||
"""Test Builder"""
|
"""Test Builder"""
|
||||||
builder = CertificateBuilder()
|
name = generate_id()
|
||||||
builder.common_name = "test-cert"
|
builder = CertificateBuilder(name)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
builder.save()
|
builder.save()
|
||||||
builder.build(
|
builder.build(
|
||||||
@ -64,7 +64,7 @@ class TestCrypto(APITestCase):
|
|||||||
)
|
)
|
||||||
instance = builder.save()
|
instance = builder.save()
|
||||||
now = datetime.datetime.today()
|
now = datetime.datetime.today()
|
||||||
self.assertEqual(instance.name, "test-cert")
|
self.assertEqual(instance.name, name)
|
||||||
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
|
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
|
||||||
|
|
||||||
def test_builder_api(self):
|
def test_builder_api(self):
|
||||||
@ -193,8 +193,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_discovery(self):
|
def test_discovery(self):
|
||||||
"""Test certificate discovery"""
|
"""Test certificate discovery"""
|
||||||
builder = CertificateBuilder()
|
name = generate_id()
|
||||||
builder.common_name = "test-cert"
|
builder = CertificateBuilder(name)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
builder.save()
|
builder.save()
|
||||||
builder.build(
|
builder.build(
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
"""Events API Views"""
|
"""Events API Views"""
|
||||||
|
from datetime import timedelta
|
||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
from django.db.models.aggregates import Count
|
from django.db.models.aggregates import Count
|
||||||
from django.db.models.fields.json import KeyTextTransform
|
from django.db.models.fields.json import KeyTextTransform
|
||||||
|
from django.db.models.functions import ExtractDay
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
@ -81,7 +83,6 @@ class EventsFilter(django_filters.FilterSet):
|
|||||||
label="Tenant name",
|
label="Tenant name",
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_context_model_pk(self, queryset, name, value):
|
def filter_context_model_pk(self, queryset, name, value):
|
||||||
"""Because we store the PK as UUID.hex,
|
"""Because we store the PK as UUID.hex,
|
||||||
we need to remove the dashes that a client may send. We can't use a
|
we need to remove the dashes that a client may send. We can't use a
|
||||||
@ -178,7 +179,7 @@ class EventViewSet(ModelViewSet):
|
|||||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||||
.filter(action=filtered_action)
|
.filter(action=filtered_action)
|
||||||
.filter(**query)
|
.filter(**query)
|
||||||
.get_events_per_day()
|
.get_events_per(timedelta(weeks=4), ExtractDay, 30)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
||||||
|
|||||||
@ -80,7 +80,6 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
|||||||
request=OpenApiTypes.NONE,
|
request=OpenApiTypes.NONE,
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def test(self, request: Request, pk=None) -> Response:
|
def test(self, request: Request, pk=None) -> Response:
|
||||||
"""Send example notification using selected transport. Requires
|
"""Send example notification using selected transport. Requires
|
||||||
Modify permissions."""
|
Modify permissions."""
|
||||||
|
|||||||
@ -12,12 +12,21 @@ from django.http import HttpRequest, HttpResponse
|
|||||||
from django_otp.plugins.otp_static.models import StaticToken
|
from django_otp.plugins.otp_static.models import StaticToken
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession, User
|
from authentik.core.models import (
|
||||||
|
AuthenticatedSession,
|
||||||
|
PropertyMapping,
|
||||||
|
Provider,
|
||||||
|
Source,
|
||||||
|
User,
|
||||||
|
UserSourceConnection,
|
||||||
|
)
|
||||||
from authentik.events.models import Event, EventAction, Notification
|
from authentik.events.models import Event, EventAction, Notification
|
||||||
from authentik.events.utils import model_to_dict
|
from authentik.events.utils import model_to_dict
|
||||||
from authentik.flows.models import FlowToken
|
from authentik.flows.models import FlowToken, Stage
|
||||||
from authentik.lib.sentry import before_send
|
from authentik.lib.sentry import before_send
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
|
from authentik.outposts.models import OutpostServiceConnection
|
||||||
|
from authentik.policies.models import Policy, PolicyBindingModel
|
||||||
|
|
||||||
IGNORED_MODELS = (
|
IGNORED_MODELS = (
|
||||||
Event,
|
Event,
|
||||||
@ -27,6 +36,14 @@ IGNORED_MODELS = (
|
|||||||
StaticToken,
|
StaticToken,
|
||||||
Session,
|
Session,
|
||||||
FlowToken,
|
FlowToken,
|
||||||
|
Provider,
|
||||||
|
Source,
|
||||||
|
PropertyMapping,
|
||||||
|
UserSourceConnection,
|
||||||
|
Stage,
|
||||||
|
OutpostServiceConnection,
|
||||||
|
Policy,
|
||||||
|
PolicyBindingModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -34,7 +51,7 @@ def should_log_model(model: Model) -> bool:
|
|||||||
"""Return true if operation on `model` should be logged"""
|
"""Return true if operation on `model` should be logged"""
|
||||||
if model.__module__.startswith("silk"):
|
if model.__module__.startswith("silk"):
|
||||||
return False
|
return False
|
||||||
return not isinstance(model, IGNORED_MODELS)
|
return model.__class__ not in IGNORED_MODELS
|
||||||
|
|
||||||
|
|
||||||
class EventNewThread(Thread):
|
class EventNewThread(Thread):
|
||||||
@ -101,7 +118,6 @@ class AuditMiddleware:
|
|||||||
self.disconnect(request)
|
self.disconnect(request)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def process_exception(self, request: HttpRequest, exception: Exception):
|
def process_exception(self, request: HttpRequest, exception: Exception):
|
||||||
"""Disconnect handlers in case of exception"""
|
"""Disconnect handlers in case of exception"""
|
||||||
self.disconnect(request)
|
self.disconnect(request)
|
||||||
@ -125,7 +141,6 @@ class AuditMiddleware:
|
|||||||
thread.run()
|
thread.run()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_handler(
|
def post_save_handler(
|
||||||
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||||
):
|
):
|
||||||
@ -137,7 +152,6 @@ class AuditMiddleware:
|
|||||||
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||||
"""Signal handler for all object's pre_delete"""
|
"""Signal handler for all object's pre_delete"""
|
||||||
if not should_log_model(instance): # pragma: no cover
|
if not should_log_model(instance): # pragma: no cover
|
||||||
|
|||||||
@ -11,8 +11,7 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, ExpressionWrapper, F
|
from django.db.models import Count, ExpressionWrapper, F
|
||||||
from django.db.models.fields import DurationField
|
from django.db.models.fields import DurationField
|
||||||
from django.db.models.functions import ExtractHour
|
from django.db.models.functions import Extract
|
||||||
from django.db.models.functions.datetime import ExtractDay
|
|
||||||
from django.db.models.manager import Manager
|
from django.db.models.manager import Manager
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@ -111,48 +110,35 @@ class EventAction(models.TextChoices):
|
|||||||
class EventQuerySet(QuerySet):
|
class EventQuerySet(QuerySet):
|
||||||
"""Custom events query set with helper functions"""
|
"""Custom events query set with helper functions"""
|
||||||
|
|
||||||
def get_events_per_hour(self) -> list[dict[str, int]]:
|
def get_events_per(
|
||||||
|
self,
|
||||||
|
time_since: timedelta,
|
||||||
|
extract: Extract,
|
||||||
|
data_points: int,
|
||||||
|
) -> list[dict[str, int]]:
|
||||||
"""Get event count by hour in the last day, fill with zeros"""
|
"""Get event count by hour in the last day, fill with zeros"""
|
||||||
date_from = now() - timedelta(days=1)
|
_now = now()
|
||||||
|
max_since = timedelta(days=60)
|
||||||
|
# Allow maximum of 60 days to limit load
|
||||||
|
if time_since.total_seconds() > max_since.total_seconds():
|
||||||
|
time_since = max_since
|
||||||
|
date_from = _now - time_since
|
||||||
result = (
|
result = (
|
||||||
self.filter(created__gte=date_from)
|
self.filter(created__gte=date_from)
|
||||||
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
.annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
|
||||||
.annotate(age_hours=ExtractHour("age"))
|
.annotate(age_interval=extract("age"))
|
||||||
.values("age_hours")
|
.values("age_interval")
|
||||||
.annotate(count=Count("pk"))
|
.annotate(count=Count("pk"))
|
||||||
.order_by("age_hours")
|
.order_by("age_interval")
|
||||||
)
|
)
|
||||||
data = Counter({int(d["age_hours"]): d["count"] for d in result})
|
data = Counter({int(d["age_interval"]): d["count"] for d in result})
|
||||||
results = []
|
results = []
|
||||||
_now = now()
|
interval_delta = time_since / data_points
|
||||||
for hour in range(0, -24, -1):
|
for interval in range(1, -data_points, -1):
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
|
"x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
|
||||||
"y_cord": data[hour * -1],
|
"y_cord": data[interval * -1],
|
||||||
}
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_events_per_day(self) -> list[dict[str, int]]:
|
|
||||||
"""Get event count by hour in the last day, fill with zeros"""
|
|
||||||
date_from = now() - timedelta(weeks=4)
|
|
||||||
result = (
|
|
||||||
self.filter(created__gte=date_from)
|
|
||||||
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
|
||||||
.annotate(age_days=ExtractDay("age"))
|
|
||||||
.values("age_days")
|
|
||||||
.annotate(count=Count("pk"))
|
|
||||||
.order_by("age_days")
|
|
||||||
)
|
|
||||||
data = Counter({int(d["age_days"]): d["count"] for d in result})
|
|
||||||
results = []
|
|
||||||
_now = now()
|
|
||||||
for day in range(0, -30, -1):
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000,
|
|
||||||
"y_cord": data[day * -1],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
@ -165,13 +151,14 @@ class EventManager(Manager):
|
|||||||
"""use custom queryset"""
|
"""use custom queryset"""
|
||||||
return EventQuerySet(self.model, using=self._db)
|
return EventQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
def get_events_per_hour(self) -> list[dict[str, int]]:
|
def get_events_per(
|
||||||
|
self,
|
||||||
|
time_since: timedelta,
|
||||||
|
extract: Extract,
|
||||||
|
data_points: int,
|
||||||
|
) -> list[dict[str, int]]:
|
||||||
"""Wrap method from queryset"""
|
"""Wrap method from queryset"""
|
||||||
return self.get_queryset().get_events_per_hour()
|
return self.get_queryset().get_events_per(time_since, extract, data_points)
|
||||||
|
|
||||||
def get_events_per_day(self) -> list[dict[str, int]]:
|
|
||||||
"""Wrap method from queryset"""
|
|
||||||
return self.get_queryset().get_events_per_day()
|
|
||||||
|
|
||||||
|
|
||||||
class Event(SerializerModel, ExpiringModel):
|
class Event(SerializerModel, ExpiringModel):
|
||||||
|
|||||||
@ -102,7 +102,7 @@ class TaskInfo:
|
|||||||
key = CACHE_KEY_PREFIX + self.task_name
|
key = CACHE_KEY_PREFIX + self.task_name
|
||||||
if self.result.uid:
|
if self.result.uid:
|
||||||
key += f"/{self.result.uid}"
|
key += f"/{self.result.uid}"
|
||||||
self.task_name += f"_{self.result.uid}"
|
self.task_name += f"/{self.result.uid}"
|
||||||
self.set_prom_metrics()
|
self.set_prom_metrics()
|
||||||
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@ SESSION_LOGIN_EVENT = "login_event"
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
||||||
"""Log successful login"""
|
"""Log successful login"""
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
@ -39,15 +38,18 @@ def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
|||||||
request.session[SESSION_LOGIN_EVENT] = event
|
request.session[SESSION_LOGIN_EVENT] = event
|
||||||
|
|
||||||
|
|
||||||
|
def get_login_event(request: HttpRequest) -> Optional[Event]:
|
||||||
|
"""Wrapper to get login event that can be mocked in tests"""
|
||||||
|
return request.session.get(SESSION_LOGIN_EVENT, None)
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||||
"""Log successfully logout"""
|
"""Log successfully logout"""
|
||||||
Event.new(EventAction.LOGOUT).from_http(request, user=user)
|
Event.new(EventAction.LOGOUT).from_http(request, user=user)
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_write)
|
@receiver(user_write)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs):
|
def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs):
|
||||||
"""Log User write"""
|
"""Log User write"""
|
||||||
data["created"] = kwargs.get("created", False)
|
data["created"] = kwargs.get("created", False)
|
||||||
@ -55,7 +57,6 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
@receiver(login_failed)
|
@receiver(login_failed)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_login_failed(
|
def on_login_failed(
|
||||||
signal,
|
signal,
|
||||||
sender,
|
sender,
|
||||||
@ -69,7 +70,6 @@ def on_login_failed(
|
|||||||
|
|
||||||
|
|
||||||
@receiver(invitation_used)
|
@receiver(invitation_used)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
|
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
|
||||||
"""Log Invitation usage"""
|
"""Log Invitation usage"""
|
||||||
Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http(
|
Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http(
|
||||||
@ -78,21 +78,18 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
|
|||||||
|
|
||||||
|
|
||||||
@receiver(password_changed)
|
@receiver(password_changed)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_password_changed(sender, user: User, password: str, **_):
|
def on_password_changed(sender, user: User, password: str, **_):
|
||||||
"""Log password change"""
|
"""Log password change"""
|
||||||
Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
|
Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Event)
|
@receiver(post_save, sender=Event)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def event_post_save_notification(sender, instance: Event, **_):
|
def event_post_save_notification(sender, instance: Event, **_):
|
||||||
"""Start task to check if any policies trigger an notification on this event"""
|
"""Start task to check if any policies trigger an notification on this event"""
|
||||||
event_notification_handler.delay(instance.event_uuid.hex)
|
event_notification_handler.delay(instance.event_uuid.hex)
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=User)
|
@receiver(pre_delete, sender=User)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def event_user_pre_delete_cleanup(sender, instance: User, **_):
|
def event_user_pre_delete_cleanup(sender, instance: User, **_):
|
||||||
"""If gdpr_compliance is enabled, remove all the user's events"""
|
"""If gdpr_compliance is enabled, remove all the user's events"""
|
||||||
gdpr_cleanup.delay(instance.pk)
|
gdpr_cleanup.delay(instance.pk)
|
||||||
|
|||||||
@ -210,7 +210,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def export(self, request: Request, slug: str) -> Response:
|
def export(self, request: Request, slug: str) -> Response:
|
||||||
"""Export flow to .yaml file"""
|
"""Export flow to .yaml file"""
|
||||||
flow = self.get_object()
|
flow = self.get_object()
|
||||||
@ -221,7 +220,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
@extend_schema(responses={200: FlowDiagramSerializer()})
|
@extend_schema(responses={200: FlowDiagramSerializer()})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def diagram(self, request: Request, slug: str) -> Response:
|
def diagram(self, request: Request, slug: str) -> Response:
|
||||||
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
|
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
|
||||||
diagram = FlowDiagram(self.get_object(), request.user)
|
diagram = FlowDiagram(self.get_object(), request.user)
|
||||||
@ -245,7 +243,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
parser_classes=(MultiPartParser,),
|
parser_classes=(MultiPartParser,),
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_background(self, request: Request, slug: str):
|
def set_background(self, request: Request, slug: str):
|
||||||
"""Set Flow background"""
|
"""Set Flow background"""
|
||||||
flow: Flow = self.get_object()
|
flow: Flow = self.get_object()
|
||||||
@ -265,7 +262,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_background_url(self, request: Request, slug: str):
|
def set_background_url(self, request: Request, slug: str):
|
||||||
"""Set Flow background (as URL)"""
|
"""Set Flow background (as URL)"""
|
||||||
flow: Flow = self.get_object()
|
flow: Flow = self.get_object()
|
||||||
@ -278,7 +274,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def execute(self, request: Request, slug: str):
|
def execute(self, request: Request, slug: str):
|
||||||
"""Execute flow for current user"""
|
"""Execute flow for current user"""
|
||||||
# Because we pre-plan the flow here, and not in the planner, we need to manually clear
|
# Because we pre-plan the flow here, and not in the planner, we need to manually clear
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""Challenge helpers"""
|
"""Challenge helpers"""
|
||||||
from dataclasses import asdict, is_dataclass
|
from dataclasses import asdict, is_dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from traceback import format_tb
|
|
||||||
from typing import TYPE_CHECKING, Optional, TypedDict
|
from typing import TYPE_CHECKING, Optional, TypedDict
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@ -9,8 +8,10 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from rest_framework.fields import CharField, ChoiceField, DictField
|
from rest_framework.fields import CharField, ChoiceField, DictField
|
||||||
|
from rest_framework.request import Request
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from authentik.flows.stage import StageView
|
from authentik.flows.stage import StageView
|
||||||
@ -90,32 +91,31 @@ class WithUserInfoChallenge(Challenge):
|
|||||||
pending_user_avatar = CharField()
|
pending_user_avatar = CharField()
|
||||||
|
|
||||||
|
|
||||||
class FlowErrorChallenge(WithUserInfoChallenge):
|
class FlowErrorChallenge(Challenge):
|
||||||
"""Challenge class when an unhandled error occurs during a stage. Normal users
|
"""Challenge class when an unhandled error occurs during a stage. Normal users
|
||||||
are shown an error message, superusers are shown a full stacktrace."""
|
are shown an error message, superusers are shown a full stacktrace."""
|
||||||
|
|
||||||
component = CharField(default="xak-flow-error")
|
type = CharField(default=ChallengeTypes.NATIVE.value)
|
||||||
|
component = CharField(default="ak-stage-flow-error")
|
||||||
|
|
||||||
request_id = CharField()
|
request_id = CharField()
|
||||||
|
|
||||||
error = CharField(required=False)
|
error = CharField(required=False)
|
||||||
traceback = CharField(required=False)
|
traceback = CharField(required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, request: Optional[Request] = None, error: Optional[Exception] = None):
|
||||||
request = kwargs.pop("request", None)
|
super().__init__(data={})
|
||||||
error = kwargs.pop("error", None)
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
if not request or not error:
|
if not request or not error:
|
||||||
return
|
return
|
||||||
self.request_id = request.request_id
|
self.initial_data["request_id"] = request.request_id
|
||||||
from authentik.core.models import USER_ATTRIBUTE_DEBUG
|
from authentik.core.models import USER_ATTRIBUTE_DEBUG
|
||||||
|
|
||||||
if request.user and request.user.is_authenticated:
|
if request.user and request.user.is_authenticated:
|
||||||
if request.user.is_superuser or request.user.group_attributes(request).get(
|
if request.user.is_superuser or request.user.group_attributes(request).get(
|
||||||
USER_ATTRIBUTE_DEBUG, False
|
USER_ATTRIBUTE_DEBUG, False
|
||||||
):
|
):
|
||||||
self.error = error
|
self.initial_data["error"] = str(error)
|
||||||
self.traceback = "".join(format_tb(self.error.__traceback__))
|
self.initial_data["traceback"] = exception_to_string(error)
|
||||||
|
|
||||||
|
|
||||||
class AccessDeniedChallenge(WithUserInfoChallenge):
|
class AccessDeniedChallenge(WithUserInfoChallenge):
|
||||||
|
|||||||
@ -19,7 +19,6 @@ LOGGER = get_logger()
|
|||||||
class StageMarker:
|
class StageMarker:
|
||||||
"""Base stage marker class, no extra attributes, and has no special handler."""
|
"""Base stage marker class, no extra attributes, and has no special handler."""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def process(
|
def process(
|
||||||
self,
|
self,
|
||||||
plan: "FlowPlan",
|
plan: "FlowPlan",
|
||||||
|
|||||||
@ -19,7 +19,6 @@ def delete_cache_prefix(prefix: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_flows(sender, **kwargs):
|
def monitoring_set_flows(sender, **kwargs):
|
||||||
"""set flow gauges"""
|
"""set flow gauges"""
|
||||||
GAUGE_FLOWS_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or []))
|
GAUGE_FLOWS_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or []))
|
||||||
@ -27,7 +26,6 @@ def monitoring_set_flows(sender, **kwargs):
|
|||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
@receiver(pre_delete)
|
@receiver(pre_delete)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def invalidate_flow_cache(sender, instance, **_):
|
def invalidate_flow_cache(sender, instance, **_):
|
||||||
"""Invalidate flow cache when flow is updated"""
|
"""Invalidate flow cache when flow is updated"""
|
||||||
from authentik.flows.models import Flow, FlowStageBinding, Stage
|
from authentik.flows.models import Flow, FlowStageBinding, Stage
|
||||||
|
|||||||
@ -91,7 +91,6 @@ class ChallengeStageView(StageView):
|
|||||||
)
|
)
|
||||||
return HttpChallengeResponse(challenge)
|
return HttpChallengeResponse(challenge)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post(self, request: Request, *args, **kwargs) -> HttpResponse:
|
def post(self, request: Request, *args, **kwargs) -> HttpResponse:
|
||||||
"""Handle challenge response"""
|
"""Handle challenge response"""
|
||||||
challenge: ChallengeResponse = self.get_response_instance(data=request.data)
|
challenge: ChallengeResponse = self.get_response_instance(data=request.data)
|
||||||
|
|||||||
@ -113,7 +113,7 @@ class InvalidStageError(SentryIgnoredException):
|
|||||||
|
|
||||||
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
||||||
class FlowExecutorView(APIView):
|
class FlowExecutorView(APIView):
|
||||||
"""Stage 1 Flow executor, passing requests to Stage Views"""
|
"""Flow executor, passing requests to Stage Views"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
@ -166,7 +166,7 @@ class FlowExecutorView(APIView):
|
|||||||
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
# pylint: disable=unused-argument, too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
|
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
|
||||||
with Hub.current.start_span(
|
with Hub.current.start_span(
|
||||||
op="authentik.flow.executor.dispatch", description=self.flow.slug
|
op="authentik.flow.executor.dispatch", description=self.flow.slug
|
||||||
@ -255,7 +255,7 @@ class FlowExecutorView(APIView):
|
|||||||
message=exception_to_string(exc),
|
message=exception_to_string(exc),
|
||||||
).from_http(self.request)
|
).from_http(self.request)
|
||||||
challenge = FlowErrorChallenge(self.request, exc)
|
challenge = FlowErrorChallenge(self.request, exc)
|
||||||
challenge.is_valid()
|
challenge.is_valid(raise_exception=True)
|
||||||
return to_stage_response(self.request, HttpChallengeResponse(challenge))
|
return to_stage_response(self.request, HttpChallengeResponse(challenge))
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
|
|||||||
@ -47,7 +47,6 @@ class FlowInspectorPlanSerializer(PassiveSerializer):
|
|||||||
"""Get the plan's context, sanitized"""
|
"""Get the plan's context, sanitized"""
|
||||||
return sanitize_dict(plan.context)
|
return sanitize_dict(plan.context)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get_session_id(self, plan: FlowPlan) -> str:
|
def get_session_id(self, plan: FlowPlan) -> str:
|
||||||
"""Get a unique session ID"""
|
"""Get a unique session ID"""
|
||||||
request: Request = self.context["request"]
|
request: Request = self.context["request"]
|
||||||
|
|||||||
@ -93,7 +93,7 @@ class ConfigLoader:
|
|||||||
if url.scheme == "file":
|
if url.scheme == "file":
|
||||||
try:
|
try:
|
||||||
with open(url.path, "r", encoding="utf8") as _file:
|
with open(url.path, "r", encoding="utf8") as _file:
|
||||||
value = _file.read()
|
value = _file.read().strip()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
self.log("error", f"Failed to read config value from {url.path}: {exc}")
|
self.log("error", f"Failed to read config value from {url.path}: {exc}")
|
||||||
value = url.query
|
value = url.query
|
||||||
|
|||||||
@ -64,6 +64,7 @@ outposts:
|
|||||||
disable_embedded_outpost: false
|
disable_embedded_outpost: false
|
||||||
|
|
||||||
ldap:
|
ldap:
|
||||||
|
task_timeout_hours: 2
|
||||||
tls:
|
tls:
|
||||||
ciphers: null
|
ciphers: null
|
||||||
|
|
||||||
|
|||||||
@ -159,7 +159,6 @@ class BaseEvaluator:
|
|||||||
raise exc
|
raise exc
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_error(self, exc: Exception, expression_source: str): # pragma: no cover
|
def handle_error(self, exc: Exception, expression_source: str): # pragma: no cover
|
||||||
"""Exception Handler"""
|
"""Exception Handler"""
|
||||||
LOGGER.warning("Expression error", exc=exc)
|
LOGGER.warning("Expression error", exc=exc)
|
||||||
|
|||||||
@ -3,7 +3,6 @@ from logging import Logger
|
|||||||
from os import getpid
|
from os import getpid
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def add_process_id(logger: Logger, method_name: str, event_dict):
|
def add_process_id(logger: Logger, method_name: str, event_dict):
|
||||||
"""Add the current process ID"""
|
"""Add the current process ID"""
|
||||||
event_dict["pid"] = getpid()
|
event_dict["pid"] = getpid()
|
||||||
|
|||||||
@ -19,7 +19,7 @@ def model_tester_factory(test_model: type[Stage]) -> Callable:
|
|||||||
def tester(self: TestModels):
|
def tester(self: TestModels):
|
||||||
try:
|
try:
|
||||||
model_class = None
|
model_class = None
|
||||||
if test_model._meta.abstract:
|
if test_model._meta.abstract: # pragma: no cover
|
||||||
return
|
return
|
||||||
model_class = test_model()
|
model_class = test_model()
|
||||||
self.assertTrue(issubclass(model_class.serializer, BaseSerializer))
|
self.assertTrue(issubclass(model_class.serializer, BaseSerializer))
|
||||||
|
|||||||
@ -48,14 +48,14 @@ def get_apps():
|
|||||||
|
|
||||||
def get_env() -> str:
|
def get_env() -> str:
|
||||||
"""Get environment in which authentik is currently running"""
|
"""Get environment in which authentik is currently running"""
|
||||||
if SERVICE_HOST_ENV_NAME in os.environ:
|
|
||||||
return "kubernetes"
|
|
||||||
if "CI" in os.environ:
|
if "CI" in os.environ:
|
||||||
return "ci"
|
return "ci"
|
||||||
if Path("/tmp/authentik-mode").exists(): # nosec
|
|
||||||
return "compose"
|
|
||||||
if CONFIG.y_bool("debug"):
|
if CONFIG.y_bool("debug"):
|
||||||
return "dev"
|
return "dev"
|
||||||
|
if SERVICE_HOST_ENV_NAME in os.environ:
|
||||||
|
return "kubernetes"
|
||||||
|
if Path("/tmp/authentik-mode").exists(): # nosec
|
||||||
|
return "compose"
|
||||||
if "AK_APPLIANCE" in os.environ:
|
if "AK_APPLIANCE" in os.environ:
|
||||||
return os.environ["AK_APPLIANCE"]
|
return os.environ["AK_APPLIANCE"]
|
||||||
return "custom"
|
return "custom"
|
||||||
|
|||||||
@ -19,7 +19,13 @@ from authentik.core.api.utils import PassiveSerializer, is_dict
|
|||||||
from authentik.core.models import Provider
|
from authentik.core.models import Provider
|
||||||
from authentik.outposts.api.service_connections import ServiceConnectionSerializer
|
from authentik.outposts.api.service_connections import ServiceConnectionSerializer
|
||||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
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.ldap.models import LDAPProvider
|
||||||
from authentik.providers.proxy.models import ProxyProvider
|
from authentik.providers.proxy.models import ProxyProvider
|
||||||
|
|
||||||
@ -96,6 +102,7 @@ class OutpostDefaultConfigSerializer(PassiveSerializer):
|
|||||||
class OutpostHealthSerializer(PassiveSerializer):
|
class OutpostHealthSerializer(PassiveSerializer):
|
||||||
"""Outpost health status"""
|
"""Outpost health status"""
|
||||||
|
|
||||||
|
uid = CharField(read_only=True)
|
||||||
last_seen = DateTimeField(read_only=True)
|
last_seen = DateTimeField(read_only=True)
|
||||||
version = CharField(read_only=True)
|
version = CharField(read_only=True)
|
||||||
version_should = 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 = CharField(read_only=True, required=False)
|
||||||
build_hash_should = 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):
|
class OutpostFilter(FilterSet):
|
||||||
"""Filter for Outposts"""
|
"""Filter for Outposts"""
|
||||||
@ -139,19 +148,21 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
@extend_schema(responses={200: OutpostHealthSerializer(many=True)})
|
@extend_schema(responses={200: OutpostHealthSerializer(many=True)})
|
||||||
@action(methods=["GET"], detail=True, pagination_class=None)
|
@action(methods=["GET"], detail=True, pagination_class=None)
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def health(self, request: Request, pk: int) -> Response:
|
def health(self, request: Request, pk: int) -> Response:
|
||||||
"""Get outposts current health"""
|
"""Get outposts current health"""
|
||||||
outpost: Outpost = self.get_object()
|
outpost: Outpost = self.get_object()
|
||||||
states = []
|
states = []
|
||||||
for state in outpost.state:
|
for state in outpost.state:
|
||||||
|
state: OutpostState
|
||||||
states.append(
|
states.append(
|
||||||
{
|
{
|
||||||
|
"uid": state.uid,
|
||||||
"last_seen": state.last_seen,
|
"last_seen": state.last_seen,
|
||||||
"version": state.version,
|
"version": state.version,
|
||||||
"version_should": state.version_should,
|
"version_should": state.version_should,
|
||||||
"version_outdated": state.version_outdated,
|
"version_outdated": state.version_outdated,
|
||||||
"build_hash": state.build_hash,
|
"build_hash": state.build_hash,
|
||||||
|
"hostname": state.hostname,
|
||||||
"build_hash_should": get_build_hash(),
|
"build_hash_should": get_build_hash(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -91,7 +91,6 @@ class ServiceConnectionViewSet(
|
|||||||
|
|
||||||
@extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
|
@extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def state(self, request: Request, pk: str) -> Response:
|
def state(self, request: Request, pk: str) -> Response:
|
||||||
"""Get the service connection's state"""
|
"""Get the service connection's state"""
|
||||||
connection = self.get_object()
|
connection = self.get_object()
|
||||||
|
|||||||
@ -69,7 +69,6 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
self.outpost = outpost
|
self.outpost = outpost
|
||||||
self.last_uid = self.channel_name
|
self.last_uid = self.channel_name
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def disconnect(self, code):
|
def disconnect(self, code):
|
||||||
if self.outpost and self.last_uid:
|
if self.outpost and self.last_uid:
|
||||||
state = OutpostState.for_instance_uid(self.outpost, self.last_uid)
|
state = OutpostState.for_instance_uid(self.outpost, self.last_uid)
|
||||||
@ -98,6 +97,7 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
if self.channel_name not in state.channel_ids:
|
if self.channel_name not in state.channel_ids:
|
||||||
state.channel_ids.append(self.channel_name)
|
state.channel_ids.append(self.channel_name)
|
||||||
state.last_seen = datetime.now()
|
state.last_seen = datetime.now()
|
||||||
|
state.hostname = msg.args.get("hostname", "")
|
||||||
|
|
||||||
if not self.first_msg:
|
if not self.first_msg:
|
||||||
GAUGE_OUTPOSTS_CONNECTED.labels(
|
GAUGE_OUTPOSTS_CONNECTED.labels(
|
||||||
@ -126,7 +126,6 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
|
response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
|
||||||
self.send_json(asdict(response))
|
self.send_json(asdict(response))
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def event_update(self, event): # pragma: no cover
|
def event_update(self, event): # pragma: no cover
|
||||||
"""Event handler which is called by post_save signals, Send update instruction"""
|
"""Event handler which is called by post_save signals, Send update instruction"""
|
||||||
self.send_json(
|
self.send_json(
|
||||||
|
|||||||
@ -13,7 +13,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from model_utils.managers import InheritanceManager
|
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 rest_framework.serializers import Serializer
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
@ -429,8 +429,9 @@ class OutpostState:
|
|||||||
channel_ids: list[str] = field(default_factory=list)
|
channel_ids: list[str] = field(default_factory=list)
|
||||||
last_seen: Optional[datetime] = field(default=None)
|
last_seen: Optional[datetime] = field(default=None)
|
||||||
version: Optional[str] = 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="")
|
build_hash: str = field(default="")
|
||||||
|
hostname: str = field(default="")
|
||||||
|
|
||||||
_outpost: Optional[Outpost] = field(default=None)
|
_outpost: Optional[Outpost] = field(default=None)
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,6 @@ UPDATE_TRIGGERING_MODELS = (
|
|||||||
|
|
||||||
|
|
||||||
@receiver(pre_save, sender=Outpost)
|
@receiver(pre_save, sender=Outpost)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_save_outpost(sender, instance: Outpost, **_):
|
def pre_save_outpost(sender, instance: Outpost, **_):
|
||||||
"""Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes,
|
"""Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes,
|
||||||
we call down and then wait for the up after save"""
|
we call down and then wait for the up after save"""
|
||||||
@ -43,7 +42,6 @@ def pre_save_outpost(sender, instance: Outpost, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(m2m_changed, sender=Outpost.providers.through)
|
@receiver(m2m_changed, sender=Outpost.providers.through)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def m2m_changed_update(sender, instance: Model, action: str, **_):
|
def m2m_changed_update(sender, instance: Model, action: str, **_):
|
||||||
"""Update outpost on m2m change, when providers are added or removed"""
|
"""Update outpost on m2m change, when providers are added or removed"""
|
||||||
if action in ["post_add", "post_remove", "post_clear"]:
|
if action in ["post_add", "post_remove", "post_clear"]:
|
||||||
@ -51,7 +49,6 @@ def m2m_changed_update(sender, instance: Model, action: str, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_update(sender, instance: Model, created: bool, **_):
|
def post_save_update(sender, instance: Model, created: bool, **_):
|
||||||
"""If an Outpost is saved, Ensure that token is created/updated
|
"""If an Outpost is saved, Ensure that token is created/updated
|
||||||
|
|
||||||
@ -70,7 +67,6 @@ def post_save_update(sender, instance: Model, created: bool, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Outpost)
|
@receiver(pre_delete, sender=Outpost)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_delete_cleanup(sender, instance: Outpost, **_):
|
def pre_delete_cleanup(sender, instance: Outpost, **_):
|
||||||
"""Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
|
"""Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
|
||||||
instance.user.delete()
|
instance.user.delete()
|
||||||
|
|||||||
@ -144,7 +144,6 @@ class PolicyViewSet(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def test(self, request: Request, pk: str) -> Response:
|
def test(self, request: Request, pk: str) -> Response:
|
||||||
"""Test policy"""
|
"""Test policy"""
|
||||||
policy = self.get_object()
|
policy = self.get_object()
|
||||||
|
|||||||
@ -52,6 +52,8 @@ class PolicyEngine:
|
|||||||
self.empty_result = True
|
self.empty_result = True
|
||||||
if not isinstance(pbm, PolicyBindingModel): # pragma: no cover
|
if not isinstance(pbm, PolicyBindingModel): # pragma: no cover
|
||||||
raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
|
raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
|
||||||
|
if not user:
|
||||||
|
raise ValueError("User must be set")
|
||||||
self.__pbm = pbm
|
self.__pbm = pbm
|
||||||
self.request = PolicyRequest(user)
|
self.request = PolicyRequest(user)
|
||||||
self.request.obj = pbm
|
self.request.obj = pbm
|
||||||
|
|||||||
@ -1,14 +1,25 @@
|
|||||||
"""Event Matcher Policy API"""
|
"""Event Matcher Policy API"""
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework.fields import ChoiceField
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.policies.api.policies import PolicySerializer
|
from authentik.policies.api.policies import PolicySerializer
|
||||||
from authentik.policies.event_matcher.models import EventMatcherPolicy
|
from authentik.policies.event_matcher.models import EventMatcherPolicy, app_choices
|
||||||
|
|
||||||
|
|
||||||
class EventMatcherPolicySerializer(PolicySerializer):
|
class EventMatcherPolicySerializer(PolicySerializer):
|
||||||
"""Event Matcher Policy Serializer"""
|
"""Event Matcher Policy Serializer"""
|
||||||
|
|
||||||
|
app = ChoiceField(
|
||||||
|
choices=app_choices(),
|
||||||
|
required=False,
|
||||||
|
help_text=_(
|
||||||
|
"Match events created by selected application. When left empty, "
|
||||||
|
"all applications are matched."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = EventMatcherPolicy
|
model = EventMatcherPolicy
|
||||||
fields = PolicySerializer.Meta.fields + [
|
fields = PolicySerializer.Meta.fields + [
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-05 09:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"authentik_policies_event_matcher",
|
||||||
|
"0020_eventmatcherpolicy_authentik_p_policy__e605cf_idx",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="eventmatcherpolicy",
|
||||||
|
name="app",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Match events created by selected application. When left empty, all applications are matched.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -33,7 +33,6 @@ class EventMatcherPolicy(Policy):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
app = models.TextField(
|
app = models.TextField(
|
||||||
choices=app_choices(),
|
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
help_text=_(
|
help_text=_(
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
"""Source API Views"""
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
|
||||||
from authentik.policies.api.policies import PolicySerializer
|
|
||||||
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
|
|
||||||
|
|
||||||
|
|
||||||
class HaveIBeenPwendPolicySerializer(PolicySerializer):
|
|
||||||
"""Have I Been Pwned Policy Serializer"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = HaveIBeenPwendPolicy
|
|
||||||
fields = PolicySerializer.Meta.fields + ["password_field", "allowed_count"]
|
|
||||||
|
|
||||||
|
|
||||||
class HaveIBeenPwendPolicyViewSet(UsedByMixin, ModelViewSet):
|
|
||||||
"""Source Viewset"""
|
|
||||||
|
|
||||||
queryset = HaveIBeenPwendPolicy.objects.all()
|
|
||||||
serializer_class = HaveIBeenPwendPolicySerializer
|
|
||||||
filterset_fields = "__all__"
|
|
||||||
search_fields = ["name", "password_field"]
|
|
||||||
ordering = ["name"]
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
"""Authentik hibp app config"""
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikPolicyHIBPConfig(AppConfig):
|
|
||||||
"""Authentik hibp app config"""
|
|
||||||
|
|
||||||
name = "authentik.policies.hibp"
|
|
||||||
label = "authentik_policies_hibp"
|
|
||||||
verbose_name = "authentik Policies.HaveIBeenPwned"
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# Generated by Django 3.0.6 on 2020-05-19 22:08
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_policies", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="HaveIBeenPwendPolicy",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"policy_ptr",
|
|
||||||
models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
to="authentik_policies.Policy",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("allowed_count", models.IntegerField(default=0)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Have I Been Pwned Policy",
|
|
||||||
"verbose_name_plural": "Have I Been Pwned Policies",
|
|
||||||
},
|
|
||||||
bases=("authentik_policies.policy",),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 3.0.8 on 2020-07-10 18:45
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_policies_hibp", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="haveibeenpwendpolicy",
|
|
||||||
name="password_field",
|
|
||||||
field=models.TextField(
|
|
||||||
default="password",
|
|
||||||
help_text="Field key to check, field keys defined in Prompt stages are available.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 4.1.2 on 2022-10-19 19:24
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_policies_hibp", "0002_haveibeenpwendpolicy_password_field"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="haveibeenpwendpolicy",
|
|
||||||
index=models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__6957d7_idx"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
"""authentik HIBP Models"""
|
|
||||||
from hashlib import sha1
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from rest_framework.serializers import BaseSerializer
|
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.lib.utils.http import get_http_session
|
|
||||||
from authentik.policies.models import Policy, PolicyResult
|
|
||||||
from authentik.policies.types import PolicyRequest
|
|
||||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class HaveIBeenPwendPolicy(Policy):
|
|
||||||
"""DEPRECATED. Check if password is on HaveIBeenPwned's list by uploading the first
|
|
||||||
5 characters of the SHA1 Hash."""
|
|
||||||
|
|
||||||
password_field = models.TextField(
|
|
||||||
default="password",
|
|
||||||
help_text=_("Field key to check, field keys defined in Prompt stages are available."),
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_count = models.IntegerField(default=0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer(self) -> type[BaseSerializer]:
|
|
||||||
from authentik.policies.hibp.api import HaveIBeenPwendPolicySerializer
|
|
||||||
|
|
||||||
return HaveIBeenPwendPolicySerializer
|
|
||||||
|
|
||||||
@property
|
|
||||||
def component(self) -> str:
|
|
||||||
return "ak-policy-hibp-form"
|
|
||||||
|
|
||||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
|
||||||
"""Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5
|
|
||||||
characters of Password in request and checks if full hash is in response. Returns 0
|
|
||||||
if Password is not in result otherwise the count of how many times it was used."""
|
|
||||||
password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get(
|
|
||||||
self.password_field, request.context.get(self.password_field)
|
|
||||||
)
|
|
||||||
if not password:
|
|
||||||
LOGGER.warning(
|
|
||||||
"Password field not set in Policy Request",
|
|
||||||
field=self.password_field,
|
|
||||||
fields=request.context.keys(),
|
|
||||||
)
|
|
||||||
return PolicyResult(False, _("Password not set in context"))
|
|
||||||
password = str(password)
|
|
||||||
|
|
||||||
pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec
|
|
||||||
url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
|
|
||||||
result = get_http_session().get(url).text
|
|
||||||
final_count = 0
|
|
||||||
for line in result.split("\r\n"):
|
|
||||||
full_hash, count = line.split(":")
|
|
||||||
if pw_hash[5:] == full_hash.lower():
|
|
||||||
final_count = int(count)
|
|
||||||
LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5])
|
|
||||||
if final_count > self.allowed_count:
|
|
||||||
message = _("Password exists on %(count)d online lists." % {"count": final_count})
|
|
||||||
return PolicyResult(False, message)
|
|
||||||
return PolicyResult(True)
|
|
||||||
|
|
||||||
class Meta(Policy.PolicyMeta):
|
|
||||||
|
|
||||||
verbose_name = _("Have I Been Pwned Policy")
|
|
||||||
verbose_name_plural = _("Have I Been Pwned Policies")
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
"""HIBP Policy tests"""
|
|
||||||
from django.test import TestCase
|
|
||||||
from guardian.shortcuts import get_anonymous_user
|
|
||||||
|
|
||||||
from authentik.lib.generators import generate_key
|
|
||||||
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
|
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
|
||||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
|
||||||
|
|
||||||
|
|
||||||
class TestHIBPPolicy(TestCase):
|
|
||||||
"""Test HIBP Policy"""
|
|
||||||
|
|
||||||
def test_invalid(self):
|
|
||||||
"""Test without password"""
|
|
||||||
policy = HaveIBeenPwendPolicy.objects.create(
|
|
||||||
name="test_invalid",
|
|
||||||
)
|
|
||||||
request = PolicyRequest(get_anonymous_user())
|
|
||||||
result: PolicyResult = policy.passes(request)
|
|
||||||
self.assertFalse(result.passing)
|
|
||||||
self.assertEqual(result.messages[0], "Password not set in context")
|
|
||||||
|
|
||||||
def test_false(self):
|
|
||||||
"""Failing password case"""
|
|
||||||
policy = HaveIBeenPwendPolicy.objects.create(
|
|
||||||
name="test_false",
|
|
||||||
)
|
|
||||||
request = PolicyRequest(get_anonymous_user())
|
|
||||||
request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"} # nosec
|
|
||||||
result: PolicyResult = policy.passes(request)
|
|
||||||
self.assertFalse(result.passing)
|
|
||||||
self.assertTrue(result.messages[0].startswith("Password exists on "))
|
|
||||||
|
|
||||||
def test_true(self):
|
|
||||||
"""Positive password case"""
|
|
||||||
policy = HaveIBeenPwendPolicy.objects.create(
|
|
||||||
name="test_true",
|
|
||||||
)
|
|
||||||
request = PolicyRequest(get_anonymous_user())
|
|
||||||
request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()}
|
|
||||||
result: PolicyResult = policy.passes(request)
|
|
||||||
self.assertTrue(result.passing)
|
|
||||||
self.assertEqual(result.messages, tuple())
|
|
||||||
@ -1,34 +1,10 @@
|
|||||||
# Generated by Django 4.1.3 on 2022-11-14 09:23
|
# Generated by Django 4.1.3 on 2022-11-14 09:23
|
||||||
from django.apps.registry import Apps
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_hibp_policy(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
db_alias = schema_editor.connection.alias
|
|
||||||
|
|
||||||
HaveIBeenPwendPolicy = apps.get_model("authentik_policies_hibp", "HaveIBeenPwendPolicy")
|
|
||||||
PasswordPolicy = apps.get_model("authentik_policies_password", "PasswordPolicy")
|
|
||||||
|
|
||||||
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
|
|
||||||
|
|
||||||
for old_policy in HaveIBeenPwendPolicy.objects.using(db_alias).all():
|
|
||||||
new_policy = PasswordPolicy.objects.using(db_alias).create(
|
|
||||||
name=old_policy.name,
|
|
||||||
hibp_allowed_count=old_policy.allowed_count,
|
|
||||||
password_field=old_policy.password_field,
|
|
||||||
execution_logging=old_policy.execution_logging,
|
|
||||||
check_static_rules=False,
|
|
||||||
check_have_i_been_pwned=True,
|
|
||||||
)
|
|
||||||
PolicyBinding.objects.using(db_alias).filter(policy=old_policy).update(policy=new_policy)
|
|
||||||
old_policy.delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_policies_hibp", "0003_haveibeenpwendpolicy_authentik_p_policy__6957d7_idx"),
|
|
||||||
("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"),
|
("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -69,5 +45,4 @@ class Migration(migrations.Migration):
|
|||||||
name="error_message",
|
name="error_message",
|
||||||
field=models.TextField(blank=True),
|
field=models.TextField(blank=True),
|
||||||
),
|
),
|
||||||
migrations.RunPython(migrate_hibp_policy),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -138,5 +138,5 @@ class PolicyProcess(PROCESS_CLASS):
|
|||||||
try:
|
try:
|
||||||
self.connection.send(self.profiling_wrapper())
|
self.connection.send(self.profiling_wrapper())
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
LOGGER.warning("Policy failed to run", exc=exc)
|
LOGGER.warning("Policy failed to run", exc=exception_to_string(exc))
|
||||||
self.connection.send(PolicyResult(False, str(exc)))
|
self.connection.send(PolicyResult(False, str(exc)))
|
||||||
|
|||||||
@ -37,7 +37,6 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(login_failed)
|
@receiver(login_failed)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_failed_login(sender, request, credentials, **_):
|
def handle_failed_login(sender, request, credentials, **_):
|
||||||
"""Lower Score for failed login attempts"""
|
"""Lower Score for failed login attempts"""
|
||||||
if "username" in credentials:
|
if "username" in credentials:
|
||||||
@ -45,14 +44,12 @@ def handle_failed_login(sender, request, credentials, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(identification_failed)
|
@receiver(identification_failed)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_identification_failed(sender, request, uid_field: str, **_):
|
def handle_identification_failed(sender, request, uid_field: str, **_):
|
||||||
"""Lower Score for failed identification attempts"""
|
"""Lower Score for failed identification attempts"""
|
||||||
update_score(request, uid_field, -1)
|
update_score(request, uid_field, -1)
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_successful_login(sender, request, user, **_):
|
def handle_successful_login(sender, request, user, **_):
|
||||||
"""Raise score for successful attempts"""
|
"""Raise score for successful attempts"""
|
||||||
update_score(request, user.username, 1)
|
update_score(request, user.username, 1)
|
||||||
|
|||||||
@ -13,14 +13,12 @@ LOGGER = get_logger()
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_policies(sender, **kwargs):
|
def monitoring_set_policies(sender, **kwargs):
|
||||||
"""set policy gauges"""
|
"""set policy gauges"""
|
||||||
GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or []))
|
GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or []))
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def invalidate_policy_cache(sender, instance, **_):
|
def invalidate_policy_cache(sender, instance, **_):
|
||||||
"""Invalidate Policy cache when policy is updated"""
|
"""Invalidate Policy cache when policy is updated"""
|
||||||
from authentik.policies.models import Policy, PolicyBinding
|
from authentik.policies.models import Policy, PolicyBinding
|
||||||
|
|||||||
@ -83,7 +83,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
@action(methods=["GET"], detail=True)
|
@action(methods=["GET"], detail=True)
|
||||||
# pylint: disable=invalid-name
|
|
||||||
def setup_urls(self, request: Request, pk: int) -> str:
|
def setup_urls(self, request: Request, pk: int) -> str:
|
||||||
"""Get Providers setup URLs"""
|
"""Get Providers setup URLs"""
|
||||||
provider = get_object_or_404(OAuth2Provider, pk=pk)
|
provider = get_object_or_404(OAuth2Provider, pk=pk)
|
||||||
@ -140,7 +139,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["GET"])
|
@action(detail=True, methods=["GET"])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def preview_user(self, request: Request, pk: int) -> Response:
|
def preview_user(self, request: Request, pk: int) -> Response:
|
||||||
"""Preview user data for provider"""
|
"""Preview user data for provider"""
|
||||||
provider: OAuth2Provider = self.get_object()
|
provider: OAuth2Provider = self.get_object()
|
||||||
|
|||||||
@ -3,6 +3,8 @@ from django_filters.filters import AllValuesMultipleFilter
|
|||||||
from django_filters.filterset import FilterSet
|
from django_filters.filterset import FilterSet
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
|
from rest_framework.fields import CharField
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
||||||
@ -10,9 +12,18 @@ from authentik.core.api.used_by import UsedByMixin
|
|||||||
from authentik.providers.oauth2.models import ScopeMapping
|
from authentik.providers.oauth2.models import ScopeMapping
|
||||||
|
|
||||||
|
|
||||||
|
def no_space(value: str) -> str:
|
||||||
|
"""Ensure value contains no spaces"""
|
||||||
|
if " " in value:
|
||||||
|
raise ValidationError("Value must not contain spaces.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ScopeMappingSerializer(PropertyMappingSerializer):
|
class ScopeMappingSerializer(PropertyMappingSerializer):
|
||||||
"""ScopeMapping Serializer"""
|
"""ScopeMapping Serializer"""
|
||||||
|
|
||||||
|
scope_name = CharField(help_text="Scope name requested by the client", validators=[no_space])
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = ScopeMapping
|
model = ScopeMapping
|
||||||
|
|||||||
@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
|||||||
model_name="oauth2provider",
|
model_name="oauth2provider",
|
||||||
name="verification_keys",
|
name="verification_keys",
|
||||||
field=models.ManyToManyField(
|
field=models.ManyToManyField(
|
||||||
help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.",
|
help_text="JWTs created with the configured certificates can authenticate with this provider.",
|
||||||
related_name="+",
|
related_name="+",
|
||||||
to="authentik_crypto.certificatekeypair",
|
to="authentik_crypto.certificatekeypair",
|
||||||
verbose_name="Allowed certificates for JWT-based client_credentials",
|
verbose_name="Allowed certificates for JWT-based client_credentials",
|
||||||
|
|||||||
@ -32,7 +32,7 @@ class Migration(migrations.Migration):
|
|||||||
field=models.ManyToManyField(
|
field=models.ManyToManyField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=None,
|
default=None,
|
||||||
help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.",
|
help_text="JWTs created with the configured certificates can authenticate with this provider.",
|
||||||
related_name="oauth2_providers",
|
related_name="oauth2_providers",
|
||||||
to="authentik_crypto.certificatekeypair",
|
to="authentik_crypto.certificatekeypair",
|
||||||
verbose_name="Allowed certificates for JWT-based client_credentials",
|
verbose_name="Allowed certificates for JWT-based client_credentials",
|
||||||
|
|||||||
@ -22,8 +22,7 @@ from rest_framework.serializers import Serializer
|
|||||||
|
|
||||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
|
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.events.models import Event
|
from authentik.events.signals import get_login_event
|
||||||
from authentik.events.signals import SESSION_LOGIN_EVENT
|
|
||||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_string_validator
|
||||||
@ -419,6 +418,8 @@ class IDToken:
|
|||||||
id_dict.pop("nonce")
|
id_dict.pop("nonce")
|
||||||
if not self.c_hash:
|
if not self.c_hash:
|
||||||
id_dict.pop("c_hash")
|
id_dict.pop("c_hash")
|
||||||
|
if not self.amr:
|
||||||
|
id_dict.pop("amr")
|
||||||
id_dict.pop("claims")
|
id_dict.pop("claims")
|
||||||
id_dict.update(self.claims)
|
id_dict.update(self.claims)
|
||||||
return id_dict
|
return id_dict
|
||||||
@ -503,8 +504,8 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
|||||||
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
||||||
# Fallback in case we can't find any login events
|
# Fallback in case we can't find any login events
|
||||||
auth_time = now
|
auth_time = now
|
||||||
if SESSION_LOGIN_EVENT in request.session:
|
auth_event = get_login_event(request)
|
||||||
auth_event: Event = request.session[SESSION_LOGIN_EVENT]
|
if auth_event:
|
||||||
auth_time = auth_event.created
|
auth_time = auth_event.created
|
||||||
# Also check which method was used for authentication
|
# Also check which method was used for authentication
|
||||||
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
|
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
|
||||||
@ -526,6 +527,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
|||||||
exp=exp_time,
|
exp=exp_time,
|
||||||
iat=iat_time,
|
iat=iat_time,
|
||||||
auth_time=auth_timestamp,
|
auth_time=auth_timestamp,
|
||||||
|
amr=amr if amr else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Include (or not) user standard claims in the id_token.
|
# Include (or not) user standard claims in the id_token.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user