Compare commits

..

7 Commits

Author SHA1 Message Date
afa4234036 release: 2024.4.0 2024-04-24 17:42:10 +02:00
ca22a4deaf website/docs: finalize 2024.4 release notes (cherry-pick #9396) (#9398)
website/docs: finalize 2024.4 release notes (#9396)

* website/docs: finalize 2024.4 release notes



* escape curly braces manually



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-24 17:41:29 +02:00
7b7a3d34ec web/admin: fix document title for admin interface (cherry-pick #9362) (#9365)
web/admin: fix document title for admin interface (#9362)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-20 23:05:23 +02:00
b1ca579397 website/docs: release notes 2024.4: add performance improvements values (cherry-pick #9356) (#9357)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-04-19 16:53:15 +00:00
c8072579c8 release: 2024.4.0-rc1 2024-04-19 16:05:20 +02:00
378a701fb9 root: bump blueprint schema version
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-04-19 16:05:15 +02:00
bba793d94c lifecycle: fix ak test-all command
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-04-19 16:04:39 +02:00
2375 changed files with 33606 additions and 602541 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2024.8.3
current_version = 2024.4.0
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
@ -17,8 +17,6 @@ optional_value = final
[bumpversion:file:pyproject.toml]
[bumpversion:file:package.json]
[bumpversion:file:docker-compose.yml]
[bumpversion:file:schema.yml]

View File

@ -54,10 +54,9 @@ runs:
authentik:
outposts:
container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
global:
image:
repository: ghcr.io/goauthentik/dev-server
tag: ${{ inputs.tag }}
image:
repository: ghcr.io/goauthentik/dev-server
tag: ${{ inputs.tag }}
```
For arm64, use these values:
@ -66,10 +65,9 @@ runs:
authentik:
outposts:
container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
global:
image:
repository: ghcr.io/goauthentik/dev-server
tag: ${{ inputs.tag }}-arm64
image:
repository: ghcr.io/goauthentik/dev-server
tag: ${{ inputs.tag }}-arm64
```
Afterwards, run the upgrade commands from the latest release notes.

View File

@ -29,15 +29,9 @@ outputs:
imageTags:
description: "Docker image tags"
value: ${{ steps.ev.outputs.imageTags }}
attestImageNames:
description: "Docker image names used for attestation"
value: ${{ steps.ev.outputs.attestImageNames }}
imageMainTag:
description: "Docker image main tag"
value: ${{ steps.ev.outputs.imageMainTag }}
imageMainName:
description: "Docker image main name"
value: ${{ steps.ev.outputs.imageMainName }}
runs:
using: "composite"

View File

@ -7,12 +7,12 @@ from time import time
parser = configparser.ConfigParser()
parser.read(".bumpversion.cfg")
should_build = str(len(os.environ.get("DOCKER_USERNAME", "")) > 0).lower()
should_build = str(os.environ.get("DOCKER_USERNAME", None) is not None).lower()
branch_name = os.environ["GITHUB_REF"]
if os.environ.get("GITHUB_HEAD_REF", "") != "":
branch_name = os.environ["GITHUB_HEAD_REF"]
safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-").replace("'", "-")
safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
image_names = os.getenv("IMAGE_NAME").split(",")
image_arch = os.getenv("IMAGE_ARCH") or None
@ -50,25 +50,13 @@ else:
f"{name}:gh-{safe_branch_name}-{int(time())}-{sha[:7]}{suffix}", # Use by FluxCD
]
image_main_tag = image_tags[0].split(":")[-1]
def get_attest_image_names(image_with_tags: list[str]):
"""Attestation only for GHCR"""
image_tags = []
for image_name in set(name.split(":")[0] for name in image_with_tags):
if not image_name.startswith("ghcr.io"):
continue
image_tags.append(image_name)
return ",".join(set(image_tags))
image_main_tag = image_tags[0]
image_tags_rendered = ",".join(image_tags)
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
print(f"shouldBuild={should_build}", file=_output)
print(f"sha={sha}", file=_output)
print(f"version={version}", file=_output)
print(f"prerelease={prerelease}", file=_output)
print(f"imageTags={','.join(image_tags)}", file=_output)
print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output)
print(f"imageMainTag={image_main_tag}", file=_output)
print(f"imageMainName={image_tags[0]}", file=_output)
print("shouldBuild=%s" % should_build, file=_output)
print("sha=%s" % sha, file=_output)
print("version=%s" % version, file=_output)
print("prerelease=%s" % prerelease, file=_output)
print("imageTags=%s" % image_tags_rendered, file=_output)
print("imageMainTag=%s" % image_main_tag, file=_output)

View File

@ -1,3 +1,5 @@
version: "3.7"
services:
postgresql:
image: docker.io/library/postgres:${PSQL_TAG:-16}

View File

@ -4,4 +4,3 @@ hass
warmup
ontext
singed
assertIn

View File

@ -21,10 +21,7 @@ updates:
labels:
- dependencies
- package-ecosystem: npm
directories:
- "/web"
- "/tests/wdio"
- "/web/sfe"
directory: "/web"
schedule:
interval: daily
time: "04:00"
@ -33,6 +30,7 @@ updates:
open-pull-requests-limit: 10
commit-message:
prefix: "web:"
# TODO: deduplicate these groups
groups:
sentry:
patterns:
@ -44,11 +42,9 @@ updates:
- "babel-*"
eslint:
patterns:
- "@eslint/*"
- "@typescript-eslint/*"
- "eslint-*"
- "eslint"
- "typescript-eslint"
- "eslint-*"
storybook:
patterns:
- "@storybook/*"
@ -56,16 +52,42 @@ updates:
esbuild:
patterns:
- "@esbuild/*"
- "esbuild*"
rollup:
patterns:
- "@rollup/*"
- "rollup-*"
- "rollup*"
swc:
- package-ecosystem: npm
directory: "/tests/wdio"
schedule:
interval: daily
time: "04:00"
labels:
- dependencies
open-pull-requests-limit: 10
commit-message:
prefix: "web:"
# TODO: deduplicate these groups
groups:
sentry:
patterns:
- "@swc/*"
- "swc-*"
- "@sentry/*"
- "@spotlightjs/*"
babel:
patterns:
- "@babel/*"
- "babel-*"
eslint:
patterns:
- "@typescript-eslint/*"
- "eslint"
- "eslint-*"
storybook:
patterns:
- "@storybook/*"
- "*storybook*"
esbuild:
patterns:
- "@esbuild/*"
wdio:
patterns:
- "@wdio/*"

View File

@ -31,16 +31,11 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Upgrade /web
working-directory: web
working-directory: web/
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- name: Upgrade /web/packages/sfe
working-directory: web/packages/sfe
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@v7
- uses: peter-evans/create-pull-request@v6
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -50,6 +50,7 @@ jobs:
fail-fast: false
matrix:
psql:
- 12-alpine
- 15-alpine
- 16-alpine
steps:
@ -103,6 +104,7 @@ jobs:
fail-fast: false
matrix:
psql:
- 12-alpine
- 15-alpine
- 16-alpine
steps:
@ -120,12 +122,6 @@ jobs:
with:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: unit
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
test-integration:
runs-on: ubuntu-latest
timeout-minutes: 30
@ -134,7 +130,7 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1.10.0
uses: helm/kind-action@v1.9.0
- name: run integration
run: |
poetry run coverage run manage.py test tests/integration
@ -144,12 +140,6 @@ jobs:
with:
flags: integration
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: integration
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
test-e2e:
name: test-e2e (${{ matrix.job.name }})
runs-on: ubuntu-latest
@ -202,12 +192,6 @@ jobs:
with:
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: e2e
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
ci-core-mark:
needs:
- lint
@ -231,16 +215,13 @@ jobs:
permissions:
# Needed to upload contianer images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
@ -261,8 +242,7 @@ jobs:
- name: generate ts client
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
uses: docker/build-push-action@v5
with:
context: .
secrets: |
@ -272,16 +252,9 @@ jobs:
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache
cache-to: ${{ steps.ev.outputs.shouldBuild == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/${{ matrix.arch }}
- uses: actions/attest-build-provenance@v1
id: attest
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
pr-comment:
needs:
- build
@ -303,7 +276,6 @@ jobs:
with:
image-name: ghcr.io/goauthentik/dev-server
- name: Comment on PR
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: ./.github/actions/comment-pr-instructions
with:
tag: ${{ steps.ev.outputs.imageMainTag }}
tag: gh-${{ steps.ev.outputs.imageMainTag }}

View File

@ -29,9 +29,9 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v4
with:
version: latest
version: v1.54.2
args: --timeout 5000s --verbose
skip-cache: true
test-unittest:
@ -71,15 +71,12 @@ jobs:
permissions:
# Needed to upload contianer images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
@ -99,8 +96,7 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: Build Docker Image
id: push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: ${{ matrix.type }}.Dockerfile
@ -109,15 +105,8 @@ jobs:
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: linux/amd64,linux/arm64
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
cache-to: ${{ steps.ev.outputs.shouldBuild == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
- uses: actions/attest-build-provenance@v1
id: attest
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
cache-from: type=gha
cache-to: type=gha,mode=max
build-binary:
timeout-minutes: 120
needs:

View File

@ -12,29 +12,14 @@ on:
- version-*
jobs:
lint:
lint-eslint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- lint
- lint:lockfile
- tsc
- prettier-check
project:
- web
- tests/wdio
include:
- command: tsc
project: web
- command: lit-analyse
project: web
exclude:
- command: lint:lockfile
project: tests/wdio
- command: tsc
project: tests/wdio
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -43,16 +28,77 @@ jobs:
cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/
run: |
npm ci
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: Lint
- name: Eslint
working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }}
run: npm run lint
lint-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/package.json
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
strategy:
fail-fast: false
matrix:
project:
- web
- tests/wdio
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: prettier
working-directory: ${{ matrix.project }}/
run: npm run prettier-check
lint-lit-analyse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: |
npm ci
# lit-analyse doesn't understand path rewrites, so make it
# belive it's an actual module
cd node_modules/@goauthentik
ln -s ../../src/ web
- name: Generate API
run: make gen-client-ts
- name: lit-analyse
working-directory: web/
run: npm run lit-analyse
ci-web-mark:
needs:
- lint
- lint-eslint
- lint-prettier
- lint-lit-analyse
- lint-build
runs-on: ubuntu-latest
steps:
- run: echo mark
@ -74,21 +120,3 @@ jobs:
- name: build
working-directory: web/
run: npm run build
test:
needs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: test
working-directory: web/
run: npm run test || exit 0

View File

@ -12,21 +12,20 @@ on:
- version-*
jobs:
lint:
lint-prettier:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- lint:lockfile
- prettier-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
run: npm ci
- name: Lint
- name: prettier
working-directory: website/
run: npm run ${{ matrix.command }}
run: npm run prettier-check
test:
runs-on: ubuntu-latest
steps:
@ -63,7 +62,7 @@ jobs:
run: npm run ${{ matrix.job }}
ci-website-mark:
needs:
- lint
- lint-prettier
- test
- build
runs-on: ubuntu-latest

View File

@ -24,7 +24,7 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- run: poetry run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@v7
- uses: peter-evans/create-pull-request@v6
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -42,7 +42,7 @@ jobs:
with:
githubToken: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}
- uses: peter-evans/create-pull-request@v7
- uses: peter-evans/create-pull-request@v6
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
id: cpr
with:

View File

@ -11,13 +11,10 @@ jobs:
permissions:
# Needed to upload contianer images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
@ -43,32 +40,20 @@ jobs:
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
build-args: |
VERSION=${{ github.ref }}
tags: ${{ steps.ev.outputs.imageTags }}
platforms: linux/amd64,linux/arm64
- uses: actions/attest-build-provenance@v1
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
build-outpost:
runs-on: ubuntu-latest
permissions:
# Needed to upload contianer images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
@ -83,7 +68,7 @@ jobs:
with:
go-version-file: "go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
@ -109,22 +94,13 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
uses: docker/build-push-action@v5
with:
push: true
build-args: |
VERSION=${{ github.ref }}
tags: ${{ steps.ev.outputs.imageTags }}
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@v1
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
build-outpost-binary:
timeout-minutes: 120
runs-on: ubuntu-latest
@ -179,8 +155,8 @@ jobs:
- uses: actions/checkout@v4
- name: Run test suite in final docker images
run: |
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker compose pull -q
docker compose up --no-start
docker compose start postgresql redis
@ -202,8 +178,8 @@ jobs:
image-name: ghcr.io/goauthentik/server
- name: Get static files from docker image
run: |
docker pull ${{ steps.ev.outputs.imageMainName }}
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
docker pull ${{ steps.ev.outputs.imageMainTag }}
container=$(docker container create ${{ steps.ev.outputs.imageMainTag }})
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@v1

View File

@ -14,8 +14,8 @@ jobs:
- uses: actions/checkout@v4
- name: Pre-release test
run: |
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker buildx install
mkdir -p ./gen-ts-api
docker build -t testing:latest .

View File

@ -32,7 +32,7 @@ jobs:
poetry run ak compilemessages
make web-check-compile
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v6
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation

View File

@ -16,6 +16,6 @@
"ms-python.black-formatter",
"redhat.vscode-yaml",
"Tobermory.es6-string-html",
"unifiedjs.vscode-mdx"
"unifiedjs.vscode-mdx",
]
}

2
.vscode/launch.json vendored
View File

@ -22,6 +22,6 @@
},
"justMyCode": true,
"django": true
}
},
]
}

32
.vscode/settings.json vendored
View File

@ -4,35 +4,33 @@
"asgi",
"authentik",
"authn",
"entra",
"goauthentik",
"jwks",
"kubernetes",
"oidc",
"openid",
"passwordless",
"plex",
"saml",
"scim",
"slo",
"sso",
"totp",
"webauthn",
"traefik",
"webauthn"
"passwordless",
"kubernetes",
"sso",
"slo",
"scim",
],
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"yaml.customTags": [
"!Condition sequence",
"!Context scalar",
"!Enumerate sequence",
"!Env scalar",
"!Find sequence",
"!Format sequence",
"!If sequence",
"!Index scalar",
"!KeyOf scalar",
"!Value scalar"
"!Context scalar",
"!Context sequence",
"!Format sequence",
"!Condition sequence",
"!Env sequence",
"!Env scalar",
"!If sequence"
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index",
@ -49,7 +47,9 @@
"ignoreCase": false
}
],
"go.testFlags": ["-count=1"],
"go.testFlags": [
"-count=1"
],
"github-actions.workflows.pinned.workflows": [
".github/workflows/ci-main.yml"
]

62
.vscode/tasks.json vendored
View File

