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