Compare commits
233 Commits
version-20
...
root/confi
Author | SHA1 | Date | |
---|---|---|---|
3fa987f443 | |||
5ea9595c9c | |||
1b6f920265 | |||
3bf8c915d5 | |||
1cc578be66 | |||
36f8f8bae5 | |||
68058fb2ae | |||
0abbe8288e | |||
a9de9101ca | |||
19ed9dc5e9 | |||
b5ae712f35 | |||
b1c01b53e7 | |||
5b31e47573 | |||
d2a9a294f2 | |||
32c7c58518 | |||
349c6e50c2 | |||
2e8027fa04 | |||
0bc1b33663 | |||
8564f9ef87 | |||
7cfd84d8f0 | |||
f2e40ec7e3 | |||
1f1255a829 | |||
53b65a9d1a | |||
9437e2d3ab | |||
6a7b78abc2 | |||
9c24e5195b | |||
306b046b9a | |||
10b50c5845 | |||
3912a57df2 | |||
08142d393f | |||
0f7af256d6 | |||
16076cc46f | |||
8aa16e66e4 | |||
a9b32e2f97 | |||
b2d272bf6f | |||
31ef6fb6a6 | |||
c9c059a008 | |||
a5e84b5482 | |||
7c697e09f3 | |||
b99afd82b2 | |||
9397598376 | |||
91ffe4e7f9 | |||
430a207865 | |||
894873b373 | |||
1ce2a1b846 | |||
4731ccfafe | |||
3e9c28d0a4 | |||
12d4394d73 | |||
b872e7072d | |||
b0ea657b18 | |||
a5f26b2ce0 | |||
c1b9b5c5e2 | |||
b288393cd4 | |||
767ffc09d0 | |||
446dc0a17b | |||
c85474ec37 | |||
3a59b75f4a | |||
8deac81364 | |||
98485c528e | |||
1a5b626f96 | |||
5736a1542c | |||
43854dc828 | |||
64af78110a | |||
59be3c7746 | |||
9e30f01fce | |||
fc8fe5317a | |||
92090ced9f | |||
ce47d4cf39 | |||
c11367553e | |||
c61529e4d4 | |||
8709f3300c | |||
e78bc1b32f | |||
89c4a7b4a4 | |||
9701907b82 | |||
7ac73bfcf9 | |||
1423d5d45b | |||
768ff67e8c | |||
b13deefd91 | |||
69e445211e | |||
ada8fc2a55 | |||
d9cc45f9ce | |||
515a402db7 | |||
813f70b806 | |||
a302a72379 | |||
e390f5b2d1 | |||
f09305a444 | |||
60189ce9ca | |||
fdc445e6a1 | |||
e3f8afcf80 | |||
9e2e8132a6 | |||
26f9bbeefa | |||
49b6c71079 | |||
97acc77e0a | |||
eb1e0427c1 | |||
6e0c9acb34 | |||
b75d659707 | |||
8894861a59 | |||
7878755acd | |||
2b62d6646e | |||
4f81f750ce | |||
fa216e2e93 | |||
181bd903be | |||
23c69c456a | |||
c73fce4f58 | |||
bd0ef69ece | |||
19ee98b36d | |||
75d4246b79 | |||
d2fd84d98c | |||
678378403b | |||
7f32d0eb9a | |||
f1b3598a0f | |||
07767c9376 | |||
5a3f9d1417 | |||
44a6303c91 | |||
5f7f80fdee | |||
a332a465ef | |||
8b16fed926 | |||
be10dd629b | |||
a6a868cbc1 | |||
a9ed275f4e | |||
fbc5378158 | |||
20210b614d | |||
063877a615 | |||
a73d50d379 | |||
9568f4dbd6 | |||
9b2ceb0d44 | |||
2deb185550 | |||
69d4719687 | |||
d31e566873 | |||
0ddcefce80 | |||
4c45d35507 | |||
829e49275d | |||
143309448e | |||
1f038ecee2 | |||
1b1f2ea72c | |||
6e1a54753e | |||
67d1f06c91 | |||
d37de6bc00 | |||
8deced771d | |||
c380512cc8 | |||
e0b06bc4de | |||
1bd6107ec7 | |||
ce1409fb6c | |||
b6b97f4706 | |||
cd12e177ea | |||
31c6ea9fda | |||
20931ccc1d | |||
9c9f441cff | |||
36822c128c | |||
29d3fdaa1d | |||
ac5167b8a3 | |||
0db434a922 | |||
3c0675486c | |||
f6d56e7e29 | |||
fac56390a0 | |||
c6e3229f0b | |||
ace30933bd | |||
d313f1576b | |||
ac07576676 | |||
df42480284 | |||
d2f722f032 | |||
a8fdcab927 | |||
0cba3c7788 | |||
0d414ec0ea | |||
c42b34a46b | |||
7a1050300d | |||
a64e87a6b1 | |||
81e9f2d608 | |||
ddbd8153e2 | |||
f7037b9f33 | |||
67a6fa6399 | |||
a35b8f5862 | |||
5b7c6f1b0e | |||
662101fd1f | |||
3f633460a8 | |||
be2d1a522a | |||
d6f5b8e421 | |||
b424c5dd27 | |||
2a83d79ace | |||
1ed24a5eef | |||
f2961cb536 | |||
4d66e42708 | |||
bd3a721753 | |||
25c3086d7a | |||
1bdd09342a | |||
ad6d773d26 | |||
b555ccd549 | |||
9445354b31 | |||
a42f2f7217 | |||
d1aa1f46da | |||
a1be924fa4 | |||
db60427e21 | |||
d3e2f41561 | |||
8840f6ef63 | |||
3b103b22e2 | |||
158f4c1c4c | |||
42606a499b | |||
c0841120bf | |||
61442a7e4a | |||
98876df5c5 | |||
a9680d6088 | |||
7eb6320d74 | |||
47aba4a996 | |||
643b36b732 | |||
001869641d | |||
bec538c543 | |||
c63ba3f378 | |||
0fb2b5550a | |||
762294c0f9 | |||
2a2ab94e97 | |||
53cab07a48 | |||
2604dc14fe | |||
06f67c738c | |||
1b001060a3 | |||
a960ce9454 | |||
439bdc54d6 | |||
e6b5810e03 | |||
89b73a4d89 | |||
ed3f36e72a | |||
78b711ec9d | |||
4be0a707b1 | |||
1e73b42c58 | |||
3df3bceccb | |||
a4370458cb | |||
742bad4080 | |||
f15946e216 | |||
b54415dcde | |||
471293ba25 | |||
3e7320734c | |||
3131e557d9 | |||
1efc7eecbf | |||
15ec6a9284 | |||
dc1359a763 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2022.12.3
|
current_version = 2023.1.2
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||||
|
@ -38,6 +38,14 @@ runs:
|
|||||||
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
|
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For arm64, use these values:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
|
||||||
|
AUTHENTIK_TAG=${{ inputs.tag }}-arm64
|
||||||
|
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
|
||||||
|
```
|
||||||
|
|
||||||
Afterwards, run the upgrade commands from the latest release notes.
|
Afterwards, run the upgrade commands from the latest release notes.
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
@ -54,6 +62,17 @@ runs:
|
|||||||
tag: ${{ inputs.tag }}
|
tag: ${{ inputs.tag }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For arm64, use these values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
authentik:
|
||||||
|
outposts:
|
||||||
|
container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/goauthentik/dev-server
|
||||||
|
tag: ${{ inputs.tag }}-arm64
|
||||||
|
```
|
||||||
|
|
||||||
Afterwards, run the upgrade commands from the latest release notes.
|
Afterwards, run the upgrade commands from the latest release notes.
|
||||||
</details>
|
</details>
|
||||||
edit-mode: replace
|
edit-mode: replace
|
||||||
|
@ -17,6 +17,9 @@ outputs:
|
|||||||
sha:
|
sha:
|
||||||
description: "sha"
|
description: "sha"
|
||||||
value: ${{ steps.ev.outputs.sha }}
|
value: ${{ steps.ev.outputs.sha }}
|
||||||
|
shortHash:
|
||||||
|
description: "shortHash"
|
||||||
|
value: ${{ steps.ev.outputs.shortHash }}
|
||||||
version:
|
version:
|
||||||
description: "version"
|
description: "version"
|
||||||
value: ${{ steps.ev.outputs.version }}
|
value: ${{ steps.ev.outputs.version }}
|
||||||
@ -53,6 +56,7 @@ runs:
|
|||||||
print("branchNameContainer=%s" % safe_branch_name, file=_output)
|
print("branchNameContainer=%s" % safe_branch_name, file=_output)
|
||||||
print("timestamp=%s" % int(time()), file=_output)
|
print("timestamp=%s" % int(time()), file=_output)
|
||||||
print("sha=%s" % os.environ["GITHUB_SHA"], file=_output)
|
print("sha=%s" % os.environ["GITHUB_SHA"], file=_output)
|
||||||
|
print("shortHash=%s" % os.environ["GITHUB_SHA"][:7], file=_output)
|
||||||
print("shouldBuild=%s" % should_build, file=_output)
|
print("shouldBuild=%s" % should_build, file=_output)
|
||||||
print("version=%s" % version, file=_output)
|
print("version=%s" % version, file=_output)
|
||||||
print("versionFamily=%s" % version_family, file=_output)
|
print("versionFamily=%s" % version_family, file=_output)
|
||||||
|
103
.github/workflows/ci-main.yml
vendored
103
.github/workflows/ci-main.yml
vendored
@ -102,14 +102,31 @@ jobs:
|
|||||||
uses: helm/kind-action@v1.5.0
|
uses: helm/kind-action@v1.5.0
|
||||||
- name: run integration
|
- name: run integration
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-integration
|
poetry run coverage run manage.py test tests/integration
|
||||||
poetry run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
flags: integration
|
flags: integration
|
||||||
test-e2e-provider:
|
test-e2e:
|
||||||
|
name: test-e2e (${{ matrix.job.name }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
job:
|
||||||
|
- name: proxy
|
||||||
|
glob: tests/e2e/test_provider_proxy*
|
||||||
|
- name: oauth
|
||||||
|
glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth*
|
||||||
|
- name: oauth-oidc
|
||||||
|
glob: tests/e2e/test_provider_oidc*
|
||||||
|
- name: saml
|
||||||
|
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
||||||
|
- name: ldap
|
||||||
|
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
|
||||||
|
- name: flows
|
||||||
|
glob: tests/e2e/test_flows*
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
@ -131,36 +148,7 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-e2e-provider
|
poetry run coverage run manage.py test ${{ matrix.job.glob }}
|
||||||
poetry run coverage xml
|
|
||||||
- if: ${{ always() }}
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
flags: e2e
|
|
||||||
test-e2e-rest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Setup authentik env
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
- name: Setup e2e env (chrome, etc)
|
|
||||||
run: |
|
|
||||||
docker-compose -f tests/e2e/docker-compose.yml up -d
|
|
||||||
- id: cache-web
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: web/dist
|
|
||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
|
|
||||||
- name: prepare web ui
|
|
||||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
|
||||||
working-directory: web/
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
make -C .. gen-client-ts
|
|
||||||
npm run build
|
|
||||||
- name: run e2e
|
|
||||||
run: |
|
|
||||||
poetry run make test-e2e-rest
|
|
||||||
poetry run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
@ -173,8 +161,7 @@ jobs:
|
|||||||
- test-migrations-from-stable
|
- test-migrations-from-stable
|
||||||
- test-unittest
|
- test-unittest
|
||||||
- test-integration
|
- test-integration
|
||||||
- test-e2e-rest
|
- test-e2e
|
||||||
- test-e2e-provider
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- run: echo mark
|
||||||
@ -182,11 +169,6 @@ jobs:
|
|||||||
needs: ci-core-mark
|
needs: ci-core-mark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
arch:
|
|
||||||
- 'linux/amd64'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
@ -205,7 +187,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
secrets: |
|
secrets: |
|
||||||
@ -214,14 +196,49 @@ jobs:
|
|||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
|
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
|
||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
|
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
platforms: ${{ matrix.arch }}
|
|
||||||
- name: Comment on PR
|
- name: Comment on PR
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: ./.github/actions/comment-pr-instructions
|
uses: ./.github/actions/comment-pr-instructions
|
||||||
with:
|
with:
|
||||||
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
|
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
|
||||||
|
build-arm64:
|
||||||
|
needs: ci-core-mark
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: prepare variables
|
||||||
|
uses: ./.github/actions/docker-push-variables
|
||||||
|
id: ev
|
||||||
|
env:
|
||||||
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
- name: Login to Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build Docker Image
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
secrets: |
|
||||||
|
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||||
|
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
||||||
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
|
tags: |
|
||||||
|
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-arm64
|
||||||
|
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}-arm64
|
||||||
|
build-args: |
|
||||||
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
|
platforms: linux/arm64
|
||||||
|
7
.github/workflows/ci-outpost.yml
vendored
7
.github/workflows/ci-outpost.yml
vendored
@ -28,6 +28,8 @@ jobs:
|
|||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v3
|
||||||
|
with:
|
||||||
|
args: --timeout 5000s
|
||||||
test-unittest:
|
test-unittest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -80,13 +82,12 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}
|
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}
|
||||||
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}
|
|
||||||
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
|
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
@ -111,7 +112,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
10
.github/workflows/ci-web.yml
vendored
10
.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'
|
||||||
@ -31,7 +31,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'
|
||||||
@ -47,7 +47,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'
|
||||||
@ -63,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'
|
||||||
@ -95,7 +95,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
2
.github/workflows/ci-website.yml
vendored
2
.github/workflows/ci-website.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
4
.github/workflows/ghcr-retention.yml
vendored
4
.github/workflows/ghcr-retention.yml
vendored
@ -11,12 +11,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Delete 'dev' containers older than a week
|
- name: Delete 'dev' containers older than a week
|
||||||
uses: sondrelg/container-retention-policy@v1
|
uses: snok/container-retention-policy@v1
|
||||||
with:
|
with:
|
||||||
image-names: dev-server,dev-ldap,dev-proxy
|
image-names: dev-server,dev-ldap,dev-proxy
|
||||||
cut-off: One week ago UTC
|
cut-off: One week ago UTC
|
||||||
account-type: org
|
account-type: org
|
||||||
org-name: goauthentik
|
org-name: goauthentik
|
||||||
untagged-only: false
|
untagged-only: false
|
||||||
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
skip-tags: gh-next,gh-main
|
skip-tags: gh-next,gh-main
|
||||||
|
11
.github/workflows/release-publish.yml
vendored
11
.github/workflows/release-publish.yml
vendored
@ -27,11 +27,11 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
secrets:
|
secrets: |
|
||||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||||
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
||||||
tags: |
|
tags: |
|
||||||
@ -75,7 +75,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
@ -88,9 +88,6 @@ jobs:
|
|||||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
secrets: |
|
|
||||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
|
||||||
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
build-outpost-binary:
|
build-outpost-binary:
|
||||||
@ -109,7 +106,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
4
.github/workflows/release-tag.yml
vendored
4
.github/workflows/release-tag.yml
vendored
@ -26,14 +26,14 @@ jobs:
|
|||||||
id: get_version
|
id: get_version
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
|
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: actions/create-release@v1.1.4
|
uses: actions/create-release@v1.1.4
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref }}
|
tag_name: ${{ github.ref }}
|
||||||
release_name: Release ${{ steps.get_version.outputs.result }}
|
release_name: Release ${{ steps.get_version.outputs.result }}
|
||||||
|
4
.github/workflows/translation-compile.yml
vendored
4
.github/workflows/translation-compile.yml
vendored
@ -19,6 +19,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: run compile
|
- name: run compile
|
||||||
@ -27,7 +29,7 @@ jobs:
|
|||||||
uses: peter-evans/create-pull-request@v4
|
uses: peter-evans/create-pull-request@v4
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
branch: compile-backend-translation
|
branch: compile-backend-translation
|
||||||
commit-message: "core: compile backend translations"
|
commit-message: "core: compile backend translations"
|
||||||
title: "core: compile backend translations"
|
title: "core: compile backend translations"
|
||||||
|
16
.github/workflows/web-api-publish.yml
vendored
16
.github/workflows/web-api-publish.yml
vendored
@ -10,7 +10,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
with:
|
||||||
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
@ -28,14 +30,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION
|
||||||
- name: Create Pull Request
|
- uses: peter-evans/create-pull-request@v4
|
||||||
uses: peter-evans/create-pull-request@v4
|
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
branch: update-web-api-client
|
branch: update-web-api-client
|
||||||
commit-message: "web: bump API Client version"
|
commit-message: "web: bump API Client version"
|
||||||
title: "web: bump API Client version"
|
title: "web: bump API Client version"
|
||||||
body: "web: bump API Client version"
|
body: "web: bump API Client version"
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
signoff: true
|
signoff: true
|
||||||
|
team-reviewers: "@goauthentik/core"
|
||||||
|
author: authentik bot <github-bot@goauthentik.io>
|
||||||
|
- uses: peter-evans/enable-pull-request-automerge@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
|
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||||
|
merge-method: squash
|
||||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -14,7 +14,9 @@
|
|||||||
"webauthn",
|
"webauthn",
|
||||||
"traefik",
|
"traefik",
|
||||||
"passwordless",
|
"passwordless",
|
||||||
"kubernetes"
|
"kubernetes",
|
||||||
|
"sso",
|
||||||
|
"slo"
|
||||||
],
|
],
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
|
@ -59,19 +59,18 @@ These are the current packages:
|
|||||||
authentik
|
authentik
|
||||||
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
||||||
├── api - General API Configuration (Routes, Schema and general API utilities)
|
├── api - General API Configuration (Routes, Schema and general API utilities)
|
||||||
|
├── blueprints - Handle managed models and their state.
|
||||||
├── core - Core authentik functionality, central routes, core Models
|
├── core - Core authentik functionality, central routes, core Models
|
||||||
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
||||||
├── events - Event Log, middleware and signals to generate signals
|
├── events - Event Log, middleware and signals to generate signals
|
||||||
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
||||||
├── lib - Generic library of functions, few dependencies on other packages.
|
├── lib - Generic library of functions, few dependencies on other packages.
|
||||||
├── managed - Handle managed models and their state.
|
|
||||||
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
||||||
├── policies - General PolicyEngine
|
├── policies - General PolicyEngine
|
||||||
│ ├── dummy - A Dummy policy used for testing
|
│ ├── dummy - A Dummy policy used for testing
|
||||||
│ ├── event_matcher - Match events based on different criteria
|
│ ├── event_matcher - Match events based on different criteria
|
||||||
│ ├── expiry - Check when a user's password was last set
|
│ ├── expiry - Check when a user's password was last set
|
||||||
│ ├── expression - Execute any arbitrary python code
|
│ ├── expression - Execute any arbitrary python code
|
||||||
│ ├── hibp - Check a password against HaveIBeenPwned
|
|
||||||
│ ├── password - Check a password against several rules
|
│ ├── password - Check a password against several rules
|
||||||
│ └── reputation - Check the user's/client's reputation
|
│ └── reputation - Check the user's/client's reputation
|
||||||
├── providers
|
├── providers
|
||||||
|
@ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \
|
|||||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||||
|
|
||||||
# Stage 4: Build go proxy
|
# Stage 4: Build go proxy
|
||||||
FROM docker.io/golang:1.19.4-bullseye AS go-builder
|
FROM docker.io/golang:1.19.5-bullseye AS go-builder
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
|
|
||||||
@ -50,6 +50,7 @@ RUN go build -o /work/authentik ./cmd/server/
|
|||||||
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
||||||
|
|
||||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
||||||
|
ENV GEOIPUPDATE_VERBOSE="true"
|
||||||
|
|
||||||
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||||
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
|
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
|
||||||
@ -57,7 +58,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
|||||||
/bin/sh -c "\
|
/bin/sh -c "\
|
||||||
export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
|
export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
|
||||||
export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
|
export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
|
||||||
/usr/bin/entry.sh || exit 0 \
|
/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0 \
|
||||||
"
|
"
|
||||||
|
|
||||||
# Stage 6: Run
|
# Stage 6: Run
|
||||||
|
9
Makefile
9
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 ./...
|
||||||
|
|
||||||
|
@ -6,8 +6,8 @@ Authentik takes security very seriously. We follow the rules of [responsible dis
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| --------- | ------------------ |
|
| --------- | ------------------ |
|
||||||
| 2022.11.x | :white_check_mark: |
|
|
||||||
| 2022.12.x | :white_check_mark: |
|
| 2022.12.x | :white_check_mark: |
|
||||||
|
| 2023.1.x | :white_check_mark: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
from os import environ
|
from os import environ
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
__version__ = "2022.12.3"
|
__version__ = "2023.1.2"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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():
|
||||||
|
@ -31,6 +31,16 @@ def validate_auth(header: bytes) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def bearer_auth(raw_header: bytes) -> Optional[User]:
|
def bearer_auth(raw_header: bytes) -> Optional[User]:
|
||||||
|
"""raw_header in the Format of `Bearer ....`"""
|
||||||
|
user = auth_user_lookup(raw_header)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthenticationFailed("Token invalid/expired")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def auth_user_lookup(raw_header: bytes) -> Optional[User]:
|
||||||
"""raw_header in the Format of `Bearer ....`"""
|
"""raw_header in the Format of `Bearer ....`"""
|
||||||
from authentik.providers.oauth2.models import RefreshToken
|
from authentik.providers.oauth2.models import RefreshToken
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -3,13 +3,12 @@ from base64 import b64encode
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
from authentik.api.authentication import bearer_auth
|
from authentik.api.authentication import bearer_auth
|
||||||
from authentik.blueprints.tests import reconcile_app
|
from authentik.blueprints.tests import reconcile_app
|
||||||
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
||||||
from authentik.core.tests.utils import create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||||
@ -36,9 +35,18 @@ class TestAPIAuth(TestCase):
|
|||||||
|
|
||||||
def test_bearer_valid(self):
|
def test_bearer_valid(self):
|
||||||
"""Test valid token"""
|
"""Test valid token"""
|
||||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
|
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=create_test_admin_user())
|
||||||
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
||||||
|
|
||||||
|
def test_bearer_valid_deactivated(self):
|
||||||
|
"""Test valid token"""
|
||||||
|
user = create_test_admin_user()
|
||||||
|
user.is_active = False
|
||||||
|
user.save()
|
||||||
|
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=user)
|
||||||
|
with self.assertRaises(AuthenticationFailed):
|
||||||
|
bearer_auth(f"Bearer {token.key}".encode())
|
||||||
|
|
||||||
def test_managed_outpost(self):
|
def test_managed_outpost(self):
|
||||||
"""Test managed outpost"""
|
"""Test managed outpost"""
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
@ -56,7 +64,7 @@ class TestAPIAuth(TestCase):
|
|||||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||||
)
|
)
|
||||||
refresh = RefreshToken.objects.create(
|
refresh = RefreshToken.objects.create(
|
||||||
user=get_anonymous_user(),
|
user=create_test_admin_user(),
|
||||||
provider=provider,
|
provider=provider,
|
||||||
refresh_token=generate_id(),
|
refresh_token=generate_id(),
|
||||||
_scope=SCOPE_AUTHENTIK_API,
|
_scope=SCOPE_AUTHENTIK_API,
|
||||||
@ -69,7 +77,7 @@ class TestAPIAuth(TestCase):
|
|||||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||||
)
|
)
|
||||||
refresh = RefreshToken.objects.create(
|
refresh = RefreshToken.objects.create(
|
||||||
user=get_anonymous_user(),
|
user=create_test_admin_user(),
|
||||||
provider=provider,
|
provider=provider,
|
||||||
refresh_token=generate_id(),
|
refresh_token=generate_id(),
|
||||||
_scope="",
|
_scope="",
|
||||||
|
@ -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},
|
||||||
|
@ -57,9 +57,10 @@ class AuthentikBlueprintsConfig(ManagedAppConfig):
|
|||||||
|
|
||||||
def reconcile_blueprints_discover(self):
|
def reconcile_blueprints_discover(self):
|
||||||
"""Run blueprint discovery"""
|
"""Run blueprint discovery"""
|
||||||
from authentik.blueprints.v1.tasks import blueprints_discover
|
from authentik.blueprints.v1.tasks import blueprints_discover, clear_failed_blueprints
|
||||||
|
|
||||||
blueprints_discover.delay()
|
blueprints_discover.delay()
|
||||||
|
clear_failed_blueprints.delay()
|
||||||
|
|
||||||
def import_models(self):
|
def import_models(self):
|
||||||
super().import_models()
|
super().import_models()
|
||||||
|
@ -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:
|
||||||
|
@ -9,4 +9,9 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
"schedule": crontab(minute=fqdn_rand("blueprints_v1_discover"), hour="*"),
|
"schedule": crontab(minute=fqdn_rand("blueprints_v1_discover"), hour="*"),
|
||||||
"options": {"queue": "authentik_scheduled"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
|
"blueprints_v1_cleanup": {
|
||||||
|
"task": "authentik.blueprints.v1.tasks.clear_failed_blueprints",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("blueprints_v1_cleanup"), hour="*"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ entries:
|
|||||||
pk: cb954fd4-65a5-4ad9-b1ee-180ee9559cf4
|
pk: cb954fd4-65a5-4ad9-b1ee-180ee9559cf4
|
||||||
model: authentik_stages_prompt.prompt
|
model: authentik_stages_prompt.prompt
|
||||||
attrs:
|
attrs:
|
||||||
|
name: qwerweqrq
|
||||||
field_key: username
|
field_key: username
|
||||||
label: Username
|
label: Username
|
||||||
type: username
|
type: username
|
||||||
|
51
authentik/blueprints/tests/fixtures/tags.yaml
vendored
51
authentik/blueprints/tests/fixtures/tags.yaml
vendored
@ -3,6 +3,12 @@ context:
|
|||||||
foo: bar
|
foo: bar
|
||||||
policy_property: name
|
policy_property: name
|
||||||
policy_property_value: foo-bar-baz-qux
|
policy_property_value: foo-bar-baz-qux
|
||||||
|
sequence:
|
||||||
|
- foo
|
||||||
|
- bar
|
||||||
|
mapping:
|
||||||
|
key1: value
|
||||||
|
key2: 2
|
||||||
entries:
|
entries:
|
||||||
- model: !Format ["%s", authentik_sources_oauth.oauthsource]
|
- model: !Format ["%s", authentik_sources_oauth.oauthsource]
|
||||||
state: !Format ["%s", present]
|
state: !Format ["%s", present]
|
||||||
@ -19,7 +25,7 @@ entries:
|
|||||||
[slug, default-source-authentication],
|
[slug, default-source-authentication],
|
||||||
]
|
]
|
||||||
enrollment_flow:
|
enrollment_flow:
|
||||||
!Find [authentik_flows.Flow, [slug, default-source-enrollment]]
|
!Find [!Format ["%s", authentik_flows.Flow], [slug, default-source-enrollment]]
|
||||||
- attrs:
|
- attrs:
|
||||||
expression: return True
|
expression: return True
|
||||||
identifiers:
|
identifiers:
|
||||||
@ -92,6 +98,49 @@ entries:
|
|||||||
]
|
]
|
||||||
if_true_simple: !If [!Context foo, true, text]
|
if_true_simple: !If [!Context foo, true, text]
|
||||||
if_false_simple: !If [null, false, 2]
|
if_false_simple: !If [null, false, 2]
|
||||||
|
enumerate_mapping_to_mapping: !Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
MAP,
|
||||||
|
[!Format ["prefix-%s", !Index 0], !Format ["other-prefix-%s", !Value 0]]
|
||||||
|
]
|
||||||
|
enumerate_mapping_to_sequence: !Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
SEQ,
|
||||||
|
!Format ["prefixed-pair-%s-%s", !Index 0, !Value 0]
|
||||||
|
]
|
||||||
|
enumerate_sequence_to_sequence: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
SEQ,
|
||||||
|
!Format ["prefixed-items-%s-%s", !Index 0, !Value 0]
|
||||||
|
]
|
||||||
|
enumerate_sequence_to_mapping: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
MAP,
|
||||||
|
[!Format ["index: %d", !Index 0], !Value 0]
|
||||||
|
]
|
||||||
|
nested_complex_enumeration: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
MAP,
|
||||||
|
[
|
||||||
|
!Index 0,
|
||||||
|
!Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
MAP,
|
||||||
|
[
|
||||||
|
!Format ["%s", !Index 0],
|
||||||
|
[
|
||||||
|
!Enumerate [!Value 2, SEQ, !Format ["prefixed-%s", !Value 0]],
|
||||||
|
{
|
||||||
|
outer_value: !Value 1,
|
||||||
|
outer_index: !Index 1,
|
||||||
|
middle_value: !Value 0,
|
||||||
|
middle_index: !Index 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
identifiers:
|
identifiers:
|
||||||
name: test
|
name: test
|
||||||
conditions:
|
conditions:
|
||||||
|
@ -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):
|
||||||
|
@ -13,7 +13,7 @@ from authentik.tenants.models import Tenant
|
|||||||
class TestPackaged(TransactionTestCase):
|
class TestPackaged(TransactionTestCase):
|
||||||
"""Empty class, test methods are added dynamically"""
|
"""Empty class, test methods are added dynamically"""
|
||||||
|
|
||||||
@apply_blueprint("default/90-default-tenant.yaml")
|
@apply_blueprint("default/default-tenant.yaml")
|
||||||
def test_decorator_static(self):
|
def test_decorator_static(self):
|
||||||
"""Test @apply_blueprint decorator"""
|
"""Test @apply_blueprint decorator"""
|
||||||
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
|
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -207,15 +262,21 @@ class TestBlueprintsV1(TransactionTestCase):
|
|||||||
with transaction_rollback():
|
with transaction_rollback():
|
||||||
# First stage fields
|
# First stage fields
|
||||||
username_prompt = Prompt.objects.create(
|
username_prompt = Prompt.objects.create(
|
||||||
field_key="username", label="Username", order=0, type=FieldTypes.TEXT
|
name=generate_id(),
|
||||||
|
field_key="username",
|
||||||
|
label="Username",
|
||||||
|
order=0,
|
||||||
|
type=FieldTypes.TEXT,
|
||||||
)
|
)
|
||||||
password = Prompt.objects.create(
|
password = Prompt.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
field_key="password",
|
field_key="password",
|
||||||
label="Password",
|
label="Password",
|
||||||
order=1,
|
order=1,
|
||||||
type=FieldTypes.PASSWORD,
|
type=FieldTypes.PASSWORD,
|
||||||
)
|
)
|
||||||
password_repeat = Prompt.objects.create(
|
password_repeat = Prompt.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
field_key="password_repeat",
|
field_key="password_repeat",
|
||||||
label="Password (repeat)",
|
label="Password (repeat)",
|
||||||
order=2,
|
order=2,
|
||||||
|
@ -43,3 +43,28 @@ class TestBlueprintsV1API(APITestCase):
|
|||||||
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_api_blank(self):
|
||||||
|
"""Test blank"""
|
||||||
|
res = self.client.post(
|
||||||
|
reverse("authentik_api:blueprintinstance-list"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_content(self):
|
||||||
|
"""Test blank"""
|
||||||
|
res = self.client.post(
|
||||||
|
reverse("authentik_api:blueprintinstance-list"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
"content": '{"version": 3}',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertJSONEqual(res.content.decode(), {"content": ["Failed to validate blueprint"]})
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
"""transfer common classes"""
|
"""transfer common classes"""
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from copy import copy
|
||||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from operator import ixor
|
from operator import ixor
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from typing import Any, Literal, Optional, Union
|
from typing import Any, Iterable, Literal, Mapping, Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from deepmerge import always_merger
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db.models import Model, Q
|
from django.db.models import Model, Q
|
||||||
from rest_framework.fields import Field
|
from rest_framework.fields import Field
|
||||||
@ -64,11 +66,13 @@ class BlueprintEntry:
|
|||||||
identifiers: dict[str, Any] = field(default_factory=dict)
|
identifiers: dict[str, Any] = field(default_factory=dict)
|
||||||
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
|
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
|
||||||
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
|
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
|
||||||
|
|
||||||
|
def __post_init__(self, *args, **kwargs) -> None:
|
||||||
|
self.__tag_contexts: list["YAMLTagContext"] = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
|
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
|
||||||
"""Convert a SerializerModel instance to a blueprint Entry"""
|
"""Convert a SerializerModel instance to a blueprint Entry"""
|
||||||
@ -85,17 +89,46 @@ class BlueprintEntry:
|
|||||||
attrs=all_attrs,
|
attrs=all_attrs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_tag_context(
|
||||||
|
self,
|
||||||
|
depth: int = 0,
|
||||||
|
context_tag_type: Optional[type["YAMLTagContext"] | tuple["YAMLTagContext", ...]] = None,
|
||||||
|
) -> "YAMLTagContext":
|
||||||
|
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("depth must be a positive number or zero")
|
||||||
|
|
||||||
|
if context_tag_type:
|
||||||
|
contexts = [x for x in self.__tag_contexts if isinstance(x, context_tag_type)]
|
||||||
|
else:
|
||||||
|
contexts = self.__tag_contexts
|
||||||
|
|
||||||
|
try:
|
||||||
|
return contexts[-(depth + 1)]
|
||||||
|
except IndexError:
|
||||||
|
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}")
|
||||||
|
|
||||||
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
|
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
|
||||||
"""Check if we have any special tags that need handling"""
|
"""Check if we have any special tags that need handling"""
|
||||||
|
val = copy(value)
|
||||||
|
|
||||||
|
if isinstance(value, YAMLTagContext):
|
||||||
|
self.__tag_contexts.append(value)
|
||||||
|
|
||||||
if isinstance(value, YAMLTag):
|
if isinstance(value, YAMLTag):
|
||||||
return value.resolve(self, blueprint)
|
val = value.resolve(self, blueprint)
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for key, inner_value in value.items():
|
for key, inner_value in value.items():
|
||||||
value[key] = self.tag_resolver(inner_value, blueprint)
|
val[key] = self.tag_resolver(inner_value, blueprint)
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
for idx, inner_value in enumerate(value):
|
for idx, inner_value in enumerate(value):
|
||||||
value[idx] = self.tag_resolver(inner_value, blueprint)
|
val[idx] = self.tag_resolver(inner_value, blueprint)
|
||||||
return value
|
|
||||||
|
if isinstance(value, YAMLTagContext):
|
||||||
|
self.__tag_contexts.pop()
|
||||||
|
|
||||||
|
return val
|
||||||
|
|
||||||
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
|
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
|
||||||
"""Get attributes of this entry, with all yaml tags resolved"""
|
"""Get attributes of this entry, with all yaml tags resolved"""
|
||||||
@ -145,12 +178,19 @@ class YAMLTag:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class YAMLTagContext:
|
||||||
|
"""Base class for all YAML Tag Contexts"""
|
||||||
|
|
||||||
|
def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
"""Implement yaml tag context logic"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class KeyOf(YAMLTag):
|
class KeyOf(YAMLTag):
|
||||||
"""Reference another object by their ID"""
|
"""Reference another object by their ID"""
|
||||||
|
|
||||||
id_from: str
|
id_from: str
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.id_from = node.value
|
self.id_from = node.value
|
||||||
@ -177,7 +217,6 @@ class Env(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Optional[Any]
|
default: Optional[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
@ -197,7 +236,6 @@ class Context(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Optional[Any]
|
default: Optional[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
@ -220,7 +258,6 @@ class Format(YAMLTag):
|
|||||||
format_string: str
|
format_string: str
|
||||||
args: list[Any]
|
args: list[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.format_string = node.value[0].value
|
self.format_string = node.value[0].value
|
||||||
@ -245,15 +282,12 @@ class Format(YAMLTag):
|
|||||||
class Find(YAMLTag):
|
class Find(YAMLTag):
|
||||||
"""Find any object"""
|
"""Find any object"""
|
||||||
|
|
||||||
model_name: str
|
model_name: str | YAMLTag
|
||||||
conditions: list[list]
|
conditions: list[list]
|
||||||
|
|
||||||
model_class: type[Model]
|
|
||||||
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.model_name = node.value[0].value
|
self.model_name = loader.construct_object(node.value[0])
|
||||||
self.model_class = apps.get_model(*self.model_name.split("."))
|
|
||||||
self.conditions = []
|
self.conditions = []
|
||||||
for raw_node in node.value[1:]:
|
for raw_node in node.value[1:]:
|
||||||
values = []
|
values = []
|
||||||
@ -262,6 +296,13 @@ class Find(YAMLTag):
|
|||||||
self.conditions.append(values)
|
self.conditions.append(values)
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
if isinstance(self.model_name, YAMLTag):
|
||||||
|
model_name = self.model_name.resolve(entry, blueprint)
|
||||||
|
else:
|
||||||
|
model_name = self.model_name
|
||||||
|
|
||||||
|
model_class = apps.get_model(*model_name.split("."))
|
||||||
|
|
||||||
query = Q()
|
query = Q()
|
||||||
for cond in self.conditions:
|
for cond in self.conditions:
|
||||||
if isinstance(cond[0], YAMLTag):
|
if isinstance(cond[0], YAMLTag):
|
||||||
@ -273,7 +314,7 @@ class Find(YAMLTag):
|
|||||||
else:
|
else:
|
||||||
query_value = cond[1]
|
query_value = cond[1]
|
||||||
query &= Q(**{query_key: query_value})
|
query &= Q(**{query_key: query_value})
|
||||||
instance = self.model_class.objects.filter(query).first()
|
instance = model_class.objects.filter(query).first()
|
||||||
if instance:
|
if instance:
|
||||||
return instance.pk
|
return instance.pk
|
||||||
return None
|
return None
|
||||||
@ -296,7 +337,6 @@ class Condition(YAMLTag):
|
|||||||
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
||||||
}
|
}
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.mode = node.value[0].value
|
self.mode = node.value[0].value
|
||||||
@ -329,7 +369,6 @@ class If(YAMLTag):
|
|||||||
when_true: Any
|
when_true: Any
|
||||||
when_false: Any
|
when_false: Any
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.condition = loader.construct_object(node.value[0])
|
self.condition = loader.construct_object(node.value[0])
|
||||||
@ -351,6 +390,133 @@ class If(YAMLTag):
|
|||||||
raise EntryInvalidError(exc)
|
raise EntryInvalidError(exc)
|
||||||
|
|
||||||
|
|
||||||
|
class Enumerate(YAMLTag, YAMLTagContext):
|
||||||
|
"""Iterate over an iterable."""
|
||||||
|
|
||||||
|
iterable: YAMLTag | Iterable
|
||||||
|
item_body: Any
|
||||||
|
output_body: Literal["SEQ", "MAP"]
|
||||||
|
|
||||||
|
_OUTPUT_BODIES = {
|
||||||
|
"SEQ": (list, lambda a, b: [*a, b]),
|
||||||
|
"MAP": (
|
||||||
|
dict,
|
||||||
|
lambda a, b: always_merger.merge(
|
||||||
|
a, {b[0]: b[1]} if isinstance(b, (tuple, list)) else b
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.iterable = loader.construct_object(node.value[0])
|
||||||
|
self.output_body = node.value[1].value
|
||||||
|
self.item_body = loader.construct_object(node.value[2])
|
||||||
|
self.__current_context: tuple[Any, Any] = tuple()
|
||||||
|
|
||||||
|
def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
return self.__current_context
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0:
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__} tag's iterable references this tag's context. "
|
||||||
|
"This is a noop. Check you are setting depth bigger than 0."
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(self.iterable, YAMLTag):
|
||||||
|
iterable = self.iterable.resolve(entry, blueprint)
|
||||||
|
else:
|
||||||
|
iterable = self.iterable
|
||||||
|
|
||||||
|
if not isinstance(iterable, Iterable):
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__}'s iterable must be an iterable "
|
||||||
|
"such as a sequence or a mapping"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(iterable, Mapping):
|
||||||
|
iterable = tuple(iterable.items())
|
||||||
|
else:
|
||||||
|
iterable = tuple(enumerate(iterable))
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise EntryInvalidError(exc)
|
||||||
|
|
||||||
|
result = output_class()
|
||||||
|
|
||||||
|
self.__current_context = tuple()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in iterable:
|
||||||
|
self.__current_context = item
|
||||||
|
resolved_body = entry.tag_resolver(self.item_body, blueprint)
|
||||||
|
result = add_fn(result, resolved_body)
|
||||||
|
if not isinstance(result, output_class):
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"Invalid {self.__class__.__name__} item found: {resolved_body}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.__current_context = tuple()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class EnumeratedItem(YAMLTag):
|
||||||
|
"""Get the current item value and index provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
depth: int
|
||||||
|
|
||||||
|
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
|
||||||
|
|
||||||
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.depth = int(node.value)
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
try:
|
||||||
|
context_tag: Enumerate = entry._get_tag_context(
|
||||||
|
depth=self.depth,
|
||||||
|
context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
if self.depth == 0:
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__} tags are only usable "
|
||||||
|
f"inside an {Enumerate.__name__} tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise EntryInvalidError(f"{self.__class__.__name__} tag: {exc}")
|
||||||
|
|
||||||
|
return context_tag.get_context(entry, blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
class Index(EnumeratedItem):
|
||||||
|
"""Get the current item index provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
context = super().resolve(entry, blueprint)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return context[0]
|
||||||
|
except IndexError: # pragma: no cover
|
||||||
|
raise EntryInvalidError(f"Empty/invalid context: {context}")
|
||||||
|
|
||||||
|
|
||||||
|
class Value(EnumeratedItem):
|
||||||
|
"""Get the current item value provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
context = super().resolve(entry, blueprint)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return context[1]
|
||||||
|
except IndexError: # pragma: no cover
|
||||||
|
raise EntryInvalidError(f"Empty/invalid context: {context}")
|
||||||
|
|
||||||
|
|
||||||
class BlueprintDumper(SafeDumper):
|
class BlueprintDumper(SafeDumper):
|
||||||
"""Dump dataclasses to yaml"""
|
"""Dump dataclasses to yaml"""
|
||||||
|
|
||||||
@ -394,6 +560,9 @@ class BlueprintLoader(SafeLoader):
|
|||||||
self.add_constructor("!Condition", Condition)
|
self.add_constructor("!Condition", Condition)
|
||||||
self.add_constructor("!If", If)
|
self.add_constructor("!If", If)
|
||||||
self.add_constructor("!Env", Env)
|
self.add_constructor("!Env", Env)
|
||||||
|
self.add_constructor("!Enumerate", Enumerate)
|
||||||
|
self.add_constructor("!Value", Value)
|
||||||
|
self.add_constructor("!Index", Index)
|
||||||
|
|
||||||
|
|
||||||
class EntryInvalidError(SentryIgnoredException):
|
class EntryInvalidError(SentryIgnoredException):
|
||||||
|
@ -3,3 +3,4 @@
|
|||||||
LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system"
|
LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system"
|
||||||
LABEL_AUTHENTIK_INSTANTIATE = "blueprints.goauthentik.io/instantiate"
|
LABEL_AUTHENTIK_INSTANTIATE = "blueprints.goauthentik.io/instantiate"
|
||||||
LABEL_AUTHENTIK_GENERATED = "blueprints.goauthentik.io/generated"
|
LABEL_AUTHENTIK_GENERATED = "blueprints.goauthentik.io/generated"
|
||||||
|
LABEL_AUTHENTIK_DESCRIPTION = "blueprints.goauthentik.io/description"
|
||||||
|
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
|
@ -219,3 +219,14 @@ def apply_blueprint(self: MonitoredTask, instance_pk: str):
|
|||||||
finally:
|
finally:
|
||||||
if instance:
|
if instance:
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task()
|
||||||
|
def clear_failed_blueprints():
|
||||||
|
"""Remove blueprints which couldn't be fetched"""
|
||||||
|
# Exclude OCI blueprints as those might be temporarily unavailable
|
||||||
|
for blueprint in BlueprintInstance.objects.exclude(path__startswith="oci://"):
|
||||||
|
try:
|
||||||
|
blueprint.retrieve()
|
||||||
|
except BlueprintRetrievalFailed:
|
||||||
|
blueprint.delete()
|
||||||
|
@ -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)
|
||||||
)
|
)
|
||||||
|
@ -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:
|
||||||
@ -157,7 +156,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@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()
|
||||||
@ -182,7 +180,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@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,9 @@ 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.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
|
from django.core.cache import cache
|
||||||
|
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
|
||||||
@ -56,6 +59,7 @@ from authentik.core.models import (
|
|||||||
USER_ATTRIBUTE_SA,
|
USER_ATTRIBUTE_SA,
|
||||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||||
USER_PATH_SERVICE_ACCOUNT,
|
USER_PATH_SERVICE_ACCOUNT,
|
||||||
|
AuthenticatedSession,
|
||||||
Group,
|
Group,
|
||||||
Token,
|
Token,
|
||||||
TokenIntents,
|
TokenIntents,
|
||||||
@ -199,38 +203,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 +272,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 +406,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 +435,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 +452,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 +467,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 +491,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()
|
||||||
@ -560,3 +564,14 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def partial_update(self, request: Request, *args, **kwargs) -> Response:
|
||||||
|
response = super().partial_update(request, *args, **kwargs)
|
||||||
|
instance: User = self.get_object()
|
||||||
|
if not instance.is_active:
|
||||||
|
sessions = AuthenticatedSession.objects.filter(user=instance)
|
||||||
|
session_ids = sessions.values_list("session_key", flat=True)
|
||||||
|
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
|
||||||
|
sessions.delete()
|
||||||
|
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
||||||
|
return response
|
||||||
|
@ -49,7 +49,6 @@ class Command(BaseCommand):
|
|||||||
return namespace
|
return namespace
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_handler(sender, instance: Model, created: bool, **_):
|
def post_save_handler(sender, instance: Model, created: bool, **_):
|
||||||
"""Signal handler for all object's post_save"""
|
"""Signal handler for all object's post_save"""
|
||||||
if not should_log_model(instance):
|
if not should_log_model(instance):
|
||||||
@ -65,7 +64,6 @@ class Command(BaseCommand):
|
|||||||
).save()
|
).save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_delete_handler(sender, instance: Model, **_):
|
def pre_delete_handler(sender, instance: Model, **_):
|
||||||
"""Signal handler for all object's pre_delete"""
|
"""Signal handler for all object's pre_delete"""
|
||||||
if not should_log_model(instance): # pragma: no cover
|
if not should_log_model(instance): # pragma: no cover
|
||||||
|
@ -20,7 +20,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||||
"""Clear user's application cache upon application creation"""
|
"""Clear user's application cache upon application creation"""
|
||||||
from authentik.core.api.applications import user_app_cache_key
|
from authentik.core.api.applications import user_app_cache_key
|
||||||
@ -36,7 +35,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
||||||
"""Create an AuthenticatedSession from request"""
|
"""Create an AuthenticatedSession from request"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
@ -47,7 +45,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
||||||
"""Delete AuthenticatedSession if it exists"""
|
"""Delete AuthenticatedSession if it exists"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
|
@ -48,7 +48,6 @@ class Action(Enum):
|
|||||||
class MessageStage(StageView):
|
class MessageStage(StageView):
|
||||||
"""Show a pre-configured message after the flow is done"""
|
"""Show a pre-configured message after the flow is done"""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Show a pre-configured message after the flow is done"""
|
"""Show a pre-configured message after the flow is done"""
|
||||||
message = getattr(self.executor.current_stage, "message", "")
|
message = getattr(self.executor.current_stage, "message", "")
|
||||||
@ -209,7 +208,6 @@ class SourceFlowManager:
|
|||||||
response.error_message = error.messages
|
response.error_message = error.messages
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||||
"""Hook to override stages which are appended to the flow"""
|
"""Hook to override stages which are appended to the flow"""
|
||||||
if not self.source.enrollment_flow:
|
if not self.source.enrollment_flow:
|
||||||
@ -264,7 +262,6 @@ class SourceFlowManager:
|
|||||||
flow_slug=flow.slug,
|
flow_slug=flow.slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_auth(
|
def handle_auth(
|
||||||
self,
|
self,
|
||||||
connection: UserSourceConnection,
|
connection: UserSourceConnection,
|
||||||
|
@ -13,7 +13,6 @@ class PostUserEnrollmentStage(StageView):
|
|||||||
"""Dynamically injected stage which saves the Connection after
|
"""Dynamically injected stage which saves the Connection after
|
||||||
the user has been enrolled."""
|
the user has been enrolled."""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Stage used after the user has been enrolled"""
|
"""Stage used after the user has been enrolled"""
|
||||||
connection: UserSourceConnection = self.executor.plan.context[
|
connection: UserSourceConnection = self.executor.plan.context[
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
<div class="ak-login-container">
|
<div class="ak-login-container">
|
||||||
<header class="pf-c-login__header">
|
<header class="pf-c-login__header">
|
||||||
<div class="pf-c-brand ak-brand">
|
<div class="pf-c-brand ak-brand">
|
||||||
<img src="{{ tenant.branding_logo }}" alt="authentik icon" />
|
<img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{% block main_container %}
|
{% block main_container %}
|
||||||
|
@ -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):
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
"""Test Users API"""
|
"""Test Users API"""
|
||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
|
from django.core.cache import cache
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import AuthenticatedSession, User
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||||
from authentik.flows.models import FlowDesignation
|
from authentik.flows.models import FlowDesignation
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
@ -257,3 +259,26 @@ class TestUsersAPI(APITestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
body = loads(response.content.decode())
|
body = loads(response.content.decode())
|
||||||
self.assertEqual(body["user"]["avatar"], "bar")
|
self.assertEqual(body["user"]["avatar"], "bar")
|
||||||
|
|
||||||
|
def test_session_delete(self):
|
||||||
|
"""Ensure sessions are deleted when a user is deactivated"""
|
||||||
|
user = create_test_admin_user()
|
||||||
|
session_id = generate_id()
|
||||||
|
AuthenticatedSession.objects.create(
|
||||||
|
user=user,
|
||||||
|
session_key=session_id,
|
||||||
|
last_ip="",
|
||||||
|
)
|
||||||
|
cache.set(KEY_PREFIX + session_id, "foo")
|
||||||
|
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("authentik_api:user-detail", kwargs={"pk": user.pk}),
|
||||||
|
data={
|
||||||
|
"is_active": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertIsNone(cache.get(KEY_PREFIX + session_id))
|
||||||
|
self.assertFalse(AuthenticatedSession.objects.filter(session_key=session_id).exists())
|
||||||
|
@ -47,11 +47,11 @@ def create_test_tenant() -> Tenant:
|
|||||||
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
||||||
"""Generate a certificate for testing"""
|
"""Generate a certificate for testing"""
|
||||||
builder = CertificateBuilder(
|
builder = CertificateBuilder(
|
||||||
|
name=f"{generate_id()}.self-signed.goauthentik.io",
|
||||||
use_ec_private_key=use_ec_private_key,
|
use_ec_private_key=use_ec_private_key,
|
||||||
)
|
)
|
||||||
builder.common_name = "goauthentik.io"
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
builder.common_name = generate_id()
|
builder.common_name = generate_id()
|
||||||
|
@ -187,7 +187,6 @@ class CertificateKeyPairFilter(FilterSet):
|
|||||||
label="Only return certificate-key pairs with keys", method="filter_has_key"
|
label="Only return certificate-key pairs with keys", method="filter_has_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
||||||
"""Only return certificate-key pairs with keys"""
|
"""Only return certificate-key pairs with keys"""
|
||||||
return queryset.exclude(key_data__exact="")
|
return queryset.exclude(key_data__exact="")
|
||||||
@ -236,10 +235,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
data = CertificateGenerationSerializer(data=request.data)
|
data = CertificateGenerationSerializer(data=request.data)
|
||||||
if not data.is_valid():
|
if not data.is_valid():
|
||||||
return Response(data.errors, status=400)
|
return Response(data.errors, status=400)
|
||||||
builder = CertificateBuilder()
|
raw_san = data.validated_data.get("subject_alt_name", "")
|
||||||
builder.common_name = data.validated_data["common_name"]
|
sans = raw_san.split(",") if raw_san != "" else []
|
||||||
|
builder = CertificateBuilder(data.validated_data["common_name"])
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","),
|
subject_alt_names=sans,
|
||||||
validity_days=int(data.validated_data["validity_days"]),
|
validity_days=int(data.validated_data["validity_days"]),
|
||||||
)
|
)
|
||||||
instance = builder.save()
|
instance = builder.save()
|
||||||
@ -257,7 +257,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
responses={200: CertificateDataSerializer(many=False)},
|
responses={200: CertificateDataSerializer(many=False)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def view_certificate(self, request: Request, pk: str) -> Response:
|
def view_certificate(self, request: Request, pk: str) -> Response:
|
||||||
"""Return certificate-key pairs certificate and log access"""
|
"""Return certificate-key pairs certificate and log access"""
|
||||||
certificate: CertificateKeyPair = self.get_object()
|
certificate: CertificateKeyPair = self.get_object()
|
||||||
@ -288,7 +287,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
responses={200: CertificateDataSerializer(many=False)},
|
responses={200: CertificateDataSerializer(many=False)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def view_private_key(self, request: Request, pk: str) -> Response:
|
def view_private_key(self, request: Request, pk: str) -> Response:
|
||||||
"""Return certificate-key pairs private key and log access"""
|
"""Return certificate-key pairs private key and log access"""
|
||||||
certificate: CertificateKeyPair = self.get_object()
|
certificate: CertificateKeyPair = self.get_object()
|
||||||
|
@ -27,20 +27,16 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder("authentik Internal JWT Certificate")
|
||||||
builder.common_name = "goauthentik.io"
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=["goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
if not cert:
|
if not cert:
|
||||||
|
|
||||||
cert = CertificateKeyPair()
|
cert = CertificateKeyPair()
|
||||||
cert.certificate_data = builder.certificate
|
builder.cert = cert
|
||||||
cert.key_data = builder.private_key
|
builder.cert.managed = MANAGED_KEY
|
||||||
cert.name = "authentik Internal JWT Certificate"
|
builder.save()
|
||||||
cert.managed = MANAGED_KEY
|
|
||||||
cert.save()
|
|
||||||
|
|
||||||
def reconcile_managed_jwt_cert(self):
|
def reconcile_managed_jwt_cert(self):
|
||||||
"""Ensure managed JWT certificate"""
|
"""Ensure managed JWT certificate"""
|
||||||
@ -63,10 +59,6 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
name = "authentik Self-signed Certificate"
|
name = "authentik Self-signed Certificate"
|
||||||
if CertificateKeyPair.objects.filter(name=name).exists():
|
if CertificateKeyPair.objects.filter(name=name).exists():
|
||||||
return
|
return
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder(name)
|
||||||
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||||
CertificateKeyPair.objects.create(
|
builder.save()
|
||||||
name="authentik Self-signed Certificate",
|
|
||||||
certificate_data=builder.certificate,
|
|
||||||
key_data=builder.private_key,
|
|
||||||
)
|
|
||||||
|
@ -21,13 +21,13 @@ class CertificateBuilder:
|
|||||||
|
|
||||||
_use_ec_private_key: bool
|
_use_ec_private_key: bool
|
||||||
|
|
||||||
def __init__(self, use_ec_private_key=False):
|
def __init__(self, name: str, use_ec_private_key=False):
|
||||||
self._use_ec_private_key = use_ec_private_key
|
self._use_ec_private_key = use_ec_private_key
|
||||||
self.__public_key = None
|
self.__public_key = None
|
||||||
self.__private_key = None
|
self.__private_key = None
|
||||||
self.__builder = None
|
self.__builder = None
|
||||||
self.__certificate = None
|
self.__certificate = None
|
||||||
self.common_name = "authentik Self-signed Certificate"
|
self.common_name = name
|
||||||
self.cert = CertificateKeyPair()
|
self.cert = CertificateKeyPair()
|
||||||
|
|
||||||
def save(self) -> CertificateKeyPair:
|
def save(self) -> CertificateKeyPair:
|
||||||
@ -57,7 +57,10 @@ class CertificateBuilder:
|
|||||||
one_day = datetime.timedelta(1, 0, 0)
|
one_day = datetime.timedelta(1, 0, 0)
|
||||||
self.__private_key = self.generate_private_key()
|
self.__private_key = self.generate_private_key()
|
||||||
self.__public_key = self.__private_key.public_key()
|
self.__public_key = self.__private_key.public_key()
|
||||||
alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]
|
alt_names: list[x509.GeneralName] = []
|
||||||
|
for alt_name in subject_alt_names or []:
|
||||||
|
if alt_name.strip() != "":
|
||||||
|
alt_names.append(x509.DNSName(alt_name))
|
||||||
self.__builder = (
|
self.__builder = (
|
||||||
x509.CertificateBuilder()
|
x509.CertificateBuilder()
|
||||||
.subject_name(
|
.subject_name(
|
||||||
@ -76,12 +79,15 @@ class CertificateBuilder:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.add_extension(x509.SubjectAlternativeName(alt_names), critical=True)
|
|
||||||
.not_valid_before(datetime.datetime.today() - one_day)
|
.not_valid_before(datetime.datetime.today() - one_day)
|
||||||
.not_valid_after(datetime.datetime.today() + datetime.timedelta(days=validity_days))
|
.not_valid_after(datetime.datetime.today() + datetime.timedelta(days=validity_days))
|
||||||
.serial_number(int(uuid.uuid4()))
|
.serial_number(int(uuid.uuid4()))
|
||||||
.public_key(self.__public_key)
|
.public_key(self.__public_key)
|
||||||
)
|
)
|
||||||
|
if alt_names:
|
||||||
|
self.__builder = self.__builder.add_extension(
|
||||||
|
x509.SubjectAlternativeName(alt_names), critical=True
|
||||||
|
)
|
||||||
self.__certificate = self.__builder.sign(
|
self.__certificate = self.__builder.sign(
|
||||||
private_key=self.__private_key,
|
private_key=self.__private_key,
|
||||||
algorithm=hashes.SHA256(),
|
algorithm=hashes.SHA256(),
|
||||||
|
@ -4,6 +4,8 @@ from json import loads
|
|||||||
from os import makedirs
|
from os import makedirs
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from cryptography.x509.extensions import SubjectAlternativeName
|
||||||
|
from cryptography.x509.general_name import DNSName
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@ -14,7 +16,7 @@ from authentik.crypto.builder import CertificateBuilder
|
|||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.generators import generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.providers.oauth2.models import OAuth2Provider
|
from authentik.providers.oauth2.models import OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
@ -54,8 +56,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_builder(self):
|
def test_builder(self):
|
||||||
"""Test Builder"""
|
"""Test Builder"""
|
||||||
builder = CertificateBuilder()
|
name = generate_id()
|
||||||
builder.common_name = "test-cert"
|
builder = CertificateBuilder(name)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
builder.save()
|
builder.save()
|
||||||
builder.build(
|
builder.build(
|
||||||
@ -64,17 +66,49 @@ class TestCrypto(APITestCase):
|
|||||||
)
|
)
|
||||||
instance = builder.save()
|
instance = builder.save()
|
||||||
now = datetime.datetime.today()
|
now = datetime.datetime.today()
|
||||||
self.assertEqual(instance.name, "test-cert")
|
self.assertEqual(instance.name, name)
|
||||||
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
|
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
|
||||||
|
|
||||||
def test_builder_api(self):
|
def test_builder_api(self):
|
||||||
"""Test Builder (via API)"""
|
"""Test Builder (via API)"""
|
||||||
self.client.force_login(create_test_admin_user())
|
self.client.force_login(create_test_admin_user())
|
||||||
|
name = generate_id()
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("authentik_api:certificatekeypair-generate"),
|
reverse("authentik_api:certificatekeypair-generate"),
|
||||||
data={"common_name": "foo", "subject_alt_name": "bar,baz", "validity_days": 3},
|
data={"common_name": name, "subject_alt_name": "bar,baz", "validity_days": 3},
|
||||||
)
|
)
|
||||||
self.assertTrue(CertificateKeyPair.objects.filter(name="foo").exists())
|
key = CertificateKeyPair.objects.filter(name=name).first()
|
||||||
|
self.assertIsNotNone(key)
|
||||||
|
ext: SubjectAlternativeName = key.certificate.extensions[0].value
|
||||||
|
self.assertIsInstance(ext, SubjectAlternativeName)
|
||||||
|
self.assertIsInstance(ext[0], DNSName)
|
||||||
|
self.assertEqual(ext[0].value, "bar")
|
||||||
|
self.assertIsInstance(ext[1], DNSName)
|
||||||
|
self.assertEqual(ext[1].value, "baz")
|
||||||
|
|
||||||
|
def test_builder_api_empty_san(self):
|
||||||
|
"""Test Builder (via API)"""
|
||||||
|
self.client.force_login(create_test_admin_user())
|
||||||
|
name = generate_id()
|
||||||
|
self.client.post(
|
||||||
|
reverse("authentik_api:certificatekeypair-generate"),
|
||||||
|
data={"common_name": name, "subject_alt_name": "", "validity_days": 3},
|
||||||
|
)
|
||||||
|
key = CertificateKeyPair.objects.filter(name=name).first()
|
||||||
|
self.assertIsNotNone(key)
|
||||||
|
self.assertEqual(len(key.certificate.extensions), 0)
|
||||||
|
|
||||||
|
def test_builder_api_empty_san_multiple(self):
|
||||||
|
"""Test Builder (via API)"""
|
||||||
|
self.client.force_login(create_test_admin_user())
|
||||||
|
name = generate_id()
|
||||||
|
self.client.post(
|
||||||
|
reverse("authentik_api:certificatekeypair-generate"),
|
||||||
|
data={"common_name": name, "subject_alt_name": ", ", "validity_days": 3},
|
||||||
|
)
|
||||||
|
key = CertificateKeyPair.objects.filter(name=name).first()
|
||||||
|
self.assertIsNotNone(key)
|
||||||
|
self.assertEqual(len(key.certificate.extensions), 0)
|
||||||
|
|
||||||
def test_builder_api_invalid(self):
|
def test_builder_api_invalid(self):
|
||||||
"""Test Builder (via API) (invalid)"""
|
"""Test Builder (via API) (invalid)"""
|
||||||
@ -193,8 +227,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_discovery(self):
|
def test_discovery(self):
|
||||||
"""Test certificate discovery"""
|
"""Test certificate discovery"""
|
||||||
builder = CertificateBuilder()
|
name = generate_id()
|
||||||
builder.common_name = "test-cert"
|
builder = CertificateBuilder(name)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
builder.save()
|
builder.save()
|
||||||
builder.build(
|
builder.build(
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
"""Events API Views"""
|
"""Events API Views"""
|
||||||
|
from datetime import timedelta
|
||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
from django.db.models.aggregates import Count
|
from django.db.models.aggregates import Count
|
||||||
from django.db.models.fields.json import KeyTextTransform
|
from django.db.models.fields.json import KeyTextTransform
|
||||||
|
from django.db.models.functions import ExtractDay
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
@ -81,7 +83,6 @@ class EventsFilter(django_filters.FilterSet):
|
|||||||
label="Tenant name",
|
label="Tenant name",
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_context_model_pk(self, queryset, name, value):
|
def filter_context_model_pk(self, queryset, name, value):
|
||||||
"""Because we store the PK as UUID.hex,
|
"""Because we store the PK as UUID.hex,
|
||||||
we need to remove the dashes that a client may send. We can't use a
|
we need to remove the dashes that a client may send. We can't use a
|
||||||
@ -178,7 +179,7 @@ class EventViewSet(ModelViewSet):
|
|||||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||||
.filter(action=filtered_action)
|
.filter(action=filtered_action)
|
||||||
.filter(**query)
|
.filter(**query)
|
||||||
.get_events_per_day()
|
.get_events_per(timedelta(weeks=4), ExtractDay, 30)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
||||||
|
@ -80,7 +80,6 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
|||||||
request=OpenApiTypes.NONE,
|
request=OpenApiTypes.NONE,
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def test(self, request: Request, pk=None) -> Response:
|
def test(self, request: Request, pk=None) -> Response:
|
||||||
"""Send example notification using selected transport. Requires
|
"""Send example notification using selected transport. Requires
|
||||||
Modify permissions."""
|
Modify permissions."""
|
||||||
|
@ -12,12 +12,21 @@ from django.http import HttpRequest, HttpResponse
|
|||||||
from django_otp.plugins.otp_static.models import StaticToken
|
from django_otp.plugins.otp_static.models import StaticToken
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession, User
|
from authentik.core.models import (
|
||||||
|
AuthenticatedSession,
|
||||||
|
PropertyMapping,
|
||||||
|
Provider,
|
||||||
|
Source,
|
||||||
|
User,
|
||||||
|
UserSourceConnection,
|
||||||
|
)
|
||||||
from authentik.events.models import Event, EventAction, Notification
|
from authentik.events.models import Event, EventAction, Notification
|
||||||
from authentik.events.utils import model_to_dict
|
from authentik.events.utils import model_to_dict
|
||||||
from authentik.flows.models import FlowToken
|
from authentik.flows.models import FlowToken, Stage
|
||||||
from authentik.lib.sentry import before_send
|
from authentik.lib.sentry import before_send
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
|
from authentik.outposts.models import OutpostServiceConnection
|
||||||
|
from authentik.policies.models import Policy, PolicyBindingModel
|
||||||
|
|
||||||
IGNORED_MODELS = (
|
IGNORED_MODELS = (
|
||||||
Event,
|
Event,
|
||||||
@ -27,6 +36,14 @@ IGNORED_MODELS = (
|
|||||||
StaticToken,
|
StaticToken,
|
||||||
Session,
|
Session,
|
||||||
FlowToken,
|
FlowToken,
|
||||||
|
Provider,
|
||||||
|
Source,
|
||||||
|
PropertyMapping,
|
||||||
|
UserSourceConnection,
|
||||||
|
Stage,
|
||||||
|
OutpostServiceConnection,
|
||||||
|
Policy,
|
||||||
|
PolicyBindingModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -34,7 +51,7 @@ def should_log_model(model: Model) -> bool:
|
|||||||
"""Return true if operation on `model` should be logged"""
|
"""Return true if operation on `model` should be logged"""
|
||||||
if model.__module__.startswith("silk"):
|
if model.__module__.startswith("silk"):
|
||||||
return False
|
return False
|
||||||
return not isinstance(model, IGNORED_MODELS)
|
return model.__class__ not in IGNORED_MODELS
|
||||||
|
|
||||||
|
|
||||||
class EventNewThread(Thread):
|
class EventNewThread(Thread):
|
||||||
@ -101,7 +118,6 @@ class AuditMiddleware:
|
|||||||
self.disconnect(request)
|
self.disconnect(request)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def process_exception(self, request: HttpRequest, exception: Exception):
|
def process_exception(self, request: HttpRequest, exception: Exception):
|
||||||
"""Disconnect handlers in case of exception"""
|
"""Disconnect handlers in case of exception"""
|
||||||
self.disconnect(request)
|
self.disconnect(request)
|
||||||
@ -125,7 +141,6 @@ class AuditMiddleware:
|
|||||||
thread.run()
|
thread.run()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_handler(
|
def post_save_handler(
|
||||||
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||||
):
|
):
|
||||||
@ -137,7 +152,6 @@ class AuditMiddleware:
|
|||||||
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||||
"""Signal handler for all object's pre_delete"""
|
"""Signal handler for all object's pre_delete"""
|
||||||
if not should_log_model(instance): # pragma: no cover
|
if not should_log_model(instance): # pragma: no cover
|
||||||
|
@ -11,8 +11,7 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, ExpressionWrapper, F
|
from django.db.models import Count, ExpressionWrapper, F
|
||||||
from django.db.models.fields import DurationField
|
from django.db.models.fields import DurationField
|
||||||
from django.db.models.functions import ExtractHour
|
from django.db.models.functions import Extract
|
||||||
from django.db.models.functions.datetime import ExtractDay
|
|
||||||
from django.db.models.manager import Manager
|
from django.db.models.manager import Manager
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@ -111,48 +110,35 @@ class EventAction(models.TextChoices):
|
|||||||
class EventQuerySet(QuerySet):
|
class EventQuerySet(QuerySet):
|
||||||
"""Custom events query set with helper functions"""
|
"""Custom events query set with helper functions"""
|
||||||
|
|
||||||
def get_events_per_hour(self) -> list[dict[str, int]]:
|
def get_events_per(
|
||||||
|
self,
|
||||||
|
time_since: timedelta,
|
||||||
|
extract: Extract,
|
||||||
|
data_points: int,
|
||||||
|
) -> list[dict[str, int]]:
|
||||||
"""Get event count by hour in the last day, fill with zeros"""
|
"""Get event count by hour in the last day, fill with zeros"""
|
||||||
date_from = now() - timedelta(days=1)
|
_now = now()
|
||||||
|
max_since = timedelta(days=60)
|
||||||
|
# Allow maximum of 60 days to limit load
|
||||||
|
if time_since.total_seconds() > max_since.total_seconds():
|
||||||
|
time_since = max_since
|
||||||
|
date_from = _now - time_since
|
||||||
result = (
|
result = (
|
||||||
self.filter(created__gte=date_from)
|
self.filter(created__gte=date_from)
|
||||||
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
.annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
|
||||||
.annotate(age_hours=ExtractHour("age"))
|
.annotate(age_interval=extract("age"))
|
||||||
.values("age_hours")
|
.values("age_interval")
|
||||||
.annotate(count=Count("pk"))
|
.annotate(count=Count("pk"))
|
||||||
.order_by("age_hours")
|
.order_by("age_interval")
|
||||||
)
|
)
|
||||||
data = Counter({int(d["age_hours"]): d["count"] for d in result})
|
data = Counter({int(d["age_interval"]): d["count"] for d in result})
|
||||||
results = []
|
results = []
|
||||||
_now = now()
|
interval_delta = time_since / data_points
|
||||||
for hour in range(0, -24, -1):
|
for interval in range(1, -data_points, -1):
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
|
"x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
|
||||||
"y_cord": data[hour * -1],
|
"y_cord": data[interval * -1],
|
||||||
}
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_events_per_day(self) -> list[dict[str, int]]:
|
|
||||||
"""Get event count by hour in the last day, fill with zeros"""
|
|
||||||
date_from = now() - timedelta(weeks=4)
|
|
||||||
result = (
|
|
||||||
self.filter(created__gte=date_from)
|
|
||||||
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
|
||||||
.annotate(age_days=ExtractDay("age"))
|
|
||||||
.values("age_days")
|
|
||||||
.annotate(count=Count("pk"))
|
|
||||||
.order_by("age_days")
|
|
||||||
)
|
|
||||||
data = Counter({int(d["age_days"]): d["count"] for d in result})
|
|
||||||
results = []
|
|
||||||
_now = now()
|
|
||||||
for day in range(0, -30, -1):
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000,
|
|
||||||
"y_cord": data[day * -1],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
@ -165,13 +151,14 @@ class EventManager(Manager):
|
|||||||
"""use custom queryset"""
|
"""use custom queryset"""
|
||||||
return EventQuerySet(self.model, using=self._db)
|
return EventQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
def get_events_per_hour(self) -> list[dict[str, int]]:
|
def get_events_per(
|
||||||
|
self,
|
||||||
|
time_since: timedelta,
|
||||||
|
extract: Extract,
|
||||||
|
data_points: int,
|
||||||
|
) -> list[dict[str, int]]:
|
||||||
"""Wrap method from queryset"""
|
"""Wrap method from queryset"""
|
||||||
return self.get_queryset().get_events_per_hour()
|
return self.get_queryset().get_events_per(time_since, extract, data_points)
|
||||||
|
|
||||||
def get_events_per_day(self) -> list[dict[str, int]]:
|
|
||||||
"""Wrap method from queryset"""
|
|
||||||
return self.get_queryset().get_events_per_day()
|
|
||||||
|
|
||||||
|
|
||||||
class Event(SerializerModel, ExpiringModel):
|
class Event(SerializerModel, ExpiringModel):
|
||||||
@ -461,7 +448,7 @@ class NotificationTransport(SerializerModel):
|
|||||||
# pyright: reportGeneralTypeIssues=false
|
# pyright: reportGeneralTypeIssues=false
|
||||||
return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter
|
return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter
|
||||||
except (SMTPException, ConnectionError, OSError) as exc:
|
except (SMTPException, ConnectionError, OSError) as exc:
|
||||||
raise NotificationTransportError from exc
|
raise NotificationTransportError(exc) from exc
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> "Serializer":
|
def serializer(self) -> "Serializer":
|
||||||
|
@ -102,7 +102,7 @@ class TaskInfo:
|
|||||||
key = CACHE_KEY_PREFIX + self.task_name
|
key = CACHE_KEY_PREFIX + self.task_name
|
||||||
if self.result.uid:
|
if self.result.uid:
|
||||||
key += f"/{self.result.uid}"
|
key += f"/{self.result.uid}"
|
||||||
self.task_name += f"_{self.result.uid}"
|
self.task_name += f"/{self.result.uid}"
|
||||||
self.set_prom_metrics()
|
self.set_prom_metrics()
|
||||||
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
||||||
|
|
||||||
|
@ -22,7 +22,6 @@ SESSION_LOGIN_EVENT = "login_event"
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
||||||
"""Log successful login"""
|
"""Log successful login"""
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
@ -45,14 +44,12 @@ def get_login_event(request: HttpRequest) -> Optional[Event]:
|
|||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
@ -60,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,
|
||||||
@ -74,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(
|
||||||
@ -83,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
|
||||||
|
@ -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]
|
||||||
|
|
||||||
@ -162,11 +162,11 @@ class FlowExecutorView(APIView):
|
|||||||
token.delete()
|
token.delete()
|
||||||
if not isinstance(plan, FlowPlan):
|
if not isinstance(plan, FlowPlan):
|
||||||
return None
|
return None
|
||||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
plan.context[PLAN_CONTEXT_IS_RESTORED] = True
|
||||||
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
|
||||||
|
@ -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"]
|
||||||
|
@ -5,13 +5,20 @@ from contextlib import contextmanager
|
|||||||
from glob import glob
|
from glob import glob
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
from pathlib import Path
|
||||||
from sys import argv, stderr
|
from sys import argv, stderr
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from django.conf import ImproperlyConfigured
|
from django.conf import ImproperlyConfigured
|
||||||
|
from watchdog.events import (
|
||||||
|
FileModifiedEvent,
|
||||||
|
FileSystemEvent,
|
||||||
|
FileSystemEventHandler,
|
||||||
|
)
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob(
|
SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob(
|
||||||
"/etc/authentik/config.d/*.yml", recursive=True
|
"/etc/authentik/config.d/*.yml", recursive=True
|
||||||
@ -38,9 +45,47 @@ class ConfigLoader:
|
|||||||
A variable like AUTHENTIK_POSTGRESQL__HOST would translate to postgresql.host"""
|
A variable like AUTHENTIK_POSTGRESQL__HOST would translate to postgresql.host"""
|
||||||
|
|
||||||
loaded_file = []
|
loaded_file = []
|
||||||
|
observer: Observer
|
||||||
|
|
||||||
|
class FSObserver(FileSystemEventHandler):
|
||||||
|
"""File system observer"""
|
||||||
|
|
||||||
|
loader: "ConfigLoader"
|
||||||
|
path: str
|
||||||
|
container: Optional[dict] = None
|
||||||
|
key: Optional[str] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
loader: "ConfigLoader",
|
||||||
|
path: str,
|
||||||
|
container: Optional[dict] = None,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.loader = loader
|
||||||
|
self.path = path
|
||||||
|
self.container = container
|
||||||
|
self.key = key
|
||||||
|
|
||||||
|
def on_any_event(self, event: FileSystemEvent):
|
||||||
|
if not isinstance(event, FileModifiedEvent):
|
||||||
|
return
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
if event.src_path != self.path:
|
||||||
|
return
|
||||||
|
if self.container and self.key:
|
||||||
|
with open(self.path, "r", encoding="utf8") as _file:
|
||||||
|
self.container[self.key] = _file.read()
|
||||||
|
else:
|
||||||
|
self.loader.log("info", "Updating from changed file", file=self.path)
|
||||||
|
self.loader.update_from_file(self.path, watch=False)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.observer = Observer()
|
||||||
|
self.observer.start()
|
||||||
self.__config = {}
|
self.__config = {}
|
||||||
base_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../.."))
|
base_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../.."))
|
||||||
for path in SEARCH_PATHS:
|
for path in SEARCH_PATHS:
|
||||||
@ -81,11 +126,11 @@ class ConfigLoader:
|
|||||||
root[key] = self.update(root.get(key, {}), value)
|
root[key] = self.update(root.get(key, {}), value)
|
||||||
else:
|
else:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = self.parse_uri(value)
|
value = self.parse_uri(value, root, key)
|
||||||
root[key] = value
|
root[key] = value
|
||||||
return root
|
return root
|
||||||
|
|
||||||
def parse_uri(self, value: str) -> str:
|
def parse_uri(self, value: str, container: dict[str, Any], key: Optional[str] = None, ) -> str:
|
||||||
"""Parse string values which start with a URI"""
|
"""Parse string values which start with a URI"""
|
||||||
url = urlparse(value)
|
url = urlparse(value)
|
||||||
if url.scheme == "env":
|
if url.scheme == "env":
|
||||||
@ -94,12 +139,22 @@ class ConfigLoader:
|
|||||||
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()
|
||||||
|
if key:
|
||||||
|
self.observer.schedule(
|
||||||
|
ConfigLoader.FSObserver(
|
||||||
|
self,
|
||||||
|
url.path,
|
||||||
|
container,
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
Path(url.path).parent,
|
||||||
|
)
|
||||||
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
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def update_from_file(self, path: str):
|
def update_from_file(self, path: str, watch=True):
|
||||||
"""Update config from file contents"""
|
"""Update config from file contents"""
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf8") as file:
|
with open(path, encoding="utf8") as file:
|
||||||
@ -107,6 +162,8 @@ class ConfigLoader:
|
|||||||
self.update(self.__config, yaml.safe_load(file))
|
self.update(self.__config, yaml.safe_load(file))
|
||||||
self.log("debug", "Loaded config", file=path)
|
self.log("debug", "Loaded config", file=path)
|
||||||
self.loaded_file.append(path)
|
self.loaded_file.append(path)
|
||||||
|
if watch:
|
||||||
|
self.observer.schedule(ConfigLoader.FSObserver(self, path), Path(path).parent)
|
||||||
except yaml.YAMLError as exc:
|
except yaml.YAMLError as exc:
|
||||||
raise ImproperlyConfigured from exc
|
raise ImproperlyConfigured from exc
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
@ -181,13 +238,12 @@ class ConfigLoader:
|
|||||||
if comp not in root:
|
if comp not in root:
|
||||||
root[comp] = {}
|
root[comp] = {}
|
||||||
root = root.get(comp, {})
|
root = root.get(comp, {})
|
||||||
root[path_parts[-1]] = value
|
self.parse_uri(value, root, path_parts[-1])
|
||||||
|
|
||||||
def y_bool(self, path: str, default=False) -> bool:
|
def y_bool(self, path: str, default=False) -> bool:
|
||||||
"""Wrapper for y that converts value into boolean"""
|
"""Wrapper for y that converts value into boolean"""
|
||||||
return str(self.y(path, default)).lower() == "true"
|
return str(self.y(path, default)).lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
CONFIG = ConfigLoader()
|
CONFIG = ConfigLoader()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
@ -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()
|
||||||
|
@ -5,7 +5,7 @@ from tempfile import mkstemp
|
|||||||
from django.conf import ImproperlyConfigured
|
from django.conf import ImproperlyConfigured
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from authentik.lib.config import ENV_PREFIX, ConfigLoader
|
from authentik.lib.config import CONFIG, ENV_PREFIX, ConfigLoader
|
||||||
|
|
||||||
|
|
||||||
class TestConfig(TestCase):
|
class TestConfig(TestCase):
|
||||||
@ -31,8 +31,8 @@ class TestConfig(TestCase):
|
|||||||
"""Test URI parsing (environment)"""
|
"""Test URI parsing (environment)"""
|
||||||
config = ConfigLoader()
|
config = ConfigLoader()
|
||||||
environ["foo"] = "bar"
|
environ["foo"] = "bar"
|
||||||
self.assertEqual(config.parse_uri("env://foo"), "bar")
|
self.assertEqual(config.parse_uri("env://foo", {}), "bar")
|
||||||
self.assertEqual(config.parse_uri("env://foo?bar"), "bar")
|
self.assertEqual(config.parse_uri("env://foo?bar", {}), "bar")
|
||||||
|
|
||||||
def test_uri_file(self):
|
def test_uri_file(self):
|
||||||
"""Test URI parsing (file load)"""
|
"""Test URI parsing (file load)"""
|
||||||
@ -41,8 +41,8 @@ class TestConfig(TestCase):
|
|||||||
write(file, "foo".encode())
|
write(file, "foo".encode())
|
||||||
_, file2_name = mkstemp()
|
_, file2_name = mkstemp()
|
||||||
chmod(file2_name, 0o000) # Remove all permissions so we can't read the file
|
chmod(file2_name, 0o000) # Remove all permissions so we can't read the file
|
||||||
self.assertEqual(config.parse_uri(f"file://{file_name}"), "foo")
|
self.assertEqual(config.parse_uri(f"file://{file_name}", {}), "foo")
|
||||||
self.assertEqual(config.parse_uri(f"file://{file2_name}?def"), "def")
|
self.assertEqual(config.parse_uri(f"file://{file2_name}?def", {}), "def")
|
||||||
unlink(file_name)
|
unlink(file_name)
|
||||||
unlink(file2_name)
|
unlink(file2_name)
|
||||||
|
|
||||||
@ -59,3 +59,13 @@ class TestConfig(TestCase):
|
|||||||
config.update_from_file(file2_name)
|
config.update_from_file(file2_name)
|
||||||
unlink(file_name)
|
unlink(file_name)
|
||||||
unlink(file2_name)
|
unlink(file2_name)
|
||||||
|
|
||||||
|
def test_update(self):
|
||||||
|
"""Test change to file"""
|
||||||
|
file, file_name = mkstemp()
|
||||||
|
write(file, b"test")
|
||||||
|
CONFIG.y_set("test.file", f"file://{file_name}")
|
||||||
|
self.assertEqual(CONFIG.y("test.file"), "test")
|
||||||
|
write(file, "test2")
|
||||||
|
self.assertEqual(CONFIG.y("test.file"), "test2")
|
||||||
|
unlink(file_name)
|
||||||
|
@ -148,7 +148,6 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
@extend_schema(responses={200: OutpostHealthSerializer(many=True)})
|
@extend_schema(responses={200: OutpostHealthSerializer(many=True)})
|
||||||
@action(methods=["GET"], detail=True, pagination_class=None)
|
@action(methods=["GET"], detail=True, pagination_class=None)
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def health(self, request: Request, pk: int) -> Response:
|
def health(self, request: Request, pk: int) -> Response:
|
||||||
"""Get outposts current health"""
|
"""Get outposts current health"""
|
||||||
outpost: Outpost = self.get_object()
|
outpost: Outpost = self.get_object()
|
||||||
|
@ -91,7 +91,6 @@ class ServiceConnectionViewSet(
|
|||||||
|
|
||||||
@extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
|
@extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def state(self, request: Request, pk: str) -> Response:
|
def state(self, request: Request, pk: str) -> Response:
|
||||||
"""Get the service connection's state"""
|
"""Get the service connection's state"""
|
||||||
connection = self.get_object()
|
connection = self.get_object()
|
||||||
|
@ -69,7 +69,6 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
self.outpost = outpost
|
self.outpost = outpost
|
||||||
self.last_uid = self.channel_name
|
self.last_uid = self.channel_name
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def disconnect(self, code):
|
def disconnect(self, code):
|
||||||
if self.outpost and self.last_uid:
|
if self.outpost and self.last_uid:
|
||||||
state = OutpostState.for_instance_uid(self.outpost, self.last_uid)
|
state = OutpostState.for_instance_uid(self.outpost, self.last_uid)
|
||||||
@ -127,7 +126,6 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
|
response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
|
||||||
self.send_json(asdict(response))
|
self.send_json(asdict(response))
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def event_update(self, event): # pragma: no cover
|
def event_update(self, event): # pragma: no cover
|
||||||
"""Event handler which is called by post_save signals, Send update instruction"""
|
"""Event handler which is called by post_save signals, Send update instruction"""
|
||||||
self.send_json(
|
self.send_json(
|
||||||
|
@ -183,7 +183,7 @@ class DockerController(BaseController):
|
|||||||
try:
|
try:
|
||||||
self.client.images.pull(image)
|
self.client.images.pull(image)
|
||||||
except DockerException: # pragma: no cover
|
except DockerException: # pragma: no cover
|
||||||
image = f"goauthentik.io/{self.outpost.type}:latest"
|
image = f"ghcr.io/goauthentik/{self.outpost.type}:latest"
|
||||||
self.client.images.pull(image)
|
self.client.images.pull(image)
|
||||||
return image
|
return image
|
||||||
|
|
||||||
|
@ -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),
|
|
||||||
]
|
]
|
||||||
|
@ -4,6 +4,7 @@ from django.urls.base import reverse
|
|||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||||
from authentik.flows.tests import FlowTestCase
|
from authentik.flows.tests import FlowTestCase
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.policies.password.models import PasswordPolicy
|
from authentik.policies.password.models import PasswordPolicy
|
||||||
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ class TestPasswordPolicyFlow(FlowTestCase):
|
|||||||
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||||
|
|
||||||
password_prompt = Prompt.objects.create(
|
password_prompt = Prompt.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
field_key="password",
|
field_key="password",
|
||||||
label="PASSWORD_LABEL",
|
label="PASSWORD_LABEL",
|
||||||
type=FieldTypes.PASSWORD,
|
type=FieldTypes.PASSWORD,
|
||||||
|
@ -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
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user