@ -2,67 +2,85 @@
"version": "2.0.0",
"tasks": [
{
"label": "authentik/core: make",
"label": "authentik[core]: format & test",
"command": "poetry",
"args": ["run", "make", "lint-fix", "lint"],
"presentation": {
"panel": "new"
},
"group": "test"
"args": [
"run",
"make"
],
"group": "build",
},
{
"label": "authentik/core: run",
"label": "authentik[core]: run",
"command": "poetry",
"args": ["run", "ak", "server"],
"args": [
"run",
"make",
"run",
],
"group": "build",
"presentation": {
"panel": "dedicated",
"group": "running"
}
},
},
{
"label": "authentik/web: make",
"label": "authentik[web]: format",
"command": "make",
"args": ["web"],
"group": "build"
"group": "build",
},
{
"label": "authentik/web: watch",
"label": "authentik[web]: watch",
"command": "make",
"args": ["web-watch"],
"group": "build",
"presentation": {
"panel": "dedicated",
"group": "running"
}
},
},
{
"label": "authentik: install",
"command": "make",
"args": ["install", "-j4"],
"group": "build"
"args": ["install"],
"group": "build",
},
{
"label": "authentik/website: make",
"label": "authentik: i18n-extract",
"command": "poetry",
"args": [
"run",
"make",
"i18n-extract"
],
"group": "build",
},
{
"label": "authentik[website]: format",
"command": "make",
"args": ["website"],
"group": "build"
"group": "build",
},
{
"label": "authentik/website: watch",
"label": "authentik[website]: watch",
"command": "make",
"args": ["website-watch"],
"group": "build",
"presentation": {
"panel": "dedicated",
"group": "running"
}
},
},
{
"label": "authentik/api: generate",
"label": "authentik[api]: generate",
"command": "poetry",
"args": ["run", "make", "gen"],
"args": [
"run",
"make",
"gen"
],
"group": "build"
}
},
]
}

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS website-builder
FROM --platform=${BUILDPLATFORM} docker.io/node:21 as website-builder
ENV NODE_ENV=production
@ -20,22 +20,17 @@ COPY ./SECURITY.md /work/
RUN npm run build-bundled
# Stage 2: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS web-builder
FROM --platform=${BUILDPLATFORM} docker.io/node:21 as web-builder
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
ENV NODE_ENV=production
WORKDIR /work/web
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev
COPY ./package.json /work
COPY ./web /work/web/
COPY ./website /work/website/
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
@ -43,7 +38,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build
# Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/golang:1.22.2-bookworm AS go-builder
ARG TARGETOS
ARG TARGETARCH
@ -54,11 +49,6 @@ ARG GOARCH=$TARGETARCH
WORKDIR /go/src/goauthentik.io
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
dpkg --add-architecture arm64 && \
apt-get update && \
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
--mount=type=cache,target=/go/pkg/mod \
@ -73,14 +63,14 @@ COPY ./internal /go/src/goauthentik.io/internal
COPY ./go.mod /go/src/goauthentik.io/go.mod
COPY ./go.sum /go/src/goauthentik.io/go.sum
ENV CGO_ENABLED=0
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server
GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 AS geoip
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="1"
@ -94,10 +84,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Python dependencies
FROM ghcr.io/goauthentik/fips-python:3.12.6-slim-bookworm-fips-full AS python-deps
ARG TARGETARCH
ARG TARGETVARIANT
FROM docker.io/python:3.12.3-slim-bookworm AS python-deps
WORKDIR /ak-root/poetry
@ -110,7 +97,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloa
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
apt-get update && \
# Required for installing pip packages
apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev libpq-dev
RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
--mount=type=bind,target=./poetry.lock,src=./poetry.lock \
@ -118,30 +105,29 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
--mount=type=cache,target=/root/.cache/pypoetry \
python -m venv /ak-root/venv/ && \
bash -c "source ${VENV_PATH}/bin/activate && \
pip3 install --upgrade pip && \
pip3 install poetry && \
poetry install --only=main --no-ansi --no-interaction --no-root && \
pip install --force-reinstall /wheels/*"
pip3 install --upgrade pip && \
pip3 install poetry && \
poetry install --only=main --no-ansi --no-interaction --no-root"
# Stage 6: Run
FROM ghcr.io/goauthentik/fips-python:3.12.6-slim-bookworm-fips-full AS final-image
FROM docker.io/python:3.12.3-slim-bookworm AS final-image
ARG VERSION
ARG GIT_BUILD_HASH
ARG VERSION
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
LABEL org.opencontainers.image.url=https://goauthentik.io
LABEL org.opencontainers.image.description="goauthentik.io Main server image, see https://goauthentik.io for more info."
LABEL org.opencontainers.image.source=https://github.com/goauthentik/authentik
LABEL org.opencontainers.image.version=${VERSION}
LABEL org.opencontainers.image.revision=${GIT_BUILD_HASH}
LABEL org.opencontainers.image.url https://goauthentik.io
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
LABEL org.opencontainers.image.source https://github.com/goauthentik/authentik
LABEL org.opencontainers.image.version ${VERSION}
LABEL org.opencontainers.image.revision ${GIT_BUILD_HASH}
WORKDIR /
# We cannot cache this layer otherwise we'll end up with a bigger image
RUN apt-get update && \
# Required for runtime
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates && \
apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 ca-certificates && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \
apt-get clean && \
@ -177,8 +163,6 @@ ENV TMPDIR=/dev/shm/ \
VENV_PATH="/ak-root/venv" \
POETRY_VIRTUALENVS_CREATE=false
ENV GOFIPS=1
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]
ENTRYPOINT [ "dumb-init", "--", "ak" ]

View File

@ -19,7 +19,6 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null)
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
-I .github/codespell-words.txt \
-S 'web/src/locales/**' \
-S 'website/developer-docs/api/reference/**' \
authentik \
internal \
cmd \
@ -43,12 +42,12 @@ help: ## Show this help
sort
@echo ""
go-test:
test-go:
go test -timeout 0 -v -race -cover ./...
test-docker: ## Run all tests in a docker-compose
echo "PG_PASS=$(shell openssl rand 32 | base64 -w 0)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(shell openssl rand 32 | base64 -w 0)" >> .env
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker compose pull -q
docker compose up --no-start
docker compose start postgresql redis
@ -60,11 +59,9 @@ test: ## Run the server tests and produce a coverage report (locally)
coverage html
coverage report
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
black $(PY_SOURCES)
ruff check --fix $(PY_SOURCES)
lint-codespell: ## Reports spelling errors.
codespell -w $(CODESPELL_ARGS)
lint: ## Lint the python and golang sources
@ -210,9 +207,6 @@ web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting is
web-install: ## Install the necessary libraries to build the Authentik UI
cd web && npm ci
web-test: ## Run tests for the Authentik UI
cd web && npm run test
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
rm -rf web/dist/
mkdir web/dist/
@ -244,7 +238,7 @@ website: website-lint-fix website-build ## Automatically fix formatting issues
website-install:
cd website && npm ci
website-lint-fix: lint-codespell
website-lint-fix:
cd website && npm run prettier
website-build:
@ -258,7 +252,6 @@ website-watch: ## Build and watch the documentation website, updating automatic
#########################
docker: ## Build a docker image of the current source tree
mkdir -p ${GEN_API_TS}
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
#########################

View File

@ -15,9 +15,7 @@
## What is authentik?
authentik is an open-source Identity Provider that emphasizes flexibility and versatility, with support for a wide set of protocols.
Our [enterprise offer](https://goauthentik.io/pricing) can also be used as a self-hosted replacement for large-scale deployments of Okta/Auth0, Entra ID, Ping Identity, or other legacy IdPs for employees and B2B2C use.
authentik is an open-source Identity Provider that emphasizes flexibility and versatility. It can be seamlessly integrated into existing environments to support new protocols. authentik is also a great solution for implementing sign-up, recovery, and other similar features in your application, saving you the hassle of dealing with them.
## Installation

View File

@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
(.x being the latest patch release for each version)
| Version | Supported |
| -------- | --------- |
| 2024.6.x | ✅ |
| 2024.8.x | ✅ |
| Version | Supported |
| --------- | --------- |
| 2023.10.x | ✅ |
| 2024.2.x | ✅ |
## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2024.8.3"
__version__ = "2024.4.0"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -2,21 +2,18 @@
import platform
from datetime import datetime
from ssl import OPENSSL_VERSION
from sys import version as python_version
from typing import TypedDict
from cryptography.hazmat.backends.openssl.backend import backend
from django.utils.timezone import now
from drf_spectacular.utils import extend_schema
from gunicorn import version_info as gunicorn_version
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik import get_full_version
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.license import LicenseKey
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_env
from authentik.outposts.apps import MANAGED_OUTPOST
@ -28,13 +25,11 @@ class RuntimeDict(TypedDict):
"""Runtime information"""
python_version: str
gunicorn_version: str
environment: str
architecture: str
platform: str
uname: str
openssl_version: str
openssl_fips_enabled: bool | None
authentik_version: str
class SystemInfoSerializer(PassiveSerializer):
@ -69,15 +64,11 @@ class SystemInfoSerializer(PassiveSerializer):
def get_runtime(self, request: Request) -> RuntimeDict:
"""Get versions"""
return {
"architecture": platform.machine(),
"authentik_version": get_full_version(),
"environment": get_env(),
"openssl_fips_enabled": (
backend._fips_enabled if LicenseKey.get_total().status().is_valid else None
),
"openssl_version": OPENSSL_VERSION,
"platform": platform.platform(),
"python_version": python_version,
"gunicorn_version": ".".join(str(x) for x in gunicorn_version),
"environment": get_env(),
"architecture": platform.machine(),
"platform": platform.platform(),
"uname": " ".join(platform.uname()),
}

View File

@ -12,7 +12,6 @@ from rest_framework.views import APIView
from authentik import __version__, get_build_hash
from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version
from authentik.core.api.utils import PassiveSerializer
from authentik.outposts.models import Outpost
class VersionSerializer(PassiveSerializer):
@ -23,7 +22,6 @@ class VersionSerializer(PassiveSerializer):
version_latest_valid = SerializerMethodField()
build_hash = SerializerMethodField()
outdated = SerializerMethodField()
outpost_outdated = SerializerMethodField()
def get_build_hash(self, _) -> str:
"""Get build hash, if version is not latest or released"""
@ -49,15 +47,6 @@ class VersionSerializer(PassiveSerializer):
"""Check if we're running the latest version"""
return parse(self.get_version_current(instance)) < parse(self.get_version_latest(instance))
def get_outpost_outdated(self, _) -> bool:
"""Check if any outpost is outdated/has a version mismatch"""
any_outdated = False
for outpost in Outpost.objects.all():
for state in outpost.state:
if state.version_outdated:
any_outdated = True
return any_outdated
class VersionView(APIView):
"""Get running and latest version."""

View File

@ -1,8 +1,10 @@
"""authentik admin tasks"""
import re
from django.core.cache import cache
from django.core.validators import URLValidator
from django.db import DatabaseError, InternalError, ProgrammingError
from django.utils.translation import gettext_lazy as _
from packaging.version import parse
from requests import RequestException
from structlog.stdlib import get_logger
@ -19,6 +21,8 @@ LOGGER = get_logger()
VERSION_NULL = "0.0.0"
VERSION_CACHE_KEY = "authentik_latest_version"
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
# Chop of the first ^ because we want to search the entire string
URL_FINDER = URLValidator.regex.pattern[1:]
LOCAL_VERSION = parse(__version__)
@ -74,16 +78,10 @@ def update_latest_version(self: SystemTask):
context__new_version=upstream_version,
).exists():
return
Event.new(
EventAction.UPDATE_AVAILABLE,
message=_(
"New version {version} available!".format(
version=upstream_version,
)
),
new_version=upstream_version,
changelog=data.get("stable", {}).get("changelog_url"),
).save()
event_dict = {"new_version": upstream_version}
if match := re.search(URL_FINDER, data.get("stable", {}).get("changelog", "")):
event_dict["message"] = f"Changelog: {match.group()}"
Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
self.set_error(exc)

View File

@ -17,7 +17,6 @@ RESPONSE_VALID = {
"stable": {
"version": "99999999.9999999",
"changelog": "See https://goauthentik.io/test",
"changelog_url": "https://goauthentik.io/test",
"reason": "bugfix",
},
}
@ -36,7 +35,7 @@ class TestAdminTasks(TestCase):
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="New version 99999999.9999999 available!",
context__message="Changelog: https://goauthentik.io/test",
).exists()
)
# test that a consecutive check doesn't create a duplicate event
@ -46,7 +45,7 @@ class TestAdminTasks(TestCase):
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="New version 99999999.9999999 available!",
context__message="Changelog: https://goauthentik.io/test",
)
),
1,

View File

@ -1,13 +1,13 @@
{% extends "base/skeleton.html" %}
{% load authentik_core %}
{% load static %}
{% block title %}
API Browser - {{ brand.branding_title }}
{% endblock %}
{% block head %}
{% versioned_script "dist/standalone/api-browser/index-%v.js" %}
<script src="{% static 'dist/standalone/api-browser/index.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
{% endblock %}

View File

@ -23,11 +23,9 @@ class Command(BaseCommand):
for blueprint_path in options.get("blueprints", []):
content = BlueprintInstance(path=blueprint_path).retrieve()
importer = Importer.from_string(content)
valid, logs = importer.validate()
valid, _ = importer.validate()
if not valid:
self.stderr.write("Blueprint invalid")
for log in logs:
self.stderr.write(f"\t{log.logger}: {log.event}: {log.attributes}")
self.stderr.write("blueprint invalid")
sys_exit(1)
importer.apply()

View File

@ -113,19 +113,16 @@ class Command(BaseCommand):
)
model_path = f"{model._meta.app_label}.{model._meta.model_name}"
self.schema["properties"]["entries"]["items"]["oneOf"].append(
self.template_entry(model_path, model, serializer)
self.template_entry(model_path, serializer)
)
def template_entry(self, model_path: str, model: type[Model], serializer: Serializer) -> dict:
def template_entry(self, model_path: str, serializer: Serializer) -> dict:
"""Template entry for a single model"""
model_schema = self.to_jsonschema(serializer)
model_schema["required"] = []
def_name = f"model_{model_path}"
def_path = f"#/$defs/{def_name}"
self.schema["$defs"][def_name] = model_schema
def_name_perm = f"model_{model_path}_permissions"
def_path_perm = f"#/$defs/{def_name_perm}"
self.schema["$defs"][def_name_perm] = self.model_permissions(model)
return {
"type": "object",
"required": ["model", "identifiers"],
@ -138,7 +135,6 @@ class Command(BaseCommand):
"default": "present",
},
"conditions": {"type": "array", "items": {"type": "boolean"}},
"permissions": {"$ref": def_path_perm},
"attrs": {"$ref": def_path},
"identifiers": {"$ref": def_path},
},
@ -189,20 +185,3 @@ class Command(BaseCommand):
if required:
result["required"] = required
return result
def model_permissions(self, model: type[Model]) -> dict:
perms = [x[0] for x in model._meta.permissions]
for action in model._meta.default_permissions:
perms.append(f"{action}_{model._meta.model_name}")
return {
"type": "array",
"items": {
"type": "object",
"required": ["permission"],
"properties": {
"permission": {"type": "string", "enum": perms},
"user": {"type": "integer"},
"role": {"type": "string"},
},
},
}

View File

@ -1,24 +0,0 @@
version: 1
entries:
- model: authentik_core.user
id: user
identifiers:
username: "%(id)s"
attrs:
name: "%(id)s"
- model: authentik_rbac.role
id: role
identifiers:
name: "%(id)s"
- model: authentik_flows.flow
identifiers:
slug: "%(id)s"
attrs:
designation: authentication
name: foo
title: foo
permissions:
- permission: view_flow
user: !KeyOf user
- permission: view_flow
role: !KeyOf role

View File

@ -1,8 +0,0 @@
version: 1
entries:
- model: authentik_rbac.role
identifiers:
name: "%(id)s"
attrs:
permissions:
- authentik_blueprints.view_blueprintinstance

View File

@ -1,9 +0,0 @@
version: 1
entries:
- model: authentik_core.user
identifiers:
username: "%(id)s"
attrs:
name: "%(id)s"
permissions:
- authentik_blueprints.view_blueprintinstance

View File

@ -1,57 +0,0 @@
"""Test blueprints v1"""
from django.test import TransactionTestCase
from guardian.shortcuts import get_perms
from authentik.blueprints.v1.importer import Importer
from authentik.core.models import User
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.rbac.models import Role
class TestBlueprintsV1RBAC(TransactionTestCase):
"""Test Blueprints rbac attribute"""
def test_user_permission(self):
"""Test permissions"""
uid = generate_id()
import_yaml = load_fixture("fixtures/rbac_user.yaml", id=uid)
importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
user = User.objects.filter(username=uid).first()
self.assertIsNotNone(user)
self.assertTrue(user.has_perms(["authentik_blueprints.view_blueprintinstance"]))
def test_role_permission(self):
"""Test permissions"""
uid = generate_id()
import_yaml = load_fixture("fixtures/rbac_role.yaml", id=uid)
importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(role)
self.assertEqual(
list(role.group.permissions.all().values_list("codename", flat=True)),
["view_blueprintinstance"],
)
def test_object_permission(self):
"""Test permissions"""
uid = generate_id()
import_yaml = load_fixture("fixtures/rbac_object.yaml", id=uid)
importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
flow = Flow.objects.filter(slug=uid).first()
user = User.objects.filter(username=uid).first()
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(flow)
self.assertEqual(get_perms(user, flow), ["view_flow"])
self.assertEqual(get_perms(role.group, flow), ["view_flow"])

View File

@ -1,7 +1,7 @@
"""transfer common classes"""
from collections import OrderedDict
from collections.abc import Generator, Iterable, Mapping
from collections.abc import Iterable, Mapping
from copy import copy
from dataclasses import asdict, dataclass, field, is_dataclass
from enum import Enum
@ -58,15 +58,6 @@ class BlueprintEntryDesiredState(Enum):
MUST_CREATED = "must_created"
@dataclass
class BlueprintEntryPermission:
"""Describe object-level permissions"""
permission: Union[str, "YAMLTag"]
user: Union[int, "YAMLTag", None] = field(default=None)
role: Union[str, "YAMLTag", None] = field(default=None)
@dataclass
class BlueprintEntry:
"""Single entry of a blueprint"""
@ -78,14 +69,13 @@ class BlueprintEntry:
conditions: list[Any] = field(default_factory=list)
identifiers: dict[str, Any] = field(default_factory=dict)
attrs: dict[str, Any] | None = field(default_factory=dict)
permissions: list[BlueprintEntryPermission] = field(default_factory=list)
id: str | None = None
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
def __post_init__(self, *args, **kwargs) -> None:
self.__tag_contexts: list[YAMLTagContext] = []
self.__tag_contexts: list["YAMLTagContext"] = []
@staticmethod
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
@ -160,17 +150,6 @@ class BlueprintEntry:
"""Get the blueprint model, with yaml tags resolved if present"""
return str(self.tag_resolver(self.model, blueprint))
def get_permissions(
self, blueprint: "Blueprint"
) -> Generator[BlueprintEntryPermission, None, None]:
"""Get permissions of this entry, with all yaml tags resolved"""
for perm in self.permissions:
yield BlueprintEntryPermission(
permission=self.tag_resolver(perm.permission, blueprint),
user=self.tag_resolver(perm.user, blueprint),
role=self.tag_resolver(perm.role, blueprint),
)
def check_all_conditions_match(self, blueprint: "Blueprint") -> bool:
"""Check all conditions of this entry match (evaluate to True)"""
return all(self.tag_resolver(self.conditions, blueprint))
@ -328,10 +307,7 @@ class Find(YAMLTag):
else:
model_name = self.model_name
try:
model_class = apps.get_model(*model_name.split("."))
except LookupError as exc:
raise EntryInvalidError.from_entry(exc, entry) from exc
model_class = apps.get_model(*model_name.split("."))
query = Q()
for cond in self.conditions:

View File

@ -16,7 +16,6 @@ from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
@ -33,23 +32,13 @@ from authentik.blueprints.v1.common import (
from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry
from authentik.core.models import (
AuthenticatedSession,
GroupSourceConnection,
PropertyMapping,
Provider,
Source,
User,
UserSourceConnection,
)
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.models import SystemTask
@ -57,13 +46,11 @@ from authentik.events.utils import cleanse_dict
from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role
from authentik.providers.scim.models import SCIMGroup, SCIMUser
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.tenants.models import Tenant
@ -92,7 +79,6 @@ def excluded_models() -> list[type[Model]]:
Source,
PropertyMapping,
UserSourceConnection,
GroupSourceConnection,
Stage,
OutpostServiceConnection,
Policy,
@ -100,11 +86,10 @@ def excluded_models() -> list[type[Model]]:
# Classes that have other dependencies
AuthenticatedSession,
# Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
FlowToken,
LicenseUsage,
SCIMProviderGroup,
SCIMProviderUser,
SCIMGroup,
SCIMUser,
Tenant,
SystemTask,
ConnectionToken,
@ -115,10 +100,6 @@ def excluded_models() -> list[type[Model]]:
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
GoogleWorkspaceProviderUser,
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
)
@ -142,16 +123,6 @@ def transaction_rollback():
pass
def rbac_models() -> dict:
models = {}
for app in get_apps():
for model in app.get_models():
if not is_model_allowed(model):
continue
models[model._meta.model_name] = app.label
return models
class Importer:
"""Import Blueprint from raw dict or YAML/JSON"""
@ -170,10 +141,7 @@ class Importer:
def default_context(self):
"""Default context"""
return {
"goauthentik.io/enterprise/licensed": LicenseKey.get_total().status().is_valid,
"goauthentik.io/rbac/models": rbac_models(),
}
return {"goauthentik.io/enterprise/licensed": LicenseKey.get_total().is_valid()}
@staticmethod
def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
@ -233,17 +201,14 @@ class Importer:
return main_query | sub_query
def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer | None: # noqa: PLR0915
def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer | None:
"""Validate a single entry"""
if not entry.check_all_conditions_match(self._import):
self.logger.debug("One or more conditions of this entry are not fulfilled, skipping")
return None
model_app_label, model_name = entry.get_model(self._import).split(".")
try:
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
except LookupError as exc:
raise EntryInvalidError.from_entry(exc, entry) from exc
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
# Don't use isinstance since we don't want to check for inheritance
if not is_model_allowed(model):
raise EntryInvalidError.from_entry(f"Model {model} not allowed", entry)
@ -318,7 +283,10 @@ class Importer:
try:
full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import))
except ValueError as exc:
raise EntryInvalidError.from_entry(exc, entry) from exc
raise EntryInvalidError.from_entry(
exc,
entry,
) from exc
always_merger.merge(full_data, updated_identifiers)
serializer_kwargs["data"] = full_data
@ -339,15 +307,6 @@ class Importer:
) from exc
return serializer
def _apply_permissions(self, instance: Model, entry: BlueprintEntry):
"""Apply object-level permissions for an entry"""
for perm in entry.get_permissions(self._import):
if perm.user is not None:
assign_perm(perm.permission, User.objects.get(pk=perm.user), instance)
if perm.role is not None:
role = Role.objects.get(pk=perm.role)
role.assign_permission(perm.permission, obj=instance)
def apply(self) -> bool:
"""Apply (create/update) models yaml, in database transaction"""
try:
@ -412,7 +371,6 @@ class Importer:
if "pk" in entry.identifiers:
self.__pk_map[entry.identifiers["pk"]] = instance.pk
entry._state = BlueprintEntryState(instance)
self._apply_permissions(instance, entry)
elif state == BlueprintEntryDesiredState.ABSENT:
instance: Model | None = serializer.instance
if instance.pk:

View File

@ -11,20 +11,21 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import SecretKeyFilter
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.tenants.utils import get_current_tenant
class FooterLinkSerializer(PassiveSerializer):
"""Links returned in Config API"""
href = CharField(read_only=True, allow_null=True)
href = CharField(read_only=True)
name = CharField(read_only=True)
@ -55,7 +56,6 @@ class BrandSerializer(ModelSerializer):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"default_application",
"web_certificate",
"attributes",
]

View File

@ -9,6 +9,3 @@ class AuthentikBrandsConfig(AppConfig):
name = "authentik.brands"
label = "authentik_brands"
verbose_name = "authentik Brands"
mountpoints = {
"authentik.brands.urls_root": "",
}

View File

@ -1,26 +0,0 @@
# Generated by Django 5.0.6 on 2024-07-04 20:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0006_brand_authentik_b_domain_b9b24a_idx_and_more"),
("authentik_core", "0035_alter_group_options_and_more"),
]
operations = [
migrations.AddField(
model_name="brand",
name="default_application",
field=models.ForeignKey(
default=None,
help_text="When set, external users will be redirected to this application after authenticating.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.application",
),
),
]

View File

@ -3,7 +3,6 @@
from uuid import uuid4
from django.db import models
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
@ -52,16 +51,6 @@ class Brand(SerializerModel):
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
)
default_application = models.ForeignKey(
"authentik_core.Application",
null=True,
default=None,
on_delete=models.SET_DEFAULT,
help_text=_(
"When set, external users will be redirected to this application after authenticating."
),
)
web_certificate = models.ForeignKey(
CertificateKeyPair,
null=True,
@ -99,13 +88,3 @@ class Brand(SerializerModel):
models.Index(fields=["domain"]),
models.Index(fields=["default"]),
]
class WebfingerProvider(models.Model):
"""Provider which supports webfinger discovery"""
class Meta:
abstract = True
def webfinger(self, resource: str, request: HttpRequest) -> dict:
raise NotImplementedError()

View File

@ -5,11 +5,7 @@ from rest_framework.test import APITestCase
from authentik.brands.api import Themes
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.saml.models import SAMLProvider
class TestBrands(APITestCase):
@ -79,45 +75,3 @@ class TestBrands(APITestCase):
reverse("authentik_api:brand-list"), data={"domain": "bar", "default": True}
)
self.assertEqual(response.status_code, 400)
def test_webfinger_no_app(self):
"""Test Webfinger"""
create_test_brand()
self.assertJSONEqual(
self.client.get(reverse("authentik_brands:webfinger")).content.decode(), {}
)
def test_webfinger_not_supported(self):
"""Test Webfinger"""
brand = create_test_brand()
provider = SAMLProvider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
brand.default_application = app
brand.save()
self.assertJSONEqual(
self.client.get(reverse("authentik_brands:webfinger")).content.decode(), {}
)
def test_webfinger_oidc(self):
"""Test Webfinger"""
brand = create_test_brand()
provider = OAuth2Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
brand.default_application = app
brand.save()
self.assertJSONEqual(
self.client.get(reverse("authentik_brands:webfinger")).content.decode(),
{
"links": [
{
"href": f"http://testserver/application/o/{app.slug}/",
"rel": "http://openid.net/specs/connect/1.0/issuer",
}
],
"subject": None,
},
)

View File

@ -1,9 +0,0 @@
"""authentik brand root URLs"""
from django.urls import path
from authentik.brands.views.webfinger import WebFingerView
urlpatterns = [
path(".well-known/webfinger", WebFingerView.as_view(), name="webfinger"),
]

View File

@ -5,7 +5,7 @@ from typing import Any
from django.db.models import F, Q
from django.db.models import Value as V
from django.http.request import HttpRequest
from sentry_sdk import get_current_span
from sentry_sdk.hub import Hub
from authentik import get_full_version
from authentik.brands.models import Brand
@ -33,7 +33,7 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
brand = getattr(request, "brand", DEFAULT_BRAND)
tenant = getattr(request, "tenant", Tenant())
trace = ""
span = get_current_span()
span = Hub.current.scope.span
if span:
trace = span.to_traceparent()
return {

View File

@ -1,29 +0,0 @@
from typing import Any
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.views import View
from authentik.brands.models import Brand, WebfingerProvider
from authentik.core.models import Application
class WebFingerView(View):
"""Webfinger endpoint"""
def get(self, request: HttpRequest) -> HttpResponse:
brand: Brand = request.brand
if not brand.default_application:
return JsonResponse({})
application: Application = brand.default_application
provider = application.get_provider()
if not provider or not isinstance(provider, WebfingerProvider):
return JsonResponse({})
webfinger_data = provider.webfinger(request.GET.get("resource"), request)
return JsonResponse(webfinger_data)
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = super().dispatch(request, *args, **kwargs)
# RFC7033 spec
response["Access-Control-Allow-Origin"] = "*"
response["Content-Type"] = "application/jrd+json"
return response

View File

@ -17,6 +17,7 @@ from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodFiel
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
@ -25,7 +26,6 @@ from authentik.api.pagination import Pagination
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.events.models import EventAction
@ -103,12 +103,7 @@ class ApplicationSerializer(ModelSerializer):
class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Application Viewset"""
queryset = (
Application.objects.all()
.with_provider()
.prefetch_related("policies")
.prefetch_related("backchannel_providers")
)
queryset = Application.objects.all().prefetch_related("provider")
serializer_class = ApplicationSerializer
search_fields = [
"name",
@ -152,15 +147,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
applications.append(application)
return applications
def _filter_applications_with_launch_url(
self, pagined_apps: Iterator[Application]
) -> list[Application]:
applications = []
for app in pagined_apps:
if app.get_launch_url():
applications.append(app)
return applications
@extend_schema(
parameters=[
OpenApiParameter(
@ -218,11 +204,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
),
OpenApiParameter(
name="only_with_launch_url",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
),
]
)
def list(self, request: Request) -> Response:
@ -235,10 +216,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
if superuser_full_list and request.user.is_superuser:
return super().list(request)
only_with_launch_url = str(
request.query_params.get("only_with_launch_url", "false")
).lower()
queryset = self._filter_queryset_for_list(self.get_queryset())
paginator: Pagination = self.paginator
paginated_apps = paginator.paginate_queryset(queryset, request)
@ -274,10 +251,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
allowed_applications,
timeout=86400,
)
if only_with_launch_url == "true":
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
serializer = self.get_serializer(allowed_applications, many=True)
return self.get_paginated_response(serializer.data)

View File

@ -8,12 +8,12 @@ from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser
from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import AuthenticatedSession
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict

View File

@ -2,13 +2,7 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.fields import (
BooleanField,
CharField,
DateTimeField,
IntegerField,
SerializerMethodField,
)
from rest_framework.fields import BooleanField, CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
@ -26,9 +20,6 @@ class DeviceSerializer(MetaNameSerializer):
name = CharField()
type = SerializerMethodField()
confirmed = BooleanField()
created = DateTimeField(read_only=True)
last_updated = DateTimeField(read_only=True)
last_used = DateTimeField(read_only=True, allow_null=True)
def get_type(self, instance: Device) -> str:
"""Get type of device"""

View File

@ -2,7 +2,6 @@
from json import loads
from django.db.models import Prefetch
from django.http import Http404
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet
@ -17,12 +16,11 @@ from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ValidationError
from rest_framework.validators import UniqueValidator
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.core.models import Group, User
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
@ -102,10 +100,7 @@ class GroupSerializer(ModelSerializer):
extra_kwargs = {
"users": {
"default": list,
},
# TODO: This field isn't unique on the database which is hard to backport
# hence we just validate the uniqueness here
"name": {"validators": [UniqueValidator(Group.objects.all())]},
}
}
@ -159,24 +154,12 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
pk = IntegerField(required=True)
queryset = Group.objects.none()
queryset = Group.objects.all().select_related("parent").prefetch_related("users")
serializer_class = GroupSerializer
search_fields = ["name", "is_superuser"]
filterset_class = GroupFilter
ordering = ["name"]
def get_queryset(self):
base_qs = Group.objects.all().select_related("parent").prefetch_related("roles")
if self.serializer_class(context={"request": self.request})._should_include_users:
base_qs = base_qs.prefetch_related("users")
else:
base_qs = base_qs.prefetch_related(
Prefetch("users", queryset=User.objects.all().only("id"))
)
return base_qs
@extend_schema(
parameters=[
OpenApiParameter("include_users", bool, default=True),
@ -185,14 +168,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
parameters=[
OpenApiParameter("include_users", bool, default=True),
]
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@permission_required("authentik_core.add_user_to_group")
@extend_schema(
request=UserAccountSerializer,

View File

@ -1,79 +0,0 @@
"""API Utilities"""
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.fields import (
BooleanField,
CharField,
)
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import all_subclasses
class TypeCreateSerializer(PassiveSerializer):
"""Types of an object that can be created"""
name = CharField(required=True)
description = CharField(required=True)
component = CharField(required=True)
model_name = CharField(required=True)
icon_url = CharField(required=False)
requires_enterprise = BooleanField(default=False)
class CreatableType:
"""Class to inherit from to mark a model as creatable, even if the model itself is marked
as abstract"""
class NonCreatableType:
"""Class to inherit from to mark a model as non-creatable even if it is not abstract"""
class TypesMixin:
"""Mixin which adds an API endpoint to list all possible types that can be created"""
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request, additional: list[dict] | None = None) -> Response:
"""Get all creatable types"""
data = []
for subclass in all_subclasses(self.queryset.model):
instance = None
if subclass._meta.abstract:
if not issubclass(subclass, CreatableType):
continue
# Circumvent the django protection for not being able to instantiate
# abstract models. We need a model instance to access .component
# and further down .icon_url
instance = subclass.__new__(subclass)
# Django re-sets abstract = False so we need to override that
instance.Meta.abstract = True
else:
if issubclass(subclass, NonCreatableType):
continue
instance = subclass()
try:
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": instance.component,
"model_name": subclass._meta.model_name,
"icon_url": getattr(instance, "icon_url", None),
"requires_enterprise": isinstance(
subclass._meta.app_config, EnterpriseConfig
),
}
)
except NotImplementedError:
continue
if additional:
data.extend(additional)
data = sorted(data, key=lambda x: x["name"])
return Response(TypeCreateSerializer(data, many=True).data)

View File

@ -2,38 +2,25 @@
from json import dumps
from django_filters.filters import AllValuesMultipleFilter, BooleanFilter
from django_filters.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_field,
)
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import BooleanField, CharField, SerializerMethodField
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.fields import BooleanField, CharField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import GenericViewSet
from authentik.blueprints.api import ManagedSerializer
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import (
MetaNameSerializer,
ModelSerializer,
PassiveSerializer,
)
from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer
from authentik.core.expression.evaluator import PropertyMappingEvaluator
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.models import Group, PropertyMapping, User
from authentik.core.models import PropertyMapping
from authentik.events.utils import sanitize_item
from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.api.exec import PolicyTestSerializer
from authentik.rbac.decorators import permission_required
@ -76,20 +63,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
]
class PropertyMappingFilterSet(FilterSet):
"""Filter for PropertyMapping"""
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
managed__isnull = BooleanFilter(field_name="managed", lookup_expr="isnull")
class Meta:
model = PropertyMapping
fields = ["name", "managed"]
class PropertyMappingViewSet(
TypesMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
@ -98,23 +72,37 @@ class PropertyMappingViewSet(
):
"""PropertyMapping Viewset"""
class PropertyMappingTestSerializer(PolicyTestSerializer):
"""Test property mapping execution for a user/group with context"""
user = PrimaryKeyRelatedField(queryset=User.objects.all(), required=False, allow_null=True)
group = PrimaryKeyRelatedField(
queryset=Group.objects.all(), required=False, allow_null=True
)
queryset = PropertyMapping.objects.select_subclasses()
queryset = PropertyMapping.objects.none()
serializer_class = PropertyMappingSerializer
filterset_class = PropertyMappingFilterSet
search_fields = [
"name",
]
filterset_fields = {"managed": ["isnull"]}
ordering = ["name"]
search_fields = ["name"]
def get_queryset(self): # pragma: no cover
return PropertyMapping.objects.select_subclasses()
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable property-mapping types"""
data = []
for subclass in all_subclasses(self.queryset.model):
subclass: PropertyMapping
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": subclass().component,
"model_name": subclass._meta.model_name,
}
)
return Response(TypeCreateSerializer(data, many=True).data)
@permission_required("authentik_core.view_propertymapping")
@extend_schema(
request=PropertyMappingTestSerializer(),
request=PolicyTestSerializer(),
responses={
200: PropertyMappingTestResultSerializer,
400: OpenApiResponse(description="Invalid parameters"),
@ -132,47 +120,34 @@ class PropertyMappingViewSet(
"""Test Property Mapping"""
_mapping: PropertyMapping = self.get_object()
# Use `get_subclass` to get correct class and correct `.evaluate` implementation
mapping: PropertyMapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk)
mapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk)
# FIXME: when we separate policy mappings between ones for sources
# and ones for providers, we need to make the user field optional for the source mapping
test_params = self.PropertyMappingTestSerializer(data=request.data)
test_params = PolicyTestSerializer(data=request.data)
if not test_params.is_valid():
return Response(test_params.errors, status=400)
format_result = str(request.GET.get("format_result", "false")).lower() == "true"
context: dict = test_params.validated_data.get("context", {})
context.setdefault("user", None)
if user := test_params.validated_data.get("user"):
# User permission check, only allow mapping testing for users that are readable
users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
pk=user.pk
)
if not users.exists():
raise PermissionDenied()
context["user"] = user
if group := test_params.validated_data.get("group"):
# Group permission check, only allow mapping testing for groups that are readable
groups = get_objects_for_user(request.user, "authentik_core.view_group").filter(
pk=group.pk
)
if not groups.exists():
raise PermissionDenied()
context["group"] = group
context["request"] = self.request
# User permission check, only allow mapping testing for users that are readable
users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
pk=test_params.validated_data["user"].pk
)
if not users.exists():
raise PermissionDenied()
response_data = {"successful": True, "result": ""}
try:
result = mapping.evaluate(dry_run=True, **context)
result = mapping.evaluate(
users.first(),
self.request,
**test_params.validated_data.get("context", {}),
)
response_data["result"] = dumps(
sanitize_item(result), indent=(4 if format_result else None)
)
except PropertyMappingExpressionException as exc:
response_data["result"] = exception_to_string(exc.exc)
response_data["successful"] = False
except Exception as exc:
response_data["result"] = exception_to_string(exc)
response_data["result"] = str(exc)
response_data["successful"] = False
response = PropertyMappingTestResultSerializer(response_data)
return Response(response.data)

View File

@ -5,14 +5,20 @@ from django.db.models.query import Q
from django.utils.translation import gettext_lazy as _
from django_filters.filters import BooleanFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import extend_schema
from rest_framework import mixins
from rest_framework.fields import ReadOnlyField, SerializerMethodField
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.models import Provider
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import all_subclasses
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
@ -57,12 +63,8 @@ class ProviderFilter(FilterSet):
"""Filter for providers"""
application__isnull = BooleanFilter(method="filter_application__isnull")
backchannel = BooleanFilter(
method="filter_backchannel",
label=_(
"When not set all providers are returned. When set to true, only backchannel "
"providers are returned. When set to false, backchannel providers are excluded"
),
backchannel_only = BooleanFilter(
method="filter_backchannel_only",
)
def filter_application__isnull(self, queryset: QuerySet, name, value):
@ -73,14 +75,12 @@ class ProviderFilter(FilterSet):
| Q(application__isnull=value)
)
def filter_backchannel(self, queryset: QuerySet, name, value):
"""By default all providers are returned. When set to true, only backchannel providers are
returned. When set to false, backchannel providers are excluded"""
def filter_backchannel_only(self, queryset: QuerySet, name, value):
"""Only return backchannel providers"""
return queryset.filter(is_backchannel=value)
class ProviderViewSet(
TypesMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
@ -99,3 +99,31 @@ class ProviderViewSet(
def get_queryset(self): # pragma: no cover
return Provider.objects.select_subclasses()
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable provider types"""
data = []
for subclass in all_subclasses(self.queryset.model):
subclass: Provider
if subclass._meta.abstract:
continue
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": subclass().component,
"model_name": subclass._meta.model_name,
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
}
)
data.append(
{
"name": _("SAML Provider from Metadata"),
"description": _("Create a SAML Provider by importing its Metadata."),
"component": "ak-provider-saml-import-form",
"model_name": "",
}
)
return Response(TypeCreateSerializer(data, many=True).data)

View File

@ -11,15 +11,15 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet
from structlog.stdlib import get_logger
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.models import Source, UserSourceConnection
from authentik.core.types import UserSettingSerializer
from authentik.lib.utils.file import (
FilePathSerializer,
@ -27,6 +27,7 @@ from authentik.lib.utils.file import (
set_file,
set_file_url,
)
from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.engine import PolicyEngine
from authentik.rbac.decorators import permission_required
@ -60,8 +61,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"enabled",
"authentication_flow",
"enrollment_flow",
"user_property_mappings",
"group_property_mappings",
"component",
"verbose_name",
"verbose_name_plural",
@ -75,7 +74,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
class SourceViewSet(
TypesMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
@ -134,6 +132,30 @@ class SourceViewSet(
source: Source = self.get_object()
return set_file_url(request, source, "icon")
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable source types"""
data = []
for subclass in all_subclasses(self.queryset.model):
subclass: Source
component = ""
if len(subclass.__subclasses__()) > 0:
continue
if subclass._meta.abstract:
component = subclass.__bases__[0]().component
else:
component = subclass().component
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": component,
"model_name": subclass._meta.model_name,
}
)
return Response(TypeCreateSerializer(data, many=True).data)
@extend_schema(responses={200: UserSettingSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def user_settings(self, request: Request) -> Response:
@ -190,47 +212,6 @@ class UserSourceConnectionViewSet(
queryset = UserSourceConnection.objects.all()
serializer_class = UserSourceConnectionSerializer
permission_classes = [OwnerSuperuserPermissions]
filterset_fields = ["user", "source__slug"]
search_fields = ["source__slug"]
filterset_fields = ["user"]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["source__slug", "pk"]
class GroupSourceConnectionSerializer(SourceSerializer):
"""Group Source Connection Serializer"""
source = SourceSerializer(read_only=True)
class Meta:
model = GroupSourceConnection
fields = [
"pk",
"group",
"source",
"identifier",
"created",
]
extra_kwargs = {
"group": {"read_only": True},
"identifier": {"read_only": True},
"created": {"read_only": True},
}
class GroupSourceConnectionViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""Group-source connection Viewset"""
queryset = GroupSourceConnection.objects.all()
serializer_class = GroupSourceConnectionSerializer
permission_classes = [OwnerSuperuserPermissions]
filterset_fields = ["group", "source__slug"]
search_fields = ["source__slug"]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["source__slug", "pk"]
ordering = ["pk"]

View File

@ -2,7 +2,6 @@
from typing import Any
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import assign_perm, get_anonymous_user
@ -12,6 +11,7 @@ from rest_framework.fields import CharField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import OwnerSuperuserPermissions
@ -19,7 +19,7 @@ from authentik.blueprints.api import ManagedSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
@ -27,6 +27,7 @@ from authentik.core.models import (
TokenIntents,
User,
default_token_duration,
token_expires_from_timedelta,
)
from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict
@ -44,13 +45,6 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["key"] = CharField(required=False)
def validate_user(self, user: User):
"""Ensure user of token cannot be changed"""
if self.instance and self.instance.user_id:
if user.pk != self.instance.user_id:
raise ValidationError("User cannot be changed")
return user
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
"""Ensure only API or App password tokens are created."""
request: Request = self.context.get("request")
@ -74,17 +68,15 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
max_token_lifetime_dt = default_token_duration()
if max_token_lifetime is not None:
try:
max_token_lifetime_dt = now() + timedelta_from_string(max_token_lifetime)
max_token_lifetime_dt = timedelta_from_string(max_token_lifetime)
except ValueError:
pass
max_token_lifetime_dt = default_token_duration()
if "expires" in attrs and attrs.get("expires") > max_token_lifetime_dt:
if "expires" in attrs and attrs.get("expires") > token_expires_from_timedelta(
max_token_lifetime_dt
):
raise ValidationError(
{
"expires": (
f"Token expires exceeds maximum lifetime ({max_token_lifetime_dt} UTC)."
)
}
{"expires": f"Token expires exceeds maximum lifetime ({max_token_lifetime})."}
)
elif attrs.get("intent") == TokenIntents.INTENT_API:
# For API tokens, expires cannot be overridden

View File

@ -14,7 +14,6 @@ from rest_framework.request import Request
from rest_framework.response import Response
from authentik.core.api.utils import PassiveSerializer
from authentik.rbac.filters import ObjectFilter
class DeleteAction(Enum):
@ -40,12 +39,12 @@ def get_delete_action(manager: Manager) -> str:
"""Get the delete action from the Foreign key, falls back to cascade"""
if hasattr(manager, "field"):
if manager.field.remote_field.on_delete.__name__ == SET_NULL.__name__:
return DeleteAction.SET_NULL.value
return DeleteAction.SET_NULL.name
if manager.field.remote_field.on_delete.__name__ == SET_DEFAULT.__name__:
return DeleteAction.SET_DEFAULT.value
return DeleteAction.SET_DEFAULT.name
if hasattr(manager, "source_field"):
return DeleteAction.CASCADE_MANY.value
return DeleteAction.CASCADE.value
return DeleteAction.CASCADE_MANY.name
return DeleteAction.CASCADE.name
class UsedByMixin:
@ -54,7 +53,7 @@ class UsedByMixin:
@extend_schema(
responses={200: UsedBySerializer(many=True)},
)
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
@action(detail=True, pagination_class=None, filter_backends=[])
def used_by(self, request: Request, *args, **kwargs) -> Response:
"""Get a list of all objects that use this object"""
model: Model = self.get_object()

View File

@ -5,7 +5,6 @@ from json import loads
from typing import Any
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour
@ -34,21 +33,16 @@ from drf_spectacular.utils import (
)
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import (
BooleanField,
CharField,
ChoiceField,
DateTimeField,
IntegerField,
ListField,
SerializerMethodField,
)
from rest_framework.fields import CharField, IntegerField, ListField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
BooleanField,
DateTimeField,
ListSerializer,
ModelSerializer,
PrimaryKeyRelatedField,
ValidationError,
)
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
@ -58,12 +52,7 @@ from authentik.admin.api.metrics import CoordinateSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import (
JSONDictField,
LinkSerializer,
ModelSerializer,
PassiveSerializer,
)
from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer
from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_USER,
@ -85,7 +74,6 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import get_permission_choices
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
@ -149,19 +137,12 @@ class UserSerializer(ModelSerializer):
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["password"] = CharField(required=False, allow_null=True)
self.fields["permissions"] = ListField(
required=False, child=ChoiceField(choices=get_permission_choices())
)
def create(self, validated_data: dict) -> User:
"""If this serializer is used in the blueprint context, we allow for
directly setting a password. However should be done via the `set_password`
method instead of directly setting it like rest_framework."""
password = validated_data.pop("password", None)
permissions = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
validated_data["user_permissions"] = permissions
instance: User = super().create(validated_data)
self._set_password(instance, password)
return instance
@ -170,10 +151,6 @@ class UserSerializer(ModelSerializer):
"""Same as `create` above, set the password directly if we're in a blueprint
context"""
password = validated_data.pop("password", None)
permissions = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
validated_data["user_permissions"] = permissions
instance = super().update(instance, validated_data)
self._set_password(instance, password)
return instance
@ -430,11 +407,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
search_fields = ["username", "name", "is_active", "email", "uuid"]
filterset_class = UsersFilter
def get_queryset(self):
base_qs = User.objects.all().exclude_anonymous()
if self.serializer_class(context={"request": self.request})._should_include_groups:
base_qs = base_qs.prefetch_related("ak_groups")
return base_qs
def get_queryset(self): # pragma: no cover
return User.objects.all().exclude_anonymous().prefetch_related("ak_groups")
@extend_schema(
parameters=[
@ -678,10 +652,10 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if not request.tenant.impersonation:
LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401)
user_to_be = self.get_object()
if not request.user.has_perm("impersonate", user_to_be):
if not request.user.has_perm("impersonate"):
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
return Response(status=401)
user_to_be = self.get_object()
if user_to_be.pk == self.request.user.pk:
LOGGER.debug("User attempted to impersonate themselves", user=request.user)
return Response(status=401)

View File

@ -6,19 +6,8 @@ from django.db.models import Model
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.plumbing import build_basic_type
from drf_spectacular.types import OpenApiTypes
from rest_framework.fields import (
CharField,
IntegerField,
JSONField,
SerializerMethodField,
)
from rest_framework.serializers import ModelSerializer as BaseModelSerializer
from rest_framework.serializers import (
Serializer,
ValidationError,
model_meta,
raise_errors_on_nested_writes,
)
from rest_framework.fields import BooleanField, CharField, IntegerField, JSONField
from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError
def is_dict(value: Any):
@ -28,39 +17,6 @@ def is_dict(value: Any):
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
class ModelSerializer(BaseModelSerializer):
def update(self, instance: Model, validated_data):
raise_errors_on_nested_writes("update", self, validated_data)
info = model_meta.get_field_info(instance)
# Simply set each attribute on the instance, and then save it.
# Note that unlike `.create()` we don't need to treat many-to-many
# relationships as being a special case. During updates we already
# have an instance pk for the relationships to be associated with.
m2m_fields = []
for attr, value in validated_data.items():
if attr in info.relations and info.relations[attr].to_many:
m2m_fields.append((attr, value))
else:
setattr(instance, attr, value)
instance.save()
# Note that many-to-many fields are set after updating instance.
# Setting m2m fields triggers signals which could potentially change
# updated instance and we do not want it to collide with .update()
for attr, value in m2m_fields:
field = getattr(instance, attr)
# We can't check for inheritance here as m2m managers are generated dynamically
if field.__class__.__name__ == "RelatedManager":
field.set(value, bulk=False)
else:
field.set(value)
return instance
class JSONDictField(JSONField):
"""JSON Field which only allows dictionaries"""
@ -112,6 +68,16 @@ class MetaNameSerializer(PassiveSerializer):
return f"{obj._meta.app_label}.{obj._meta.model_name}"
class TypeCreateSerializer(PassiveSerializer):
"""Types of an object that can be created"""
name = CharField(required=True)
description = CharField(required=True)
component = CharField(required=True)
model_name = CharField(required=True)
requires_enterprise = BooleanField(default=False)
class CacheSerializer(PassiveSerializer):
"""Generic cache stats for an object"""

View File

@ -31,9 +31,8 @@ class InbuiltBackend(ModelBackend):
# Since we can't directly pass other variables to signals, and we want to log the method
# and the token used, we assume we're running in a flow and set a variable in the context
flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan(""))
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD, method)
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS].update(cleanse_dict(sanitize_dict(kwargs)))
flow_plan.context[PLAN_CONTEXT_METHOD] = method
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
request.session[SESSION_KEY_PLAN] = flow_plan

View File

@ -0,0 +1,7 @@
"""authentik core exceptions"""
from authentik.lib.sentry import SentryIgnoredException
class PropertyMappingExpressionException(SentryIgnoredException):
"""Error when a PropertyMapping Exception expression could not be parsed or evaluated."""

View File

@ -1,13 +1,11 @@
"""Property Mapping Evaluator"""
from types import CodeType
from typing import Any
from django.db.models import Model
from django.http import HttpRequest
from prometheus_client import Histogram
from authentik.core.expression.exceptions import SkipObjectException
from authentik.core.models import User
from authentik.events.models import Event, EventAction
from authentik.lib.expression.evaluator import BaseEvaluator
@ -25,8 +23,6 @@ class PropertyMappingEvaluator(BaseEvaluator):
"""Custom Evaluator that adds some different context variables."""
dry_run: bool
model: Model
_compiled: CodeType | None = None
def __init__(
self,
@ -36,32 +32,22 @@ class PropertyMappingEvaluator(BaseEvaluator):
dry_run: bool | None = False,
**kwargs,
):
self.model = model
if hasattr(model, "name"):
_filename = model.name
else:
_filename = str(model)
super().__init__(filename=_filename)
self.dry_run = dry_run
self.set_context(user, request, **kwargs)
def set_context(
self,
user: User | None = None,
request: HttpRequest | None = None,
**kwargs,
):
req = PolicyRequest(user=User())
req.obj = self.model
req.obj = model
if user:
req.user = user
self._context["user"] = user
if request:
req.http_request = request
req.context.update(**kwargs)
self._context["request"] = req
req.context.update(**kwargs)
self._context.update(**kwargs)
self._globals["SkipObject"] = SkipObjectException
self.dry_run = dry_run
def handle_error(self, exc: Exception, expression_source: str):
"""Exception Handler"""
@ -76,19 +62,10 @@ class PropertyMappingEvaluator(BaseEvaluator):
)
if "request" in self._context:
req: PolicyRequest = self._context["request"]
if req.http_request:
event.from_http(req.http_request, req.user)
return
elif req.user:
event.set_user(req.user)
event.from_http(req.http_request, req.user)
return
event.save()
def evaluate(self, *args, **kwargs) -> Any:
with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time():
return super().evaluate(*args, **kwargs)
def compile(self, expression: str | None = None) -> Any:
if not self._compiled:
compiled = super().compile(expression or self.model.expression)
self._compiled = compiled
return self._compiled

View File

@ -1,19 +0,0 @@
"""authentik core exceptions"""
from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.sentry import SentryIgnoredException
class PropertyMappingExpressionException(SentryIgnoredException):
"""Error when a PropertyMapping Exception expression could not be parsed or evaluated."""
def __init__(self, exc: Exception, mapping) -> None:
super().__init__()
self.exc = exc
self.mapping = mapping
class SkipObjectException(ControlFlowException):
"""Exception which can be raised in a property mapping to skip syncing an object.
Only applies to Property mappings which sync objects, and not on mappings which transitively
apply to a single user"""

View File

@ -1,32 +0,0 @@
"""Change user type"""
from authentik.core.models import User, UserTypes
from authentik.tenants.management import TenantCommand
class Command(TenantCommand):
"""Change user type"""
def add_arguments(self, parser):
parser.add_argument("--type", type=str, required=True)
parser.add_argument("--all", action="store_true", default=False)
parser.add_argument("usernames", nargs="*", type=str)
def handle_per_tenant(self, **options):
print(options)
new_type = UserTypes(options["type"])
qs = (
User.objects.exclude_anonymous()
.exclude(type=UserTypes.SERVICE_ACCOUNT)
.exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
)
if options["usernames"] and options["all"]:
self.stderr.write("--all and usernames specified, only one can be specified")
return
if not options["usernames"] and not options["all"]:
self.stderr.write("--all or usernames must be specified")
return
if options["usernames"] and not options["all"]:
qs = qs.filter(username__in=options["usernames"])
updated = qs.update(type=new_type)
self.stdout.write(f"Updated {updated} users.")

View File

@ -7,13 +7,11 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
from authentik.providers.ldap.models import LDAPProvider
from authentik.providers.scim.models import SCIMProvider
from authentik.core.models import BackchannelProvider
for model in [LDAPProvider, SCIMProvider]:
for model in BackchannelProvider.__subclasses__():
try:
for obj in model.objects.using(db_alias).only("is_backchannel"):
for obj in model.objects.only("is_backchannel"):
obj.is_backchannel = True
obj.save()
except (DatabaseError, InternalError, ProgrammingError):

View File

@ -1,43 +0,0 @@
# Generated by Django 5.0.2 on 2024-02-29 11:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0035_alter_group_options_and_more"),
]
operations = [
migrations.AddField(
model_name="source",
name="group_property_mappings",
field=models.ManyToManyField(
blank=True,
default=None,
related_name="source_grouppropertymappings_set",
to="authentik_core.propertymapping",
),
),
migrations.AddField(
model_name="source",
name="user_property_mappings",
field=models.ManyToManyField(
blank=True,
default=None,
related_name="source_userpropertymappings_set",
to="authentik_core.propertymapping",
),
),
migrations.AlterField(
model_name="source",
name="property_mappings",
field=models.ManyToManyField(
blank=True,
default=None,
related_name="source_set",
to="authentik_core.propertymapping",
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.2 on 2024-02-29 11:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0005_remove_ldappropertymapping_object_field_and_more"),
("authentik_core", "0036_source_group_property_mappings_and_more"),
]
operations = [
migrations.RemoveField(
model_name="source",
name="property_mappings",
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.7 on 2024-07-22 13:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0037_remove_source_property_mappings"),
("authentik_flows", "0027_auto_20231028_1424"),
("authentik_policies", "0011_policybinding_failure_result_and_more"),
]
operations = [
migrations.AddIndex(
model_name="source",
index=models.Index(fields=["enabled"], name="authentik_c_enabled_d72365_idx"),
),
]

View File

@ -1,67 +0,0 @@
# Generated by Django 5.0.7 on 2024-08-01 18:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0038_source_authentik_c_enabled_d72365_idx"),
]
operations = [
migrations.AddField(
model_name="source",
name="group_matching_mode",
field=models.TextField(
choices=[
("identifier", "Use the source-specific identifier"),
(
"name_link",
"Link to a group with identical name. Can have security implications when a group name is used with another source.",
),
(
"name_deny",
"Use the group name, but deny enrollment when the name already exists.",
),
],
default="identifier",
help_text="How the source determines if an existing group should be used or a new group created.",
),
),
migrations.AlterField(
model_name="group",
name="name",
field=models.TextField(verbose_name="name"),
),
migrations.CreateModel(
name="GroupSourceConnection",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("created", models.DateTimeField(auto_now_add=True)),
("last_updated", models.DateTimeField(auto_now=True)),
("identifier", models.TextField()),
(
"group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
),
),
(
"source",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.source"
),
),
],
options={
"unique_together": {("group", "source")},
},
),
]

View File

@ -1,6 +1,6 @@
"""authentik core models"""
from datetime import datetime
from datetime import datetime, timedelta
from hashlib import sha256
from typing import Any, Optional, Self
from uuid import uuid4
@ -11,12 +11,10 @@ from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.db import models
from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP
from django.http import HttpRequest
from django.utils.functional import SimpleLazyObject, cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_cte import CTEQuerySet, With
from guardian.conf import settings
from guardian.mixins import GuardianUserMixin
from model_utils.managers import InheritanceManager
@ -24,12 +22,10 @@ from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.blueprints.models import ManagedModel
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.lib.avatars import get_avatar
from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.generators import generate_id
from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.models import (
CreatedUpdatedModel,
DomainlessFormattedURLValidator,
@ -60,8 +56,6 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
"authentik_used_by_shadows",
)
GROUP_RECURSION_LIMIT = 20
def default_token_duration() -> datetime:
"""Default duration a Token is valid"""
@ -74,6 +68,11 @@ def default_token_duration() -> datetime:
return now() + timedelta_from_string(token_duration)
def token_expires_from_timedelta(dt: timedelta) -> datetime:
"""Return a `datetime.datetime` object with the duration of the Token"""
return now() + dt
def default_token_key() -> str:
"""Default token key"""
current_tenant = get_current_tenant()
@ -102,78 +101,12 @@ class UserTypes(models.TextChoices):
INTERNAL_SERVICE_ACCOUNT = "internal_service_account"
class AttributesMixin(models.Model):
"""Adds an attributes property to a model"""
attributes = models.JSONField(default=dict, blank=True)
class Meta:
abstract = True
def update_attributes(self, properties: dict[str, Any]):
"""Update fields and attributes, but correctly by merging dicts"""
for key, value in properties.items():
if key == "attributes":
continue
setattr(self, key, value)
final_attributes = {}
MERGE_LIST_UNIQUE.merge(final_attributes, self.attributes)
MERGE_LIST_UNIQUE.merge(final_attributes, properties.get("attributes", {}))
self.attributes = final_attributes
self.save()
@classmethod
def update_or_create_attributes(
cls, query: dict[str, Any], properties: dict[str, Any]
) -> tuple[models.Model, bool]:
"""Same as django's update_or_create but correctly updates attributes by merging dicts"""
instance = cls.objects.filter(**query).first()
if not instance:
return cls.objects.create(**properties), True
instance.update_attributes(properties)
return instance, False
class GroupQuerySet(CTEQuerySet):
def with_children_recursive(self):
"""Recursively get all groups that have the current queryset as parents
or are indirectly related."""
def make_cte(cte):
"""Build the query that ends up in WITH RECURSIVE"""
# Start from self, aka the current query
# Add a depth attribute to limit the recursion
return self.annotate(
relative_depth=models.Value(0, output_field=models.IntegerField())
).union(
# Here is the recursive part of the query. cte refers to the previous iteration
# Only select groups for which the parent is part of the previous iteration
# and increase the depth
# Finally, limit the depth
cte.join(Group, group_uuid=cte.col.parent_id)
.annotate(
relative_depth=models.ExpressionWrapper(
cte.col.relative_depth
+ models.Value(1, output_field=models.IntegerField()),
output_field=models.IntegerField(),
)
)
.filter(relative_depth__lt=GROUP_RECURSION_LIMIT),
all=True,
)
# Build the recursive query, see above
cte = With.recursive(make_cte)
# Return the result, as a usable queryset for Group.
return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte)
class Group(SerializerModel, AttributesMixin):
class Group(SerializerModel):
"""Group model which supports a basic hierarchy and has attributes"""
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField(_("name"))
name = models.CharField(_("name"), max_length=80)
is_superuser = models.BooleanField(
default=False, help_text=_("Users added to this group will be superusers.")
)
@ -188,26 +121,7 @@ class Group(SerializerModel, AttributesMixin):
on_delete=models.SET_NULL,
related_name="children",
)
objects = GroupQuerySet.as_manager()
class Meta:
unique_together = (
(
"name",
"parent",
),
)
indexes = [models.Index(fields=["name"])]
verbose_name = _("Group")
verbose_name_plural = _("Groups")
permissions = [
("add_user_to_group", _("Add user to group")),
("remove_user_from_group", _("Remove user from group")),
]
def __str__(self):
return f"Group {self.name}"
attributes = models.JSONField(default=dict, blank=True)
@property
def serializer(self) -> Serializer:
@ -227,11 +141,54 @@ class Group(SerializerModel, AttributesMixin):
return user.all_groups().filter(group_uuid=self.group_uuid).exists()
def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]:
"""Compatibility layer for Group.objects.with_children_recursive()"""
qs = self
if not isinstance(self, QuerySet):
qs = Group.objects.filter(group_uuid=self.group_uuid)
return qs.with_children_recursive()
"""Recursively get all groups that have this as parent or are indirectly related"""
direct_groups = []
if isinstance(self, QuerySet):
direct_groups = list(x for x in self.all().values_list("pk", flat=True).iterator())
else:
direct_groups = [self.pk]
if len(direct_groups) < 1:
return Group.objects.none()
query = """
WITH RECURSIVE parents AS (
SELECT authentik_core_group.*, 0 AS relative_depth
FROM authentik_core_group
WHERE authentik_core_group.group_uuid = ANY(%s)
UNION ALL
SELECT authentik_core_group.*, parents.relative_depth + 1
FROM authentik_core_group, parents
WHERE (
authentik_core_group.group_uuid = parents.parent_id and
parents.relative_depth < 20
)
)
SELECT group_uuid
FROM parents
GROUP BY group_uuid, name
ORDER BY name;
"""
group_pks = [group.pk for group in Group.objects.raw(query, [direct_groups]).iterator()]
return Group.objects.filter(pk__in=group_pks)
def __str__(self):
return f"Group {self.name}"
class Meta:
unique_together = (
(
"name",
"parent",
),
)
indexes = [models.Index(fields=["name"])]
verbose_name = _("Group")
verbose_name_plural = _("Groups")
permissions = [
("add_user_to_group", _("Add user to group")),
("remove_user_from_group", _("Remove user from group")),
]
class UserQuerySet(models.QuerySet):
@ -258,7 +215,7 @@ class UserManager(DjangoUserManager):
return self.get_queryset().exclude_anonymous()
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
class User(SerializerModel, GuardianUserMixin, AbstractUser):
"""authentik User model, based on django's contrib auth user model."""
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
@ -270,38 +227,20 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
ak_groups = models.ManyToManyField("Group", related_name="users")
password_change_date = models.DateTimeField(auto_now_add=True)
attributes = models.JSONField(default=dict, blank=True)
objects = UserManager()
class Meta:
verbose_name = _("User")
verbose_name_plural = _("Users")
permissions = [
("reset_user_password", _("Reset Password")),
("impersonate", _("Can impersonate other users")),
("assign_user_permissions", _("Can assign permissions to users")),
("unassign_user_permissions", _("Can unassign permissions from users")),
("preview_user", _("Can preview user data sent to providers")),
("view_user_applications", _("View applications the user has access to")),
]
indexes = [
models.Index(fields=["last_login"]),
models.Index(fields=["password_change_date"]),
models.Index(fields=["uuid"]),
models.Index(fields=["path"]),
models.Index(fields=["type"]),
]
def __str__(self):
return self.username
@staticmethod
def default_path() -> str:
"""Get the default user path"""
return User._meta.get_field("path").default
def all_groups(self) -> QuerySet[Group]:
"""Recursively get all groups this user is a member of."""
return self.ak_groups.all().with_children_recursive()
"""Recursively get all groups this user is a member of.
At least one query is done to get the direct groups of the user, with groups
there are at most 3 queries done"""
return Group.children_recursive(self.ak_groups.all())
def group_attributes(self, request: HttpRequest | None = None) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to,
@ -375,6 +314,25 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
"""Get avatar, depending on authentik.avatar setting"""
return get_avatar(self)
class Meta:
verbose_name = _("User")
verbose_name_plural = _("Users")
permissions = [
("reset_user_password", _("Reset Password")),
("impersonate", _("Can impersonate other users")),
("assign_user_permissions", _("Can assign permissions to users")),
("unassign_user_permissions", _("Can unassign permissions from users")),
("preview_user", _("Can preview user data sent to providers")),
("view_user_applications", _("View applications the user has access to")),
]
indexes = [
models.Index(fields=["last_login"]),
models.Index(fields=["password_change_date"]),
models.Index(fields=["uuid"]),
models.Index(fields=["path"]),
models.Index(fields=["type"]),
]
class Provider(SerializerModel):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
@ -424,10 +382,6 @@ class Provider(SerializerModel):
Can return None for providers that are not URL-based"""
return None
@property
def icon_url(self) -> str | None:
return None
@property
def component(self) -> str:
"""Return component used to edit this object"""
@ -462,14 +416,6 @@ class BackchannelProvider(Provider):
abstract = True
class ApplicationQuerySet(QuerySet):
def with_provider(self) -> "QuerySet[Application]":
qs = self.select_related("provider")
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
qs = qs.select_related(f"provider__{subclass}")
return qs
class Application(SerializerModel, PolicyBindingModel):
"""Every Application which uses authentik for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to
@ -501,8 +447,6 @@ class Application(SerializerModel, PolicyBindingModel):
meta_description = models.TextField(default="", blank=True)
meta_publisher = models.TextField(default="", blank=True)
objects = ApplicationQuerySet.as_manager()
@property
def serializer(self) -> Serializer:
from authentik.core.api.applications import ApplicationSerializer
@ -539,28 +483,16 @@ class Application(SerializerModel, PolicyBindingModel):
return url
def get_provider(self) -> Provider | None:
"""Get casted provider instance. Needs Application queryset with_provider"""
"""Get casted provider instance"""
if not self.provider:
return None
candidates = []
base_class = Provider
for subclass in base_class.objects.get_queryset()._get_subclasses_recurse(base_class):
parent = self.provider
for level in subclass.split(LOOKUP_SEP):
try:
parent = getattr(parent, level)
except AttributeError:
break
if parent in candidates:
continue
idx = subclass.count(LOOKUP_SEP)
if type(parent) is not base_class:
idx += 1
candidates.insert(idx, parent)
if not candidates:
# if the Application class has been cache, self.provider is set
# but doing a direct query lookup will fail.
# In that case, just return None
try:
return Provider.objects.get_subclass(pk=self.provider.pk)
except Provider.DoesNotExist:
return None
return candidates[-1]
def __str__(self):
return str(self.name)
@ -590,19 +522,6 @@ class SourceUserMatchingModes(models.TextChoices):
)
class SourceGroupMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning groups"""
IDENTIFIER = "identifier", _("Use the source-specific identifier")
NAME_LINK = "name_link", _(
"Link to a group with identical name. Can have security implications "
"when a group name is used with another source."
)
NAME_DENY = "name_deny", _(
"Use the group name, but deny enrollment when the name already exists."
)
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
@ -612,12 +531,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
enabled = models.BooleanField(default=True)
user_property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True, related_name="source_userpropertymappings_set"
)
group_property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True, related_name="source_grouppropertymappings_set"
)
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
icon = models.FileField(
upload_to="source-icons/",
default=None,
@ -652,14 +566,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"a new user enrolled."
),
)
group_matching_mode = models.TextField(
choices=SourceGroupMatchingModes.choices,
default=SourceGroupMatchingModes.IDENTIFIER,
help_text=_(
"How the source determines if an existing group should be used or "
"a new group created."
),
)
objects = InheritanceManager()
@ -689,11 +595,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"""Return component used to edit this object"""
raise NotImplementedError
@property
def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object"""
raise NotImplementedError
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
"""If source uses a http-based flow, return UI Information about the login
button. If source doesn't use http-based flow, return None."""
@ -704,14 +605,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
user settings are available, or UserSettingSerializer."""
return None
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user to build final properties upon."""
raise NotImplementedError
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a group to build final properties upon."""
raise NotImplementedError
def __str__(self):
return str(self.name)
@ -727,11 +620,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"name",
]
),
models.Index(
fields=[
"enabled",
]
),
]
@ -749,33 +637,12 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
raise NotImplementedError
def __str__(self) -> str:
return f"User-source connection (user={self.user_id}, source={self.source_id})"
return f"User-source connection (user={self.user.username}, source={self.source.slug})"
class Meta:
unique_together = (("user", "source"),)
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
"""Connection between Group and Source."""
group = models.ForeignKey(Group, on_delete=models.CASCADE)
source = models.ForeignKey(Source, on_delete=models.CASCADE)
identifier = models.TextField()
objects = InheritanceManager()
@property
def serializer(self) -> type[Serializer]:
"""Get serializer for this model"""
raise NotImplementedError
def __str__(self) -> str:
return f"Group-source connection (group={self.group_id}, source={self.source_id})"
class Meta:
unique_together = (("group", "source"),)
class ExpiringModel(models.Model):
"""Base Model which can expire, and is automatically cleaned up."""
@ -905,10 +772,8 @@ class PropertyMapping(SerializerModel, ManagedModel):
evaluator = PropertyMappingEvaluator(self, user, request, **kwargs)
try:
return evaluator.evaluate(self.expression)
except ControlFlowException as exc:
raise exc
except Exception as exc:
raise PropertyMappingExpressionException(exc, self) from exc
raise PropertyMappingExpressionException(exc) from exc
def __str__(self):
return f"Property Mapping {self.name}"

View File

@ -52,8 +52,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
@receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Delete AuthenticatedSession if it exists"""
if not request.session or not request.session.session_key:
return
AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete()

View File

@ -4,7 +4,7 @@ from enum import Enum
from typing import Any
from django.contrib import messages
from django.db import IntegrityError, transaction
from django.db import IntegrityError
from django.db.models.query_utils import Q
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
@ -12,20 +12,8 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
from authentik.core.models import (
Group,
GroupSourceConnection,
Source,
SourceGroupMatchingModes,
SourceUserMatchingModes,
User,
UserSourceConnection,
)
from authentik.core.sources.mapper import SourceMapper
from authentik.core.sources.stage import (
PLAN_CONTEXT_SOURCES_CONNECTION,
PostSourceStage,
)
from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import Flow, FlowToken, Stage, in_memory_stage
@ -48,10 +36,7 @@ from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
LOGGER = get_logger()
SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token" # nosec
PLAN_CONTEXT_SOURCE_GROUPS = "source_groups"
class Action(Enum):
@ -85,69 +70,50 @@ class SourceFlowManager:
or deny the request."""
source: Source
mapper: SourceMapper
request: HttpRequest
identifier: str
user_connection_type: type[UserSourceConnection] = UserSourceConnection
group_connection_type: type[GroupSourceConnection] = GroupSourceConnection
connection_type: type[UserSourceConnection] = UserSourceConnection
user_info: dict[str, Any]
enroll_info: dict[str, Any]
policy_context: dict[str, Any]
user_properties: dict[str, Any | dict[str, Any]]
groups_properties: dict[str, dict[str, Any | dict[str, Any]]]
def __init__(
self,
source: Source,
request: HttpRequest,
identifier: str,
user_info: dict[str, Any],
policy_context: dict[str, Any],
enroll_info: dict[str, Any],
) -> None:
self.source = source
self.mapper = SourceMapper(self.source)
self.request = request
self.identifier = identifier
self.user_info = user_info
self.enroll_info = enroll_info
self._logger = get_logger().bind(source=source, identifier=identifier)
self.policy_context = policy_context
self.user_properties = self.mapper.build_object_properties(
object_type=User, request=request, user=None, **self.user_info
)
self.groups_properties = {
group_id: self.mapper.build_object_properties(
object_type=Group,
request=request,
user=None,
group_id=group_id,
**self.user_info,
)
for group_id in self.user_properties.setdefault("groups", [])
}
del self.user_properties["groups"]
self.policy_context = {}
def get_action(self, **kwargs) -> tuple[Action, UserSourceConnection | None]: # noqa: PLR0911
"""decide which action should be taken"""
new_connection = self.user_connection_type(source=self.source, identifier=self.identifier)
new_connection = self.connection_type(source=self.source, identifier=self.identifier)
# When request is authenticated, always link
if self.request.user.is_authenticated:
new_connection.user = self.request.user
new_connection = self.update_user_connection(new_connection, **kwargs)
new_connection = self.update_connection(new_connection, **kwargs)
new_connection.save()
return Action.LINK, new_connection
existing_connections = self.user_connection_type.objects.filter(
existing_connections = self.connection_type.objects.filter(
source=self.source, identifier=self.identifier
)
if existing_connections.exists():
connection = existing_connections.first()
return Action.AUTH, self.update_user_connection(connection, **kwargs)
return Action.AUTH, self.update_connection(connection, **kwargs)
# No connection exists, but we match on identifier, so enroll
if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER:
# We don't save the connection here cause it doesn't have a user assigned yet
return Action.ENROLL, self.update_user_connection(new_connection, **kwargs)
return Action.ENROLL, self.update_connection(new_connection, **kwargs)
# Check for existing users with matching attributes
query = Q()
@ -156,24 +122,24 @@ class SourceFlowManager:
SourceUserMatchingModes.EMAIL_LINK,
SourceUserMatchingModes.EMAIL_DENY,
]:
if not self.user_properties.get("email", None):
self._logger.warning("Refusing to use none email")
if not self.enroll_info.get("email", None):
self._logger.warning("Refusing to use none email", source=self.source)
return Action.DENY, None
query = Q(email__exact=self.user_properties.get("email", None))
query = Q(email__exact=self.enroll_info.get("email", None))
if self.source.user_matching_mode in [
SourceUserMatchingModes.USERNAME_LINK,
SourceUserMatchingModes.USERNAME_DENY,
]:
if not self.user_properties.get("username", None):
self._logger.warning("Refusing to use none username")
if not self.enroll_info.get("username", None):
self._logger.warning("Refusing to use none username", source=self.source)
return Action.DENY, None
query = Q(username__exact=self.user_properties.get("username", None))
query = Q(username__exact=self.enroll_info.get("username", None))
self._logger.debug("trying to link with existing user", query=query)
matching_users = User.objects.filter(query)
# No matching users, always enroll
if not matching_users.exists():
self._logger.debug("no matching users found, enrolling")
return Action.ENROLL, self.update_user_connection(new_connection, **kwargs)
return Action.ENROLL, self.update_connection(new_connection, **kwargs)
user = matching_users.first()
if self.source.user_matching_mode in [
@ -181,7 +147,8 @@ class SourceFlowManager:
SourceUserMatchingModes.USERNAME_LINK,
]:
new_connection.user = user
new_connection = self.update_user_connection(new_connection, **kwargs)
new_connection = self.update_connection(new_connection, **kwargs)
new_connection.save()
return Action.LINK, new_connection
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_DENY,
@ -192,10 +159,10 @@ class SourceFlowManager:
# Should never get here as default enroll case is returned above.
return Action.DENY, None # pragma: no cover
def update_user_connection(
def update_connection(
self, connection: UserSourceConnection, **kwargs
) -> UserSourceConnection: # pragma: no cover
"""Optionally make changes to the user connection after it is looked up/created."""
"""Optionally make changes to the connection after it is looked up/created."""
return connection
def get_flow(self, **kwargs) -> HttpResponse:
@ -242,40 +209,38 @@ class SourceFlowManager:
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
"""Hook to override stages which are appended to the flow"""
return [
in_memory_stage(PostSourceStage),
]
if not self.source.enrollment_flow:
return []
if flow.slug == self.source.enrollment_flow.slug:
return [
in_memory_stage(PostUserEnrollmentStage),
]
return []
def _prepare_flow(
self,
flow: Flow | None,
flow: Flow,
connection: UserSourceConnection,
stages: list[StageView] | None = None,
**flow_context,
**kwargs,
) -> HttpResponse:
"""Prepare Authentication Plan, redirect user FlowExecutor"""
# Ensure redirect is carried through when user was trying to
# authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user"
)
flow_context.update(
kwargs.update(
{
# Since we authenticate the user by their token, they have no backend set
PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT,
PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
PLAN_CONTEXT_SOURCE_GROUPS: self.groups_properties,
}
)
flow_context.update(self.policy_context)
kwargs.update(self.policy_context)
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
plan.context.update(flow_context)
plan.context.update(kwargs)
for stage in self.get_stages_to_append(flow):
plan.append_stage(stage)
if stages:
@ -294,8 +259,8 @@ class SourceFlowManager:
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user"
)
if PLAN_CONTEXT_REDIRECT not in flow_context:
flow_context[PLAN_CONTEXT_REDIRECT] = final_redirect
if PLAN_CONTEXT_REDIRECT not in kwargs:
kwargs[PLAN_CONTEXT_REDIRECT] = final_redirect
if not flow:
return bad_request_message(
@ -304,15 +269,9 @@ class SourceFlowManager:
)
# We run the Flow planner here so we can pass the Pending user in the context
planner = FlowPlanner(flow)
# We append some stages so the initial flow we get might be empty
planner.allow_empty_flows = True
planner.use_cache = False
plan = planner.plan(self.request, flow_context)
plan = planner.plan(self.request, kwargs)
for stage in self.get_stages_to_append(flow):
plan.append_stage(stage)
plan.append_stage(
in_memory_stage(GroupUpdateStage, group_connection_type=self.group_connection_type)
)
if stages:
for stage in stages:
plan.append_stage(stage)
@ -354,9 +313,7 @@ class SourceFlowManager:
# When request isn't authenticated we jump straight to auth
if not self.request.user.is_authenticated:
return self.handle_auth(connection)
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
return self._prepare_flow(None, connection)
connection.save()
# Connection has already been saved
Event.new(
EventAction.SOURCE_LINKED,
message="Linked Source",
@ -370,7 +327,7 @@ class SourceFlowManager:
reverse(
"authentik_core:if-user",
)
+ "#/settings;page-sources"
+ f"#/settings;page-{self.source.slug}"
)
def handle_enroll(
@ -399,123 +356,7 @@ class SourceFlowManager:
)
],
**{
PLAN_CONTEXT_PROMPT: delete_none_values(self.user_properties),
PLAN_CONTEXT_PROMPT: delete_none_values(self.enroll_info),
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
},
)
class GroupUpdateStage(StageView):
"""Dynamically injected stage which updates the user after enrollment/authentication."""
def get_action(
self, group_id: str, group_properties: dict[str, Any | dict[str, Any]]
) -> tuple[Action, GroupSourceConnection | None]:
"""decide which action should be taken"""
new_connection = self.group_connection_type(source=self.source, identifier=group_id)
existing_connections = self.group_connection_type.objects.filter(
source=self.source, identifier=group_id
)
if existing_connections.exists():
return Action.LINK, existing_connections.first()
# No connection exists, but we match on identifier, so enroll
if self.source.group_matching_mode == SourceGroupMatchingModes.IDENTIFIER:
# We don't save the connection here cause it doesn't have a user assigned yet
return Action.ENROLL, new_connection
# Check for existing groups with matching attributes
query = Q()
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_LINK,
SourceGroupMatchingModes.NAME_DENY,
]:
if not group_properties.get("name", None):
LOGGER.warning(
"Refusing to use none group name", source=self.source, group_id=group_id
)
return Action.DENY, None
query = Q(name__exact=group_properties.get("name"))
LOGGER.debug(
"trying to link with existing group", source=self.source, query=query, group_id=group_id
)
matching_groups = Group.objects.filter(query)
# No matching groups, always enroll
if not matching_groups.exists():
LOGGER.debug(
"no matching groups found, enrolling", source=self.source, group_id=group_id
)
return Action.ENROLL, new_connection
group = matching_groups.first()
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_LINK,
]:
new_connection.group = group
return Action.LINK, new_connection
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_DENY,
]:
LOGGER.info(
"denying source because group exists",
source=self.source,
group=group,
group_id=group_id,
)
return Action.DENY, None
# Should never get here as default enroll case is returned above.
return Action.DENY, None # pragma: no cover
def handle_group(
self, group_id: str, group_properties: dict[str, Any | dict[str, Any]]
) -> Group | None:
action, connection = self.get_action(group_id, group_properties)
if action == Action.ENROLL:
group = Group.objects.create(**group_properties)
connection.group = group
connection.save()
return group
elif action == Action.LINK:
group = connection.group
group.update_attributes(group_properties)
connection.save()
return group
return None
def handle_groups(self) -> bool:
self.source: Source = self.executor.plan.context[PLAN_CONTEXT_SOURCE]
self.user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
self.group_connection_type: GroupSourceConnection = (
self.executor.current_stage.group_connection_type
)
raw_groups: dict[str, dict[str, Any | dict[str, Any]]] = self.executor.plan.context[
PLAN_CONTEXT_SOURCE_GROUPS
]
groups: list[Group] = []
for group_id, group_properties in raw_groups.items():
group = self.handle_group(group_id, group_properties)
if not group:
return False
groups.append(group)
with transaction.atomic():
self.user.ak_groups.remove(
*self.user.ak_groups.filter(groupsourceconnection__source=self.source)
)
self.user.ak_groups.add(*groups)
return True
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Stage used after the user has been enrolled to sync their groups from source data"""
if self.handle_groups():
return self.executor.stage_ok()
else:
return self.executor.stage_invalid("Failed to update groups. Please try again later.")
def post(self, request: HttpRequest) -> HttpResponse:
"""Wrapper for post requests"""
return self.get(request)

View File

@ -1,103 +0,0 @@
from typing import Any
from django.http import HttpRequest
from structlog.stdlib import get_logger
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.models import Group, PropertyMapping, Source, User
from authentik.events.models import Event, EventAction
from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.policies.utils import delete_none_values
LOGGER = get_logger()
class SourceMapper:
def __init__(self, source: Source):
self.source = source
def get_manager(
self, object_type: type[User | Group], context_keys: list[str]
) -> PropertyMappingManager:
"""Get property mapping manager for this source."""
qs = PropertyMapping.objects.none()
if object_type == User:
qs = self.source.user_property_mappings.all().select_subclasses()
elif object_type == Group:
qs = self.source.group_property_mappings.all().select_subclasses()
qs = qs.order_by("name")
return PropertyMappingManager(
qs,
self.source.property_mapping_type,
["source", "properties"] + context_keys,
)
def get_base_properties(
self, object_type: type[User | Group], **kwargs
) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user or a group to build final properties upon."""
if object_type == User:
properties = self.source.get_base_user_properties(**kwargs)
properties.setdefault("path", self.source.get_user_path())
return properties
if object_type == Group:
return self.source.get_base_group_properties(**kwargs)
return {}
def build_object_properties(
self,
object_type: type[User | Group],
manager: "PropertyMappingManager | None" = None,
user: User | None = None,
request: HttpRequest | None = None,
**kwargs,
) -> dict[str, Any | dict[str, Any]]:
"""Build a user or group properties from the source configured property mappings."""
properties = self.get_base_properties(object_type, **kwargs)
if "attributes" not in properties:
properties["attributes"] = {}
if not manager:
manager = self.get_manager(object_type, list(kwargs.keys()))
evaluations = manager.iter_eval(
user=user,
request=request,
return_mapping=True,
source=self.source,
properties=properties,
**kwargs,
)
while True:
try:
value, mapping = next(evaluations)
except StopIteration:
break
except PropertyMappingExpressionException as exc:
Event.new(
EventAction.CONFIGURATION_ERROR,
message=f"Failed to evaluate property mapping: '{exc.mapping.name}'",
source=self,
mapping=exc.mapping,
).save()
LOGGER.warning(
"Mapping failed to evaluate",
exc=exc,
source=self,
mapping=exc.mapping,
)
raise exc
if not value or not isinstance(value, dict):
LOGGER.debug(
"Mapping evaluated to None or is not a dict. Skipping",
source=self,
mapping=mapping,
)
continue
MERGE_LIST_UNIQUE.merge(properties, value)
return delete_none_values(properties)

View File

@ -10,7 +10,7 @@ from authentik.flows.stage import StageView
PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection"
class PostSourceStage(StageView):
class PostUserEnrollmentStage(StageView):
"""Dynamically injected stage which saves the Connection after
the user has been enrolled."""
@ -21,12 +21,10 @@ class PostSourceStage(StageView):
]
user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
connection.user = user
linked = connection.pk is None
connection.save()
if linked:
Event.new(
EventAction.SOURCE_LINKED,
message="Linked Source",
source=connection.source,
).from_http(self.request)
Event.new(
EventAction.SOURCE_LINKED,
message="Linked Source",
source=connection.source,
).from_http(self.request)
return self.executor.stage_ok()

View File

@ -2,9 +2,7 @@
from datetime import datetime, timedelta
from django.conf import ImproperlyConfigured
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
from django.core.cache import cache
from django.utils.timezone import now
from structlog.stdlib import get_logger
@ -17,7 +15,6 @@ from authentik.core.models import (
User,
)
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@ -42,31 +39,16 @@ def clean_expired_models(self: SystemTask):
amount = 0
for session in AuthenticatedSession.objects.all():
match CONFIG.get("session_storage", "cache"):
case "cache":
cache_key = f"{KEY_PREFIX}{session.session_key}"
value = None
try:
value = cache.get(cache_key)
cache_key = f"{KEY_PREFIX}{session.session_key}"
value = None
try:
value = cache.get(cache_key)
except Exception as exc:
LOGGER.debug("Failed to get session from cache", exc=exc)
if not value:
session.delete()
amount += 1
case "db":
if not (
DBSessionStore.get_model_class()
.objects.filter(session_key=session.session_key, expire_date__gt=now())
.exists()
):
session.delete()
amount += 1
case _:
# Should never happen, as we check for other values in authentik/root/settings.py
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
except Exception as exc:
LOGGER.debug("Failed to get session from cache", exc=exc)
if not value:
session.delete()
amount += 1
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")

View File

@ -10,7 +10,7 @@
versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}",
};
window.addEventListener("DOMContentLoaded", function () {
window.addEventListener("DOMContentLoaded", () => {
{% for message in messages %}
window.dispatchEvent(
new CustomEvent("ak-message", {

View File

@ -1,10 +1,9 @@
{% load static %}
{% load i18n %}
{% load authentik_core %}
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
@ -15,8 +14,8 @@
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
{% versioned_script "dist/poly-%v.js" %}
{% versioned_script "dist/standalone/loading/index-%v.js" %}
<script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script>
<script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script>
{% block head %}
{% endblock %}
<meta name="sentry-trace" content="{{ sentry_trace }}" />

View File

@ -1,9 +1,9 @@
{% extends "base/skeleton.html" %}
{% load authentik_core %}
{% load static %}
{% block head %}
{% versioned_script "dist/admin/AdminInterface-%v.js" %}
<script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
{% include "base/header_js.html" %}

View File

@ -1,7 +1,6 @@
{% extends "base/skeleton.html" %}
{% load static %}
{% load authentik_core %}
{% block head_before %}
{{ block.super }}
@ -18,7 +17,7 @@ window.authentik.flow = {
{% endblock %}
{% block head %}
{% versioned_script "dist/flow/FlowInterface-%v.js" %}
<script src="{% static 'dist/flow/FlowInterface.js' %}?version={{ version }}" type="module"></script>
<style>
:root {
--ak-flow-background: url("{{ flow.background_url }}");

View File

@ -1,9 +1,9 @@
{% extends "base/skeleton.html" %}
{% load authentik_core %}
{% load static %}
{% block head %}
{% versioned_script "dist/user/UserInterface-%v.js" %}
<script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)">
{% include "base/header_js.html" %}

View File

@ -71,9 +71,9 @@
</li>
{% endfor %}
<li>
<span>
<a href="https://goauthentik.io?utm_source=authentik">
{% trans 'Powered by authentik' %}
</span>
</a>
</li>
</ul>
</footer>

View File

@ -1,21 +0,0 @@
"""authentik core tags"""
from django import template
from django.templatetags.static import static as static_loader
from django.utils.safestring import mark_safe
from authentik import get_full_version
register = template.Library()
@register.simple_tag()
def versioned_script(path: str) -> str:
"""Wrapper around {% static %} tag that supports setting the version"""
returned_lines = [
(
f'<script src="{static_loader(path.replace("%v", get_full_version()))}'
'" type="module"></script>'
),
]
return mark_safe("".join(returned_lines)) # nosec

View File

@ -9,12 +9,9 @@ from rest_framework.test import APITestCase
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.proxy.models import ProxyProvider
from authentik.providers.saml.models import SAMLProvider
class TestApplicationsAPI(APITestCase):
@ -225,31 +222,3 @@ class TestApplicationsAPI(APITestCase):
],
},
)
def test_get_provider(self):
"""Ensure that proxy providers (at the time of writing that is the only provider
that inherits from another proxy type (OAuth) instead of inheriting from the root
provider class) is correctly looked up and selected from the database"""
slug = generate_id()
provider = ProxyProvider.objects.create(name=generate_id())
Application.objects.create(
name=generate_id(),
slug=slug,
provider=provider,
)
self.assertEqual(Application.objects.get(slug=slug).get_provider(), provider)
self.assertEqual(
Application.objects.with_provider().get(slug=slug).get_provider(), provider
)
slug = generate_id()
provider = SAMLProvider.objects.create(name=generate_id())
Application.objects.create(
name=generate_id(),
slug=slug,
provider=provider,
)
self.assertEqual(Application.objects.get(slug=slug).get_provider(), provider)
self.assertEqual(
Application.objects.with_provider().get(slug=slug).get_provider(), provider
)

View File

@ -5,7 +5,7 @@ from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id
@ -16,24 +16,6 @@ class TestGroupsAPI(APITestCase):
self.login_user = create_test_user()
self.user = User.objects.create(username="test-user")
def test_list_with_users(self):
"""Test listing with users"""
admin = create_test_admin_user()
self.client.force_login(admin)
response = self.client.get(reverse("authentik_api:group-list"), {"include_users": "true"})
self.assertEqual(response.status_code, 200)
def test_retrieve_with_users(self):
"""Test retrieve with users"""
admin = create_test_admin_user()
group = Group.objects.create(name=generate_id())
self.client.force_login(admin)
response = self.client.get(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
{"include_users": "true"},
)
self.assertEqual(response.status_code, 200)
def test_add_user(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())

View File

@ -3,10 +3,10 @@
from json import loads
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
from authentik.tenants.utils import get_current_tenant
@ -15,7 +15,7 @@ class TestImpersonation(APITestCase):
def setUp(self) -> None:
super().setUp()
self.other_user = create_test_user()
self.other_user = User.objects.create(username="to-impersonate")
self.user = create_test_admin_user()
def test_impersonate_simple(self):
@ -44,26 +44,6 @@ class TestImpersonation(APITestCase):
self.assertEqual(response_body["user"]["username"], self.user.username)
self.assertNotIn("original", response_body)
def test_impersonate_scoped(self):
"""Test impersonation with scoped permissions"""
new_user = create_test_user()
assign_perm("authentik_core.impersonate", new_user, self.other_user)
assign_perm("authentik_core.view_user", new_user, self.other_user)
self.client.force_login(new_user)
response = self.client.post(
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
)
self.assertEqual(response.status_code, 201)
response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode())
self.assertEqual(response_body["user"]["username"], self.other_user.username)
self.assertEqual(response_body["original"]["username"], new_user.username)
def test_impersonate_denied(self):
"""test impersonation without permissions"""
self.client.force_login(self.other_user)

View File

@ -1,14 +1,14 @@
"""authentik core models tests"""
from collections.abc import Callable
from datetime import timedelta
from time import sleep
from django.test import RequestFactory, TestCase
from django.utils.timezone import now
from freezegun import freeze_time
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Provider, Source, Token
from authentik.flows.models import Stage
from authentik.lib.utils.reflection import all_subclasses
@ -17,20 +17,18 @@ class TestModels(TestCase):
def test_token_expire(self):
"""Test token expiring"""
with freeze_time() as freeze:
token = Token.objects.create(expires=now(), user=get_anonymous_user())
freeze.tick(timedelta(seconds=1))
self.assertTrue(token.is_expired)
token = Token.objects.create(expires=now(), user=get_anonymous_user())
sleep(0.5)
self.assertTrue(token.is_expired)
def test_token_expire_no_expire(self):
"""Test token expiring with "expiring" set"""
with freeze_time() as freeze:
token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False)
freeze.tick(timedelta(seconds=1))
self.assertFalse(token.is_expired)
token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False)
sleep(0.5)
self.assertFalse(token.is_expired)
def source_tester_factory(test_model: type[Source]) -> Callable:
def source_tester_factory(test_model: type[Stage]) -> Callable:
"""Test source"""
factory = RequestFactory()
@ -38,19 +36,19 @@ def source_tester_factory(test_model: type[Source]) -> Callable:
def tester(self: TestModels):
model_class = None
if test_model._meta.abstract:
model_class = [x for x in test_model.__bases__ if issubclass(x, Source)][0]()
if test_model._meta.abstract: # pragma: no cover
model_class = test_model.__bases__[0]()
else:
model_class = test_model()
model_class.slug = "test"
self.assertIsNotNone(model_class.component)
model_class.ui_login_button(request)
model_class.ui_user_settings()
_ = model_class.ui_login_button(request)
_ = model_class.ui_user_settings()
return tester
def provider_tester_factory(test_model: type[Provider]) -> Callable:
def provider_tester_factory(test_model: type[Stage]) -> Callable:
"""Test provider"""
def tester(self: TestModels):

View File

@ -3,10 +3,7 @@
from django.test import RequestFactory, TestCase
from guardian.shortcuts import get_anonymous_user
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.models import PropertyMapping
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import Event, EventAction
@ -45,17 +42,6 @@ class TestPropertyMappings(TestCase):
self.assertTrue(events.exists())
self.assertEqual(len(events), 1)
def test_expression_skip(self):
"""Test expression error"""
expr = "raise SkipObject"
mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr)
with self.assertRaises(SkipObjectException):
mapping.evaluate(None, None)
events = Event.objects.filter(
action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
)
self.assertFalse(events.exists())
def test_expression_error_extended(self):
"""Test expression error (with user and http request"""
expr = "return aaa"
@ -80,11 +66,14 @@ class TestPropertyMappings(TestCase):
expression="return request.http_request.path",
)
http_request = self.factory.get("/")
tmpl = f"""
res = ak_call_policy('{expr.name}')
tmpl = (
"""
res = ak_call_policy('%s')
result = [request.http_request.path, res.raw_result]
return result
"""
% expr.name
)
evaluator = PropertyMapping(expression=tmpl, name=generate_id())
res = evaluator.evaluate(self.user, http_request)
self.assertEqual(res, ["/", "/"])

View File

@ -6,10 +6,9 @@ from django.urls import reverse
from rest_framework.serializers import ValidationError
from rest_framework.test import APITestCase
from authentik.core.api.property_mappings import PropertyMappingSerializer
from authentik.core.models import Group, PropertyMapping
from authentik.core.api.propertymappings import PropertyMappingSerializer
from authentik.core.models import PropertyMapping
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
class TestPropertyMappingAPI(APITestCase):
@ -17,40 +16,23 @@ class TestPropertyMappingAPI(APITestCase):
def setUp(self) -> None:
super().setUp()
self.mapping = PropertyMapping.objects.create(
name="dummy", expression="""return {'foo': 'bar'}"""
)
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_test_call(self):
"""Test PropertyMappings's test endpoint"""
mapping = PropertyMapping.objects.create(
name="dummy", expression="""return {'foo': 'bar', 'baz': user.username}"""
)
"""Test PropertMappings's test endpoint"""
response = self.client.post(
reverse("authentik_api:propertymapping-test", kwargs={"pk": mapping.pk}),
reverse("authentik_api:propertymapping-test", kwargs={"pk": self.mapping.pk}),
data={
"user": self.user.pk,
},
)
self.assertJSONEqual(
response.content.decode(),
{"result": dumps({"foo": "bar", "baz": self.user.username}), "successful": True},
)
def test_test_call_group(self):
"""Test PropertyMappings's test endpoint"""
mapping = PropertyMapping.objects.create(
name="dummy", expression="""return {'foo': 'bar', 'baz': group.name}"""
)
group = Group.objects.create(name=generate_id())
response = self.client.post(
reverse("authentik_api:propertymapping-test", kwargs={"pk": mapping.pk}),
data={
"group": group.pk,
},
)
self.assertJSONEqual(
response.content.decode(),
{"result": dumps({"foo": "bar", "baz": group.name}), "successful": True},
{"result": dumps({"foo": "bar"}), "successful": True},
)
def test_validate(self):

View File

@ -2,15 +2,11 @@
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from django.urls import reverse
from guardian.utils import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes, User
from authentik.core.sources.flow_manager import Action
from authentik.core.sources.stage import PostSourceStage
from authentik.core.tests.utils import create_test_flow
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request
from authentik.policies.denied import AccessDeniedResponse
@ -25,70 +21,42 @@ class TestSourceFlowManager(TestCase):
def setUp(self) -> None:
super().setUp()
self.authentication_flow = create_test_flow()
self.enrollment_flow = create_test_flow()
self.source: OAuthSource = OAuthSource.objects.create(
name=generate_id(),
slug=generate_id(),
authentication_flow=self.authentication_flow,
enrollment_flow=self.enrollment_flow,
)
self.source: OAuthSource = OAuthSource.objects.create(name="test")
self.identifier = generate_id()
def test_unauthenticated_enroll(self):
"""Test un-authenticated user enrolling"""
request = get_request("/", user=AnonymousUser())
flow_manager = OAuthSourceFlowManager(
self.source, request, self.identifier, {"info": {}}, {}
self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.ENROLL)
response = flow_manager.get_flow()
self.assertEqual(response.status_code, 302)
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
self.assertEqual(flow_plan.bindings[0].stage.view, PostSourceStage)
flow_manager.get_flow()
def test_unauthenticated_auth(self):
"""Test un-authenticated user authenticating"""
UserOAuthSourceConnection.objects.create(
user=get_anonymous_user(), source=self.source, identifier=self.identifier
)
request = get_request("/", user=AnonymousUser())
flow_manager = OAuthSourceFlowManager(
self.source, request, self.identifier, {"info": {}}, {}
self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.AUTH)
response = flow_manager.get_flow()
self.assertEqual(response.status_code, 302)
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
self.assertEqual(flow_plan.bindings[0].stage.view, PostSourceStage)
flow_manager.get_flow()
def test_authenticated_link(self):
"""Test authenticated user linking"""
UserOAuthSourceConnection.objects.create(
user=get_anonymous_user(), source=self.source, identifier=self.identifier
)
user = User.objects.create(username="foo", email="foo@bar.baz")
request = get_request("/", user=user)
flow_manager = OAuthSourceFlowManager(
self.source, request, self.identifier, {"info": {}}, {}
self.source, get_request("/", user=user), self.identifier, {}
)
action, connection = flow_manager.get_action()
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.LINK)
self.assertIsNone(connection.pk)
response = flow_manager.get_flow()
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url,
reverse("authentik_core:if-user") + "#/settings;page-sources",
)
def test_unauthenticated_link(self):
"""Test un-authenticated user linking"""
flow_manager = OAuthSourceFlowManager(
self.source, get_request("/"), self.identifier, {"info": {}}, {}
)
action, connection = flow_manager.get_action()
self.assertEqual(action, Action.LINK)
self.assertIsNone(connection.pk)
flow_manager.get_flow()
def test_unauthenticated_enroll_email(self):
@ -98,7 +66,7 @@ class TestSourceFlowManager(TestCase):
# Without email, deny
flow_manager = OAuthSourceFlowManager(
self.source, get_request("/", user=AnonymousUser()), self.identifier, {"info": {}}, {}
self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.DENY)
@ -108,12 +76,7 @@ class TestSourceFlowManager(TestCase):
self.source,
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {
"email": "foo@bar.baz",
},
},
{},
{"email": "foo@bar.baz"},
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.LINK)
@ -126,7 +89,7 @@ class TestSourceFlowManager(TestCase):
# Without username, deny
flow_manager = OAuthSourceFlowManager(
self.source, get_request("/", user=AnonymousUser()), self.identifier, {"info": {}}, {}
self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.DENY)
@ -136,10 +99,7 @@ class TestSourceFlowManager(TestCase):
self.source,
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {"username": "foo"},
},
{},
{"username": "foo"},
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.LINK)
@ -156,11 +116,8 @@ class TestSourceFlowManager(TestCase):
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {
"username": "bar",
},
"username": "bar",
},
{},
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.ENROLL)
@ -170,10 +127,7 @@ class TestSourceFlowManager(TestCase):
self.source,
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {"username": "foo"},
},
{},
{"username": "foo"},
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.DENY)
@ -187,10 +141,7 @@ class TestSourceFlowManager(TestCase):
self.source,
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {"username": "foo"},
},
{},
{"username": "foo"},
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.ENROLL)
@ -216,10 +167,7 @@ class TestSourceFlowManager(TestCase):
self.source,
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {"username": "foo"},
},
{},
{"username": "foo"},
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.ENROLL)

View File

@ -1,237 +0,0 @@
"""Test Source flow_manager group update stage"""
from django.test import RequestFactory
from authentik.core.models import Group, SourceGroupMatchingModes
from authentik.core.sources.flow_manager import PLAN_CONTEXT_SOURCE_GROUPS, GroupUpdateStage
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.generators import generate_id
from authentik.sources.oauth.models import GroupOAuthSourceConnection, OAuthSource
class TestSourceFlowManager(FlowTestCase):
"""Test Source flow_manager group update stage"""
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.authentication_flow = create_test_flow()
self.enrollment_flow = create_test_flow()
self.source: OAuthSource = OAuthSource.objects.create(
name=generate_id(),
slug=generate_id(),
authentication_flow=self.authentication_flow,
enrollment_flow=self.enrollment_flow,
)
self.identifier = generate_id()
self.user = create_test_admin_user()
def test_nonexistant_group(self):
request = self.factory.get("/")
stage = GroupUpdateStage(
FlowExecutorView(
current_stage=in_memory_stage(
GroupUpdateStage, group_connection_type=GroupOAuthSourceConnection
),
plan=FlowPlan(
flow_pk=generate_id(),
context={
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_PENDING_USER: self.user,
PLAN_CONTEXT_SOURCE_GROUPS: {
"group 1": {
"name": "group 1",
},
},
},
),
),
request=request,
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
).exists()
)
def test_nonexistant_group_name_link(self):
self.source.group_matching_mode = SourceGroupMatchingModes.NAME_LINK
self.source.save()
request = self.factory.get("/")
stage = GroupUpdateStage(
FlowExecutorView(
current_stage=in_memory_stage(
GroupUpdateStage, group_connection_type=GroupOAuthSourceConnection
),
plan=FlowPlan(
flow_pk=generate_id(),
context={
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_PENDING_USER: self.user,
PLAN_CONTEXT_SOURCE_GROUPS: {
"group 1": {
"name": "group 1",
},
},
},
),
),
request=request,
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
).exists()
)
def test_existant_group_name_link(self):
self.source.group_matching_mode = SourceGroupMatchingModes.NAME_LINK
self.source.save()
group = Group.objects.create(name="group 1")
request = self.factory.get("/")
stage = GroupUpdateStage(
FlowExecutorView(
current_stage=in_memory_stage(
GroupUpdateStage, group_connection_type=GroupOAuthSourceConnection
),
plan=FlowPlan(
flow_pk=generate_id(),
context={
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_PENDING_USER: self.user,
PLAN_CONTEXT_SOURCE_GROUPS: {
"group 1": {
"name": "group 1",
},
},
},
),
),
request=request,
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(group=group, source=self.source).exists()
)
def test_nonexistant_group_name_deny(self):
self.source.group_matching_mode = SourceGroupMatchingModes.NAME_DENY
self.source.save()
request = self.factory.get("/")
stage = GroupUpdateStage(
FlowExecutorView(
current_stage=in_memory_stage(
GroupUpdateStage, group_connection_type=GroupOAuthSourceConnection
),
plan=FlowPlan(
flow_pk=generate_id(),
context={
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_PENDING_USER: self.user,
PLAN_CONTEXT_SOURCE_GROUPS: {
"group 1": {
"name": "group 1",
},
},
},
),
),
request=request,
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
).exists()
)
def test_existant_group_name_deny(self):
self.source.group_matching_mode = SourceGroupMatchingModes.NAME_DENY
self.source.save()
group = Group.objects.create(name="group 1")
request = self.factory.get("/")
stage = GroupUpdateStage(
FlowExecutorView(
current_stage=in_memory_stage(
GroupUpdateStage, group_connection_type=GroupOAuthSourceConnection
),
plan=FlowPlan(
flow_pk=generate_id(),
context={
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_PENDING_USER: self.user,
PLAN_CONTEXT_SOURCE_GROUPS: {
"group 1": {
"name": "group 1",
},
},
},
),
),
request=request,
)
self.assertFalse(stage.handle_groups())
self.assertFalse(self.user.ak_groups.filter(name="group 1").exists())
self.assertFalse(
GroupOAuthSourceConnection.objects.filter(group=group, source=self.source).exists()
)
def test_group_updates(self):
self.source.group_matching_mode = SourceGroupMatchingModes.NAME_LINK
self.source.save()
other_group = Group.objects.create(name="other group")
old_group = Group.objects.create(name="old group")
new_group = Group.objects.create(name="new group")
self.user.ak_groups.set([other_group, old_group])
GroupOAuthSourceConnection.objects.create(
group=old_group, source=self.source, identifier=old_group.name
)
GroupOAuthSourceConnection.objects.create(
group=new_group, source=self.source, identifier=new_group.name
)
request = self.factory.get("/")
stage = GroupUpdateStage(
FlowExecutorView(
current_stage=in_memory_stage(
GroupUpdateStage, group_connection_type=GroupOAuthSourceConnection
),
plan=FlowPlan(
flow_pk=generate_id(),
context={
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_PENDING_USER: self.user,
PLAN_CONTEXT_SOURCE_GROUPS: {
"new group": {
"name": "new group",
},
},
},
),
),
request=request,
)
self.assertTrue(stage.handle_groups())
self.assertFalse(self.user.ak_groups.filter(name="old group").exists())
self.assertTrue(self.user.ak_groups.filter(name="other group").exists())
self.assertTrue(self.user.ak_groups.filter(name="new group").exists())
self.assertEqual(self.user.ak_groups.count(), 2)

View File

@ -1,72 +0,0 @@
"""Test Source Property mappings"""
from django.test import TestCase
from authentik.core.models import Group, PropertyMapping, Source, User
from authentik.core.sources.mapper import SourceMapper
from authentik.lib.generators import generate_id
class ProxySource(Source):
@property
def property_mapping_type(self):
return PropertyMapping
def get_base_user_properties(self, **kwargs):
return {
"username": kwargs.get("username", None),
"email": kwargs.get("email", "default@authentik"),
}
def get_base_group_properties(self, **kwargs):
return {"name": kwargs.get("name", None)}
class Meta:
proxy = True
class TestSourcePropertyMappings(TestCase):
"""Test Source PropertyMappings"""
def test_base_properties(self):
source = ProxySource.objects.create(name=generate_id(), slug=generate_id(), enabled=True)
mapper = SourceMapper(source)
user_base_properties = mapper.get_base_properties(User, username="test1")
self.assertEqual(
user_base_properties,
{
"username": "test1",
"email": "default@authentik",
"path": f"goauthentik.io/sources/{source.slug}",
},
)
group_base_properties = mapper.get_base_properties(Group)
self.assertEqual(group_base_properties, {"name": None})
def test_build_properties(self):
source = ProxySource.objects.create(name=generate_id(), slug=generate_id(), enabled=True)
mapper = SourceMapper(source)
source.user_property_mappings.add(
PropertyMapping.objects.create(
name=generate_id(),
expression="""
return {"username": data.get("username", None), "email": None}
""",
)
)
properties = mapper.build_object_properties(
object_type=User, user=None, request=None, username="test1", data={"username": "test2"}
)
self.assertEqual(
properties,
{
"username": "test2",
"path": f"goauthentik.io/sources/{source.slug}",
"attributes": {},
},
)

View File

@ -13,8 +13,9 @@ from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
Token,
TokenIntents,
User,
)
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
@ -23,7 +24,7 @@ class TestTokenAPI(APITestCase):
def setUp(self) -> None:
super().setUp()
self.user = create_test_user()
self.user = User.objects.create(username="testuser")
self.admin = create_test_admin_user()
self.client.force_login(self.user)
@ -153,24 +154,6 @@ class TestTokenAPI(APITestCase):
self.assertEqual(token.expiring, True)
self.assertNotEqual(token.expires.timestamp(), expires.timestamp())
def test_token_change_user(self):
"""Test creating a token and then changing the user"""
ident = generate_id()
response = self.client.post(reverse("authentik_api:token-list"), {"identifier": ident})
self.assertEqual(response.status_code, 201)
token = Token.objects.get(identifier=ident)
self.assertEqual(token.user, self.user)
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.expiring, True)
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
response = self.client.put(
reverse("authentik_api:token-detail", kwargs={"identifier": ident}),
data={"identifier": "user_token_poc_v3", "intent": "api", "user": self.admin.pk},
)
self.assertEqual(response.status_code, 400)
token.refresh_from_db()
self.assertEqual(token.user, self.user)
def test_list(self):
"""Test Token List (Test normal authentication)"""
Token.objects.all().delete()

View File

@ -41,12 +41,6 @@ class TestUsersAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
def test_list_with_groups(self):
"""Test listing with groups"""
self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
self.assertEqual(response.status_code, 200)
def test_metrics(self):
"""Test user's metrics"""
self.client.force_login(self.admin)

View File

@ -8,6 +8,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.config import CONFIG
from authentik.tenants.utils import get_current_tenant
@ -24,6 +25,7 @@ class TestUsersAvatars(APITestCase):
tenant.avatars = mode
tenant.save()
@CONFIG.patch("avatars", "none")
def test_avatars_none(self):
"""Test avatars none"""
self.set_avatar_mode("none")
@ -42,8 +44,8 @@ class TestUsersAvatars(APITestCase):
with Mocker() as mocker:
mocker.head(
(
"https://www.gravatar.com/avatar/76eb3c74c8beb6faa037f1b6e2ecb3e252bdac"
"6cf71fb567ae36025a9d4ea86b?size=158&rating=g&default=404"
"https://secure.gravatar.com/avatar/84730f9c1851d1ea03f1a"
"a9ed85bd1ea?size=158&rating=g&default=404"
),
text="foo",
)

View File

@ -4,7 +4,7 @@ from django.utils.text import slugify
from authentik.brands.models import Brand
from authentik.core.models import Group, User
from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id
@ -50,10 +50,12 @@ def create_test_brand(**kwargs) -> Brand:
return Brand.objects.create(domain=uid, default=True, **kwargs)
def create_test_cert(alg=PrivateKeyAlg.RSA) -> CertificateKeyPair:
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
"""Generate a certificate for testing"""
builder = CertificateBuilder(f"{generate_id()}.self-signed.goauthentik.io")
builder.alg = alg
builder = CertificateBuilder(
name=f"{generate_id()}.self-signed.goauthentik.io",
use_ec_private_key=use_ec_private_key,
)
builder.build(
subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"],
validity_days=360,

View File

@ -6,26 +6,22 @@ from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import RedirectView
from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
from authentik.core.api.groups import GroupViewSet
from authentik.core.api.property_mappings import PropertyMappingViewSet
from authentik.core.api.propertymappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.users import UserViewSet
from authentik.core.views.apps import RedirectToAppLaunch
from authentik.core.views import apps
from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.interface import (
BrandDefaultRedirectView,
InterfaceView,
RootRedirectView,
)
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
from authentik.core.views.session import EndSessionView
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware
@ -33,30 +29,30 @@ from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [
path(
"",
login_required(RootRedirectView.as_view()),
login_required(
RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True)
),
name="root-redirect",
),
path(
# We have to use this format since everything else uses application/o or application/saml
# We have to use this format since everything else uses applications/o or applications/saml
"application/launch/<slug:application_slug>/",
RedirectToAppLaunch.as_view(),
apps.RedirectToAppLaunch.as_view(),
name="application-launch",
),
# Interfaces
path(
"if/admin/",
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/admin.html")),
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/admin.html")),
name="if-admin",
),
path(
"if/user/",
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/user.html")),
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/user.html")),
name="if-user",
),
path(
"if/flow/<slug:flow_slug>/",
# FIXME: move this url to the flows app...also will cause all
# of the reverse calls to be adjusted
ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow",
),

View File

@ -8,6 +8,7 @@ from django.views import View
from authentik.core.models import Application
from authentik.flows.challenge import (
ChallengeResponse,
ChallengeTypes,
HttpChallengeResponse,
RedirectChallenge,
)
@ -73,6 +74,7 @@ class RedirectToAppStage(ChallengeStageView):
raise Http404
return RedirectChallenge(
instance={
"type": ChallengeTypes.REDIRECT.value,
"to": launch,
}
)

Some files were not shown because too many files have changed in this diff Show More