Compare commits

..

4 Commits

Author SHA1 Message Date
72d67f65e5 release: 2023.8.4 2023-10-28 21:44:15 +02:00
ea75741ec2 security: fix oobe-flow reuse when akadmin is deleted (#7361)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	website/docs/releases/2023/v2023.10.md
2023-10-28 21:26:53 +02:00
aaa9b398f4 sources/ldap: fix inverted interpretation of FreeIPA nsaccountlock (cherry-pick #6877) (#6879)
sources/ldap: fix inverted interpretation of FreeIPA nsaccountlock (#6877)

sources/ldap: fix inverted interpretation of nsaccountlock

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-09-13 19:50:48 +02:00
d54d01b118 providers/saml: set WantAuthnRequestsSigned in metadata (cherry-pick #6851) (#6880)
providers/saml: set WantAuthnRequestsSigned in metadata (#6851)

Co-authored-by: Jens L <jens@goauthentik.io>
2023-09-13 19:50:41 +02:00
1824 changed files with 64860 additions and 166951 deletions

View File

@ -1,28 +1,18 @@
[bumpversion]
current_version = 2024.4.0
current_version = 2023.8.4
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*))?
serialize =
{major}.{minor}.{patch}-{rc_t}{rc_n}
{major}.{minor}.{patch}
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
serialize = {major}.{minor}.{patch}
message = release: {new_version}
tag_name = version/{new_version}
[bumpversion:part:rc_t]
values =
rc
final
optional_value = final
[bumpversion:file:pyproject.toml]
[bumpversion:file:docker-compose.yml]
[bumpversion:file:schema.yml]
[bumpversion:file:blueprints/schema.json]
[bumpversion:file:authentik/__init__.py]
[bumpversion:file:internal/constants/constants.go]

View File

@ -1,12 +1,10 @@
env
htmlcov
*.env.yml
**/node_modules
dist/**
build/**
build_docs/**
*Dockerfile
Dockerfile
authentik/enterprise
blueprints/local
.git
!gen-ts-api/node_modules
!gen-ts-api/dist/**
!gen-go-api/

2
.github/FUNDING.yml vendored
View File

@ -1 +1 @@
custom: https://goauthentik.io/pricing/
github: [BeryJu]

View File

@ -9,7 +9,7 @@ assignees: ""
**Describe your question/**
A clear and concise description of what you're trying to do.
**Relevant info**
**Relevant infos**
i.e. Version of other software you're using, specifics of your setup
**Screenshots**

View File

@ -9,6 +9,9 @@ inputs:
runs:
using: "composite"
steps:
- name: Generate config
id: ev
uses: ./.github/actions/docker-push-variables
- name: Find Comment
uses: peter-evans/find-comment@v2
id: fc

View File

@ -1,47 +1,64 @@
---
name: "Prepare docker environment variables"
description: "Prepare docker environment variables"
inputs:
image-name:
required: true
description: "Docker image prefix"
image-arch:
required: false
description: "Docker image arch"
outputs:
shouldBuild:
description: "Whether to build image or not"
value: ${{ steps.ev.outputs.shouldBuild }}
branchName:
description: "Branch name"
value: ${{ steps.ev.outputs.branchName }}
branchNameContainer:
description: "Branch name (for containers)"
value: ${{ steps.ev.outputs.branchNameContainer }}
timestamp:
description: "Timestamp"
value: ${{ steps.ev.outputs.timestamp }}
sha:
description: "sha"
value: ${{ steps.ev.outputs.sha }}
shortHash:
description: "shortHash"
value: ${{ steps.ev.outputs.shortHash }}
version:
description: "Version"
description: "version"
value: ${{ steps.ev.outputs.version }}
prerelease:
description: "Prerelease"
value: ${{ steps.ev.outputs.prerelease }}
imageTags:
description: "Docker image tags"
value: ${{ steps.ev.outputs.imageTags }}
imageMainTag:
description: "Docker image main tag"
value: ${{ steps.ev.outputs.imageMainTag }}
versionFamily:
description: "versionFamily"
value: ${{ steps.ev.outputs.versionFamily }}
runs:
using: "composite"
steps:
- name: Generate config
id: ev
shell: bash
env:
IMAGE_NAME: ${{ inputs.image-name }}
IMAGE_ARCH: ${{ inputs.image-arch }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
shell: python
run: |
python3 ${{ github.action_path }}/push_vars.py
"""Helper script to get the actual branch name, docker safe"""
import configparser
import os
from time import time
parser = configparser.ConfigParser()
parser.read(".bumpversion.cfg")
branch_name = os.environ["GITHUB_REF"]
if os.environ.get("GITHUB_HEAD_REF", "") != "":
branch_name = os.environ["GITHUB_HEAD_REF"]
should_build = str(os.environ.get("DOCKER_USERNAME", "") != "").lower()
version = parser.get("bumpversion", "current_version")
version_family = ".".join(version.split(".")[:-1])
safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
sha = os.environ["GITHUB_SHA"] if not "${{ github.event.pull_request.head.sha }}" else "${{ github.event.pull_request.head.sha }}"
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
print("branchName=%s" % branch_name, file=_output)
print("branchNameContainer=%s" % safe_branch_name, file=_output)
print("timestamp=%s" % int(time()), file=_output)
print("sha=%s" % sha, file=_output)
print("shortHash=%s" % sha[:7], file=_output)
print("shouldBuild=%s" % should_build, file=_output)
print("version=%s" % version, file=_output)
print("versionFamily=%s" % version_family, file=_output)

View File

@ -1,62 +0,0 @@
"""Helper script to get the actual branch name, docker safe"""
import configparser
import os
from time import time
parser = configparser.ConfigParser()
parser.read(".bumpversion.cfg")
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("/", "-")
image_names = os.getenv("IMAGE_NAME").split(",")
image_arch = os.getenv("IMAGE_ARCH") or None
is_pull_request = bool(os.getenv("PR_HEAD_SHA"))
is_release = "dev" not in image_names[0]
sha = os.environ["GITHUB_SHA"] if not is_pull_request else os.getenv("PR_HEAD_SHA")
# 2042.1.0 or 2042.1.0-rc1
version = parser.get("bumpversion", "current_version")
# 2042.1
version_family = ".".join(version.split("-", 1)[0].split(".")[:-1])
prerelease = "-" in version
image_tags = []
if is_release:
for name in image_names:
image_tags += [
f"{name}:{version}",
]
if not prerelease:
image_tags += [
f"{name}:latest",
f"{name}:{version_family}",
]
else:
suffix = ""
if image_arch and image_arch != "amd64":
suffix = f"-{image_arch}"
for name in image_names:
image_tags += [
f"{name}:gh-{sha}{suffix}", # Used for ArgoCD and PR comments
f"{name}:gh-{safe_branch_name}{suffix}", # For convenience
f"{name}:gh-{safe_branch_name}-{int(time())}-{sha[:7]}{suffix}", # Use by FluxCD
]
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("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,7 +0,0 @@
#!/bin/bash -x
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
GITHUB_OUTPUT=/dev/stdout \
GITHUB_REF=ref \
GITHUB_SHA=sha \
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
python $SCRIPT_DIR/push_vars.py

View File

@ -2,39 +2,36 @@ name: "Setup authentik testing environment"
description: "Setup authentik testing environment"
inputs:
postgresql_version:
postgresql_tag:
description: "Optional postgresql image tag"
default: "16"
default: "12"
runs:
using: "composite"
steps:
- name: Install poetry & deps
- name: Install poetry
shell: bash
run: |
pipx install poetry || true
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
sudo apt update
sudo apt install -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
- name: Setup python and restore poetry
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version-file: "pyproject.toml"
python-version: "3.11"
cache: "poetry"
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version-file: web/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
- name: Setup dependencies
shell: bash
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/docker-compose.yml up -d
export PSQL_TAG=${{ inputs.postgresql_tag }}
docker-compose -f .github/actions/setup/docker-compose.yml up -d
poetry env use python3.11
poetry install
cd web && npm ci
- name: Generate config

View File

@ -2,7 +2,7 @@ version: "3.7"
services:
postgresql:
image: docker.io/library/postgres:${PSQL_TAG:-16}
image: docker.io/library/postgres:${PSQL_TAG:-12}
volumes:
- db-data:/var/lib/postgresql/data
environment:

4
.github/codecov.yml vendored
View File

@ -6,5 +6,5 @@ coverage:
# adjust accordingly based on how flaky your tests are
# this allows a 1% drop from the previous base commit coverage
threshold: 1%
comment:
after_n_builds: 3
notify:
after_n_builds: 3

View File

@ -2,5 +2,3 @@ keypair
keypairs
hass
warmup
ontext
singed

View File

@ -30,19 +30,17 @@ updates:
open-pull-requests-limit: 10
commit-message:
prefix: "web:"
# TODO: deduplicate these groups
groups:
sentry:
patterns:
- "@sentry/*"
- "@spotlightjs/*"
babel:
patterns:
- "@babel/*"
- "babel-*"
eslint:
patterns:
- "@typescript-eslint/*"
- "@typescript-eslint/eslint-*"
- "eslint"
- "eslint-*"
storybook:
@ -52,45 +50,6 @@ updates:
esbuild:
patterns:
- "@esbuild/*"
rollup:
patterns:
- "@rollup/*"
- "rollup-*"
- 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:
- "@sentry/*"
- "@spotlightjs/*"
babel:
patterns:
- "@babel/*"
- "babel-*"
eslint:
patterns:
- "@typescript-eslint/*"
- "eslint"
- "eslint-*"
storybook:
patterns:
- "@storybook/*"
- "*storybook*"
esbuild:
patterns:
- "@esbuild/*"
wdio:
patterns:
- "@wdio/*"
- package-ecosystem: npm
directory: "/website"
schedule:

View File

@ -27,6 +27,7 @@ If an API change has been made
If changes to the frontend have been made
- [ ] The code has been formatted (`make web`)
- [ ] The translation files have been updated (`make i18n-extract`)
If applicable

View File

@ -1,65 +0,0 @@
name: authentik-api-py-publish
on:
push:
branches: [main]
paths:
- "schema.yml"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Install poetry & deps
shell: bash
run: |
pipx install poetry || true
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
- name: Setup python and restore poetry
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
cache: "poetry"
- name: Generate API Client
run: make gen-client-py
- name: Publish package
working-directory: gen-py-api/
run: |
poetry build
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: gen-py-api/dist/
# We can't easily upgrade the API client being used due to poetry being poetry
# so we'll have to rely on dependabot
# - name: Upgrade /
# run: |
# export VERSION=$(cd gen-py-api && poetry version -s)
# poetry add "authentik_client=$VERSION" --allow-prereleases --lock
# - uses: peter-evans/create-pull-request@v6
# id: cpr
# with:
# token: ${{ steps.generate_token.outputs.token }}
# branch: update-root-api-client
# commit-message: "root: bump API Client version"
# title: "root: bump API Client version"
# body: "root: bump API Client version"
# delete-branch: true
# signoff: true
# # ID from https://api.github.com/users/authentik-automation[bot]
# author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
# - uses: peter-evans/enable-pull-request-automerge@v3
# with:
# token: ${{ steps.generate_token.outputs.token }}
# pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
# merge-method: squash

View File

@ -1,4 +1,3 @@
---
name: authentik-ci-main
on:
@ -7,10 +6,11 @@ on:
- main
- next
- version-*
paths-ignore:
- website
pull_request:
branches:
- main
- version-*
env:
POSTGRES_DB: authentik
@ -26,11 +26,14 @@ jobs:
- bandit
- black
- codespell
- isort
- pending-migrations
- pylint
- pyright
- ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run job
@ -38,40 +41,31 @@ jobs:
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run migrations
run: poetry run python -m lifecycle.migrate
test-migrations-from-stable:
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
psql:
- 12-alpine
- 15-alpine
- 16-alpine
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup authentik env
uses: ./.github/actions/setup
- name: checkout stable
run: |
# Delete all poetry envs
rm -rf /home/runner/.cache/pypoetry
# Copy current, latest config to local
cp authentik/lib/default.yml local.env.yml
cp -R .github ..
cp -R scripts ..
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
rm -rf .github/ scripts/
mv ../.github ../scripts .
- name: Setup authentik env (stable)
- name: Setup authentik env (ensure stable deps are installed)
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
- name: run migrations to stable
run: poetry run python -m lifecycle.migrate
- name: checkout current code
@ -81,21 +75,11 @@ jobs:
git reset --hard HEAD
git clean -d -fx .
git checkout $GITHUB_SHA
# Delete previous poetry env
rm -rf /home/runner/.cache/pypoetry/virtualenvs/*
poetry install
- name: Setup authentik env (ensure latest deps are installed)
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
- name: migrate to latest
run: |
poetry run python -m lifecycle.migrate
- name: run tests
env:
# Test in the main database that we just migrated from the previous stable version
AUTHENTIK_POSTGRESQL__TEST__NAME: authentik
run: |
poetry run make test
run: poetry run python -m lifecycle.migrate
test-unittest:
name: test-unittest - PostgreSQL ${{ matrix.psql }}
runs-on: ubuntu-latest
@ -106,40 +90,37 @@ jobs:
psql:
- 12-alpine
- 15-alpine
- 16-alpine
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
postgresql_tag: ${{ matrix.psql }}
- name: run unittest
run: |
poetry run make test
poetry run coverage xml
- if: ${{ always() }}
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
test-integration:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- 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.8.0
- name: run integration
run: |
poetry run coverage run manage.py test tests/integration
poetry run coverage xml
- if: ${{ always() }}
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
flags: integration
token: ${{ secrets.CODECOV_TOKEN }}
test-e2e:
name: test-e2e (${{ matrix.job.name }})
runs-on: ubuntu-latest
@ -160,19 +141,17 @@ jobs:
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
- name: radius
glob: tests/e2e/test_provider_radius*
- name: scim
glob: tests/e2e/test_source_scim*
- name: flows
glob: tests/e2e/test_flows*
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/docker-compose.yml up -d
docker-compose -f tests/e2e/docker-compose.yml up -d
- id: cache-web
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
@ -188,10 +167,9 @@ jobs:
poetry run coverage run manage.py test ${{ matrix.job.glob }}
poetry run coverage xml
- if: ${{ always() }}
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
ci-core-mark:
needs:
- lint
@ -204,78 +182,87 @@ jobs:
steps:
- run: echo mark
build:
strategy:
fail-fast: false
matrix:
arch:
- amd64
- arm64
needs: ci-core-mark
runs-on: ubuntu-latest
permissions:
# Needed to upload contianer images to ghcr.io
packages: write
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/dev-server
image-arch: ${{ matrix.arch }}
- name: Login to Container Registry
uses: docker/login-action@v2
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: generate ts client
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v4
with:
context: .
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
tags: ${{ steps.ev.outputs.imageTags }}
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
tags: |
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.sha }}
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/${{ matrix.arch }}
pr-comment:
needs:
- build
VERSION=${{ steps.ev.outputs.version }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
- name: Comment on PR
if: github.event_name == 'pull_request'
continue-on-error: true
uses: ./.github/actions/comment-pr-instructions
with:
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
build-arm64:
needs: ci-core-mark
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }}
permissions:
# Needed to write comments on PRs
pull-requests: write
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- name: Login to Container Registry
uses: docker/login-action@v2
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with:
image-name: ghcr.io/goauthentik/dev-server
- name: Comment on PR
uses: ./.github/actions/comment-pr-instructions
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v4
with:
tag: gh-${{ steps.ev.outputs.imageMainTag }}
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
tags: |
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-arm64
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.sha }}-arm64
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}-arm64
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
VERSION=${{ steps.ev.outputs.version }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
platforms: linux/arm64

View File

@ -1,4 +1,3 @@
---
name: authentik-ci-outpost
on:
@ -10,14 +9,13 @@ on:
pull_request:
branches:
- main
- version-*
jobs:
lint-golint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version-file: "go.mod"
- name: Prepare and generate API
@ -29,20 +27,18 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@v5
uses: golangci/golangci-lint-action@v3
with:
version: v1.54.2
version: v1.52.2
args: --timeout 5000s --verbose
skip-cache: true
skip-pkg-cache: true
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version-file: "go.mod"
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Generate API
run: make gen-client-go
- name: Go unittests
@ -66,29 +62,23 @@ jobs:
- proxy
- ldap
- radius
- rac
runs-on: ubuntu-latest
permissions:
# Needed to upload contianer images to ghcr.io
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
- name: Login to Container Registry
uses: docker/login-action@v2
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -96,17 +86,19 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: Build Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v4
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: ${{ matrix.type }}.Dockerfile
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
tags: |
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
file: ${{ matrix.type }}.Dockerfile
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
VERSION=${{ steps.ev.outputs.version }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
platforms: linux/amd64,linux/arm64
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
build-binary:
timeout-minutes: 120
needs:
@ -119,19 +111,18 @@ jobs:
- proxy
- ldap
- radius
- rac
goos: [linux]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@v5
- uses: actions/setup-go@v4
with:
go-version-file: "go.mod"
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: web/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Generate API

View File

@ -9,45 +9,31 @@ on:
pull_request:
branches:
- main
- version-*
jobs:
lint-eslint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project:
- web
- tests/wdio
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: ${{ matrix.project }}/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: Eslint
working-directory: ${{ matrix.project }}/
working-directory: web/
run: npm run lint
lint-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- working-directory: web/
run: |
[ -z "$(jq -r '.packages | to_entries[] | select((.key | startswith("node_modules")) and (.value | has("resolved") | not)) | .key' < package-lock.json)" ]
lint-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: web/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
@ -59,33 +45,27 @@ jobs:
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
- uses: actions/setup-node@v3
with:
node-version-file: ${{ matrix.project }}/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: prettier
working-directory: ${{ matrix.project }}/
working-directory: web/
run: npm run prettier-check
lint-lit-analyse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: web/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
@ -102,7 +82,6 @@ jobs:
run: npm run lit-analyse
ci-web-mark:
needs:
- lint-lockfile
- lint-eslint
- lint-prettier
- lint-lit-analyse
@ -116,9 +95,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: web/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/

View File

@ -9,23 +9,15 @@ on:
pull_request:
branches:
- main
- version-*
jobs:
lint-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- working-directory: website/
run: |
[ -z "$(jq -r '.packages | to_entries[] | select((.key | startswith("node_modules")) and (.value | has("resolved") | not)) | .key' < package-lock.json)" ]
lint-prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: website/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
@ -37,9 +29,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: website/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
@ -55,11 +47,12 @@ jobs:
matrix:
job:
- build
- build-docs-only
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: website/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
@ -69,7 +62,6 @@ jobs:
run: npm run ${{ matrix.job }}
ci-website-mark:
needs:
- lint-lockfile
- lint-prettier
- test
- build

View File

@ -23,14 +23,14 @@ jobs:
language: ["go", "javascript", "python"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v2

View File

@ -1,43 +0,0 @@
name: authentik-gen-update-webauthn-mds
on:
workflow_dispatch:
schedule:
- cron: '30 1 1,15 * *'
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- run: poetry run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@v6
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}
branch: update-fido-mds-client
commit-message: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
title: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
body: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
delete-branch: true
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash

View File

@ -6,16 +6,12 @@ on:
types:
- closed
permissions:
# Permission to delete cache
actions: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Cleanup
run: |

View File

@ -1,8 +1,8 @@
name: ghcr-retention
on:
# schedule:
# - cron: "0 0 * * *" # every day at midnight
schedule:
- cron: "0 0 * * *" # every day at midnight
workflow_dispatch:
jobs:
@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}

View File

@ -29,11 +29,11 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
@ -42,7 +42,7 @@ jobs:
with:
githubToken: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}
- uses: peter-evans/create-pull-request@v6
- uses: peter-evans/create-pull-request@v5
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
id: cpr
with:

View File

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- name: generate docs

View File

@ -6,7 +6,6 @@ on:
workflow_dispatch:
permissions:
# Needed to be able to push to the next branch
contents: write
jobs:
@ -14,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
environment: internal-production
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: main
- run: |

View File

@ -1,4 +1,3 @@
---
name: authentik-on-release
on:
@ -8,52 +7,46 @@ on:
jobs:
build-server:
runs-on: ubuntu-latest
permissions:
# Needed to upload contianer images to ghcr.io
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/server,beryju/authentik
- name: Docker Login Registry
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: make empty clients
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Build Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v4
with:
context: .
push: true
push: ${{ github.event_name == 'release' }}
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
tags: ${{ steps.ev.outputs.imageTags }}
tags: |
beryju/authentik:${{ steps.ev.outputs.version }},
beryju/authentik:${{ steps.ev.outputs.versionFamily }},
beryju/authentik:latest,
ghcr.io/goauthentik/server:${{ steps.ev.outputs.version }},
ghcr.io/goauthentik/server:${{ steps.ev.outputs.versionFamily }},
ghcr.io/goauthentik/server:latest
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.ev.outputs.version }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
build-outpost:
runs-on: ubuntu-latest
permissions:
# Needed to upload contianer images to ghcr.io
packages: write
strategy:
fail-fast: false
matrix:
@ -61,52 +54,48 @@ jobs:
- proxy
- ldap
- radius
- rac
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version-file: "go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }}
- name: make empty clients
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Docker Login Registry
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v4
with:
push: true
tags: ${{ steps.ev.outputs.imageTags }}
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.version }},
beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }},
beryju/authentik-${{ matrix.type }}:latest,
ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.version }},
ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }},
ghcr.io/goauthentik/${{ matrix.type }}:latest
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
context: .
build-args: |
VERSION=${{ steps.ev.outputs.version }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
build-outpost-binary:
timeout-minutes: 120
runs-on: ubuntu-latest
permissions:
# Needed to upload binaries to the release
contents: write
strategy:
fail-fast: false
matrix:
@ -117,13 +106,13 @@ jobs:
goos: [linux, darwin]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version-file: "go.mod"
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: web/package.json
node-version: "20.5"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Build web
@ -152,15 +141,15 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Run test suite in final docker images
run: |
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
docker compose run -u root server test-all
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
sentry-release:
needs:
- build-server
@ -168,22 +157,19 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/server
- name: Get static files from docker image
run: |
docker pull ${{ steps.ev.outputs.imageMainTag }}
container=$(docker container create ${{ steps.ev.outputs.imageMainTag }})
docker pull ghcr.io/goauthentik/server:latest
container=$(docker container create ghcr.io/goauthentik/server:latest)
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@v1
continue-on-error: true
if: ${{ github.event_name == 'release' }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: authentik-security-inc

View File

@ -1,4 +1,3 @@
---
name: authentik-on-tag
on:
@ -11,31 +10,30 @@ jobs:
name: Create Release from Tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Pre-release test
run: |
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 .
echo "AUTHENTIK_IMAGE=testing" >> .env
echo "AUTHENTIK_TAG=latest" >> .env
docker compose up --no-start
docker compose start postgresql redis
docker compose run -u root server test-all
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
- id: generate_token
uses: tibdex/github-app-token@v2
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- name: Extract version number
id: get_version
uses: actions/github-script@v6
with:
image-name: ghcr.io/goauthentik/server
github-token: ${{ steps.generate_token.outputs.token }}
script: |
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
- name: Create Release
id: create_release
uses: actions/create-release@v1.1.4
@ -43,6 +41,6 @@ jobs:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ steps.ev.outputs.version }}
release_name: Release ${{ steps.get_version.outputs.result }}
draft: true
prerelease: ${{ steps.ev.outputs.prerelease == 'true' }}
prerelease: false

View File

@ -6,24 +6,24 @@ on:
workflow_dispatch:
permissions:
# Needed to update issues and PRs
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/stale@v9
- uses: actions/stale@v8
with:
repo-token: ${{ steps.generate_token.outputs.token }}
days-before-stale: 60
days-before-close: 7
exempt-issue-labels: pinned,security,pr_wanted,enhancement,bug/confirmed,enhancement/confirmed,question,status/reviewing
exempt-issue-labels: pinned,security,pr_wanted,enhancement,bug/confirmed,enhancement/confirmed,question
stale-issue-label: wontfix
stale-issue-message: >
This issue has been automatically marked as stale because it has not had

View File

@ -7,26 +7,21 @@ on:
paths:
- "!**"
- "locale/**"
- "!locale/en/**"
- "web/xliff/**"
permissions:
# Permission to write comment
pull-requests: write
- "web/src/locales/**"
jobs:
post-comment:
runs-on: ubuntu-latest
steps:
- name: Find Comment
uses: peter-evans/find-comment@v3
uses: peter-evans/find-comment@v2
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: authentik translations instructions
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v4
uses: peter-evans/create-or-update-comment@v3
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}

View File

@ -1,8 +1,9 @@
---
name: authentik-backend-translate-extract-compile
name: authentik-backend-translate-compile
on:
schedule:
- cron: "0 0 * * *" # every day at midnight
push:
branches: [main]
paths:
- "locale/**"
workflow_dispatch:
env:
@ -15,29 +16,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run extract
run: |
poetry run make i18n-extract
- name: run compile
run: |
poetry run ak compilemessages
make web-check-compile
run: poetry run ak compilemessages
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@v5
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation
commit-message: "core, web: update translations"
title: "core, web: update translations"
body: "core, web: update translations"
branch: compile-backend-translation
commit-message: "core: compile backend translations"
title: "core: compile backend translations"
body: "core: compile backend translations"
delete-branch: true
signoff: true

View File

@ -6,17 +6,13 @@ on:
pull_request:
types: [opened, reopened]
permissions:
# Permission to rename PR
pull-requests: write
jobs:
rename_pr:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}}
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}

View File

@ -1,4 +1,4 @@
name: authentik-api-ts-publish
name: authentik-web-api-publish
on:
push:
branches: [main]
@ -10,16 +10,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: web/package.json
node-version: "20.5"
registry-url: "https://registry.npmjs.org"
- name: Generate API Client
run: make gen-client-ts
@ -35,7 +35,7 @@ jobs:
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@v6
- uses: peter-evans/create-pull-request@v5
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

3
.gitignore vendored
View File

@ -206,6 +206,3 @@ data/
.netlify
.ruff_cache
source_docs/
### Golang ###
/vendor/

View File

@ -10,10 +10,10 @@
"Gruntfuggly.todo-tree",
"mechatroner.rainbow-csv",
"ms-python.black-formatter",
"charliermarsh.ruff",
"ms-python.isort",
"ms-python.pylint",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.black-formatter",
"redhat.vscode-yaml",
"Tobermory.es6-string-html",
"unifiedjs.vscode-mdx",

View File

@ -19,8 +19,10 @@
"slo",
"scim",
],
"python.linting.pylintEnabled": true,
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"python.formatting.provider": "black",
"yaml.customTags": [
"!Find sequence",
"!KeyOf scalar",

View File

@ -1,28 +1,2 @@
# Fallback
* @goauthentik/backend @goauthentik/frontend
# Backend
authentik/ @goauthentik/backend
blueprints/ @goauthentik/backend
cmd/ @goauthentik/backend
internal/ @goauthentik/backend
lifecycle/ @goauthentik/backend
schemas/ @goauthentik/backend
scripts/ @goauthentik/backend
tests/ @goauthentik/backend
pyproject.toml @goauthentik/backend
poetry.lock @goauthentik/backend
go.mod @goauthentik/backend
go.sum @goauthentik/backend
# Infrastructure
.github/ @goauthentik/infrastructure
Dockerfile @goauthentik/infrastructure
*Dockerfile @goauthentik/infrastructure
.dockerignore @goauthentik/infrastructure
docker-compose.yml @goauthentik/infrastructure
# Web
web/ @goauthentik/frontend
tests/wdio/ @goauthentik/frontend
# Docs & Website
website/ @goauthentik/docs
# Security
website/docs/security/ @goauthentik/security
* @goauthentik/core
website/docs/security/** @goauthentik/security

View File

@ -1,79 +1,57 @@
# syntax=docker/dockerfile:1
# Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/node:21 as website-builder
ENV NODE_ENV=production
WORKDIR /work/website
RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \
--mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \
--mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \
npm ci --include=dev
FROM --platform=${BUILDPLATFORM} docker.io/node:20.5 as website-builder
COPY ./website /work/website/
COPY ./blueprints /work/blueprints/
COPY ./schema.yml /work/
COPY ./SECURITY.md /work/
RUN npm run build-bundled
ENV NODE_ENV=production
WORKDIR /work/website
RUN npm ci --include=dev && npm run build-docs-only
# Stage 2: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/node:21 as web-builder
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=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev
FROM --platform=${BUILDPLATFORM} docker.io/node:20.5 as web-builder
COPY ./web /work/web/
COPY ./website /work/website/
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build
ENV NODE_ENV=production
WORKDIR /work/web
RUN npm ci --include=dev && npm run build
# Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/golang:1.22.2-bookworm AS go-builder
# Stage 3: Poetry to requirements.txt export
FROM docker.io/python:3.11.5-slim-bookworm AS poetry-locker
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
WORKDIR /work
COPY ./pyproject.toml /work
COPY ./poetry.lock /work
ARG GOOS=$TARGETOS
ARG GOARCH=$TARGETARCH
RUN pip install --no-cache-dir poetry && \
poetry export -f requirements.txt --output requirements.txt && \
poetry export -f requirements.txt --dev --output requirements-dev.txt
WORKDIR /go/src/goauthentik.io
# Stage 4: Build go proxy
FROM docker.io/golang:1.21.0-bookworm AS go-builder
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 \
go mod download
WORKDIR /work
COPY ./cmd /go/src/goauthentik.io/cmd
COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib
COPY ./web/static.go /go/src/goauthentik.io/web/static.go
COPY --from=web-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt
COPY --from=web-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt
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
COPY --from=web-builder /work/web/robots.txt /work/web/robots.txt
COPY --from=web-builder /work/web/security.txt /work/web/security.txt
ENV CGO_ENABLED=0
COPY ./cmd /work/cmd
COPY ./authentik/lib /work/authentik/lib
COPY ./web/static.go /work/web/static.go
COPY ./internal /work/internal
COPY ./go.mod /work/go.mod
COPY ./go.sum /work/go.sum
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 \
GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
RUN go build -o /work/bin/authentik ./cmd/server/
# Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip
# Stage 5: MaxMind GeoIP
FROM ghcr.io/maxmind/geoipupdate:v6.0 as geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="1"
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
ENV GEOIPUPDATE_VERBOSE="true"
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
@ -83,34 +61,8 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
mkdir -p /usr/share/GeoIP && \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Python dependencies
FROM docker.io/python:3.12.3-slim-bookworm AS python-deps
WORKDIR /ak-root/poetry
ENV VENV_PATH="/ak-root/venv" \
POETRY_VIRTUALENVS_CREATE=false \
PATH="/ak-root/venv/bin:$PATH"
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
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 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 \
--mount=type=cache,target=/root/.cache/pip \
--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"
# Stage 6: Run
FROM docker.io/python:3.12.3-slim-bookworm AS final-image
FROM docker.io/python:3.11.5-slim-bookworm AS final-image
ARG GIT_BUILD_HASH
ARG VERSION
@ -124,45 +76,46 @@ LABEL org.opencontainers.image.revision ${GIT_BUILD_HASH}
WORKDIR /
# We cannot cache this layer otherwise we'll end up with a bigger image
COPY --from=poetry-locker /work/requirements.txt /
COPY --from=poetry-locker /work/requirements-dev.txt /
COPY --from=geoip /usr/share/GeoIP /geoip
RUN apt-get update && \
# Required for installing pip packages
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev libpq-dev python3-dev && \
# Required for runtime
apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 ca-certificates && \
apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \
pip install --no-cache-dir -r /requirements.txt && \
apt-get remove --purge -y build-essential pkg-config libxmlsec1-dev libpq-dev python3-dev && \
apt-get autoremove --purge -y && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
mkdir -p /certs /media /blueprints && \
mkdir -p /authentik/.ssh && \
mkdir -p /ak-root && \
chown authentik:authentik /certs /media /authentik/.ssh /ak-root
chown authentik:authentik /certs /media /authentik/.ssh
COPY ./authentik/ /authentik
COPY ./pyproject.toml /
COPY ./poetry.lock /
COPY ./schemas /schemas
COPY ./locale /locale
COPY ./tests /tests
COPY ./manage.py /
COPY ./blueprints /blueprints
COPY ./lifecycle/ /lifecycle
COPY --from=go-builder /go/authentik /bin/authentik
COPY --from=python-deps /ak-root/venv /ak-root/venv
COPY --from=go-builder /work/bin/authentik /bin/authentik
COPY --from=web-builder /work/web/dist/ /web/dist/
COPY --from=web-builder /work/web/authentik/ /web/authentik/
COPY --from=website-builder /work/website/build/ /website/help/
COPY --from=geoip /usr/share/GeoIP /geoip
COPY --from=website-builder /work/website/help/ /website/help/
USER 1000
ENV TMPDIR=/dev/shm/ \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/ak-root/venv/bin:/lifecycle:$PATH" \
VENV_PATH="/ak-root/venv" \
POETRY_VIRTUALENVS_CREATE=false
ENV TMPDIR /dev/shm/
ENV PYTHONUNBUFFERED 1
ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle"
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ]
ENTRYPOINT [ "dumb-init", "--", "ak" ]
ENTRYPOINT [ "/usr/local/bin/dumb-init", "--", "/lifecycle/ak" ]

206
Makefile
View File

@ -1,20 +1,9 @@
.PHONY: gen dev-reset all clean test web website
.SHELLFLAGS += ${SHELLFLAGS} -e
.SHELLFLAGS += -x -e
PWD = $(shell pwd)
UID = $(shell id -u)
GID = $(shell id -g)
NPM_VERSION = $(shell python -m scripts.npm_version)
PY_SOURCES = authentik tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test"
GEN_API_TS = "gen-ts-api"
GEN_API_PY = "gen-py-api"
GEN_API_GO = "gen-go-api"
pg_user := $(shell python -m authentik.lib.config postgresql.user 2>/dev/null)
pg_host := $(shell python -m authentik.lib.config postgresql.host 2>/dev/null)
pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null)
PY_SOURCES = authentik tests scripts lifecycle
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
-I .github/codespell-words.txt \
@ -30,190 +19,128 @@ CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
website/integrations \
website/src
all: lint-fix lint test gen web ## Lint, build, and test everything
HELP_WIDTH := $(shell grep -h '^[a-z][^ ]*:.*\#\#' $(MAKEFILE_LIST) 2>/dev/null | \
cut -d':' -f1 | awk '{printf "%d\n", length}' | sort -rn | head -1)
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"
@grep -Eh '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-$(HELP_WIDTH)s \033[m %s\n", $$1, $$2}' | \
sort
@echo ""
all: lint-fix lint test gen web
test-go:
go test -timeout 0 -v -race -cover ./...
test-docker: ## Run all tests in a docker-compose
test-docker:
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
docker compose run -u root server test-all
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test
rm -f .env
test: ## Run the server tests and produce a coverage report (locally)
test:
coverage run manage.py test --keepdb authentik
coverage html
coverage report
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
black $(PY_SOURCES)
ruff check --fix $(PY_SOURCES)
lint-fix:
isort authentik $(PY_SOURCES)
black authentik $(PY_SOURCES)
ruff authentik $(PY_SOURCES)
codespell -w $(CODESPELL_ARGS)
lint: ## Lint the python and golang sources
bandit -r $(PY_SOURCES) -x web/node_modules -x tests/wdio/node_modules -x website/node_modules
lint:
pylint $(PY_SOURCES)
bandit -r $(PY_SOURCES) -x node_modules
golangci-lint run -v
core-install:
poetry install
migrate: ## Run the Authentik Django server's migrations
migrate:
python -m lifecycle.migrate
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
i18n-extract: i18n-extract-core web-i18n-extract
core-i18n-extract:
ak makemessages \
--add-location file \
--no-obsolete \
--ignore web \
--ignore internal \
--ignore ${GEN_API_TS} \
--ignore ${GEN_API_GO} \
--ignore website \
-l en
install: web-install website-install core-install ## Install all requires dependencies for `web`, `website` and `core`
dev-drop-db:
dropdb -U ${pg_user} -h ${pg_host} ${pg_name}
# Also remove the test-db if it exists
dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true
redis-cli -n 0 flushall
dev-create-db:
createdb -U ${pg_user} -h ${pg_host} ${pg_name}
dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik PostgreSQL instance to a "fresh install" state.
i18n-extract-core:
ak makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
#########################
## API Schema
#########################
gen-build: ## Extract the schema from the database
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
ak make_blueprint_schema > blueprints/schema.json
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
ak spectacular --file schema.yml
gen-build:
AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json
AUTHENTIK_DEBUG=true ak spectacular --file schema.yml
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
gen-changelog:
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
npx prettier --write changelog.md
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
gen-diff:
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > old_schema.yml
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-diff:2.1.0-beta.8 \
docker.io/openapitools/openapi-diff:2.1.0-beta.6 \
--markdown /local/diff.md \
/local/old_schema.yml /local/schema.yml
rm old_schema.yml
sed -i 's/{/&#123;/g' diff.md
sed -i 's/}/&#125;/g' diff.md
npx prettier --write diff.md
gen-clean-ts: ## Remove generated API client for Typescript
rm -rf ./${GEN_API_TS}/
rm -rf ./web/node_modules/@goauthentik/api/
gen-clean:
rm -rf web/api/src/
rm -rf api/
gen-clean-go: ## Remove generated API client for Go
rm -rf ./${GEN_API_GO}/
gen-clean-py: ## Remove generated API client for Python
rm -rf ./${GEN_API_PY}/
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
gen-client-ts:
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
-i /local/schema.yml \
-g typescript-fetch \
-o /local/${GEN_API_TS} \
-o /local/gen-ts-api \
-c /local/scripts/api-ts-config.yaml \
--additional-properties=npmVersion=${NPM_VERSION} \
--git-repo-id authentik \
--git-user-id goauthentik
mkdir -p web/node_modules/@goauthentik/api
cd ./${GEN_API_TS} && npm i
\cp -rf ./${GEN_API_TS}/* web/node_modules/@goauthentik/api
cd gen-ts-api && npm i
\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
gen-client-go:
mkdir -p ./gen-go-api ./gen-go-api/templates
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./gen-go-api/config.yaml
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./gen-go-api/templates/README.mustache
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./gen-go-api/templates/go.mod.mustache
cp schema.yml ./gen-go-api/
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v7.4.0 generate \
-i /local/schema.yml \
-g python \
-o /local/${GEN_API_PY} \
-c /local/scripts/api-py-config.yaml \
--additional-properties=packageVersion=${NPM_VERSION} \
--git-repo-id authentik \
--git-user-id goauthentik
pip install ./${GEN_API_PY}
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
mkdir -p ./${GEN_API_GO} ./${GEN_API_GO}/templates
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./${GEN_API_GO}/config.yaml
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./${GEN_API_GO}/templates/README.mustache
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./${GEN_API_GO}/templates/go.mod.mustache
cp schema.yml ./${GEN_API_GO}/
docker run \
--rm -v ${PWD}/${GEN_API_GO}:/local \
--rm -v ${PWD}/gen-go-api:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
-i /local/schema.yml \
-g go \
-o /local/ \
-c /local/config.yaml
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/
go mod edit -replace goauthentik.io/api/v3=./gen-go-api
rm -rf ./gen-go-api/config.yaml ./gen-go-api/templates/
gen-dev-config: ## Generate a local development config file
gen-dev-config:
python -m scripts.generate_config
gen: gen-build gen-client-ts
gen: gen-build gen-clean gen-client-ts
#########################
## Web
#########################
web-build: web-install ## Build the Authentik UI
web-build: web-install
cd web && npm run build
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
web: web-lint-fix web-lint web-check-compile
web-install: ## Install the necessary libraries to build the Authentik UI
web-install:
cd web && npm ci
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
web-watch:
rm -rf web/dist/
mkdir web/dist/
touch web/dist/.gitkeep
cd web && npm run watch
web-storybook-watch: ## Build and run the storybook documentation server
web-storybook-watch:
cd web && npm run storybook
web-lint-fix:
@ -233,7 +160,7 @@ web-i18n-extract:
## Website
#########################
website: website-lint-fix website-build ## Automatically fix formatting issues in the Authentik website/docs source code, lint the code, and compile it
website: website-lint-fix website-build
website-install:
cd website && npm ci
@ -244,26 +171,18 @@ website-lint-fix:
website-build:
cd website && npm run build
website-watch: ## Build and watch the documentation website, updating automatically
website-watch:
cd website && npm run watch
#########################
## Docker
#########################
docker: ## Build a docker image of the current source tree
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
#########################
## CI
#########################
# These targets are use by GitHub actions to allow usage of matrix
# which makes the YAML File a lot smaller
ci--meta-debug:
python -V
node --version
ci-pylint: ci--meta-debug
pylint $(PY_SOURCES)
ci-black: ci--meta-debug
black --check $(PY_SOURCES)
@ -273,8 +192,25 @@ ci-ruff: ci--meta-debug
ci-codespell: ci--meta-debug
codespell $(CODESPELL_ARGS) -s
ci-isort: ci--meta-debug
isort --check $(PY_SOURCES)
ci-bandit: ci--meta-debug
bandit -r $(PY_SOURCES)
ci-pyright: ci--meta-debug
./web/node_modules/.bin/pyright $(PY_SOURCES)
ci-pending-migrations: ci--meta-debug
ak makemigrations --check
install: web-install website-install
poetry install
dev-reset:
dropdb -U postgres -h localhost authentik
# Also remove the test-db if it exists
dropdb -U postgres -h localhost test_authentik || true
createdb -U postgres -h localhost authentik
redis-cli -n 0 flushall
make migrate

View File

@ -25,10 +25,10 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h
## Screenshots
| Light | Dark |
| ----------------------------------------------------------- | ---------------------------------------------------------- |
| ![](https://docs.goauthentik.io/img/screen_apps_light.jpg) | ![](https://docs.goauthentik.io/img/screen_apps_dark.jpg) |
| ![](https://docs.goauthentik.io/img/screen_admin_light.jpg) | ![](https://docs.goauthentik.io/img/screen_admin_dark.jpg) |
| Light | Dark |
| ------------------------------------------------------ | ----------------------------------------------------- |
| ![](https://goauthentik.io/img/screen_apps_light.jpg) | ![](https://goauthentik.io/img/screen_apps_dark.jpg) |
| ![](https://goauthentik.io/img/screen_admin_light.jpg) | ![](https://goauthentik.io/img/screen_admin_dark.jpg) |
## Development
@ -41,3 +41,15 @@ See [SECURITY.md](SECURITY.md)
## Adoption and Contributions
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [CONTRIBUTING.md file](./CONTRIBUTING.md).
## Sponsors
This project is proudly sponsored by:
<p>
<a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=goauthentik.io">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">
</a>
</p>
DigitalOcean provides development and testing resources for authentik.

View File

@ -1,9 +1,5 @@
authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce it's existence and release the fixed version.
## Independent audits and pentests
In May/June of 2023 [Cure53](https://cure53.de) conducted an audit and pentest. The [results](https://cure53.de/pentest-report_authentik.pdf) are published on the [Cure53 website](https://cure53.de/#publications-2023). For more details about authentik's response to the findings of the audit refer to [2023-06 Cure53 Code audit](https://goauthentik.io/docs/security/2023-06-cure53).
## What authentik classifies as a CVE
CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is a either vulnerability or exposure. Per NIST, A vulnerability is:
@ -18,10 +14,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 |
| --------- | --------- |
| 2023.10.x | ✅ |
| 2024.2.x | ✅ |
| Version | Supported |
| --- | --- |
| 2023.6.x | ✅ |
| 2023.8.x | ✅ |
## Reporting a Vulnerability
@ -31,12 +27,12 @@ To report a vulnerability, send an email to [security@goauthentik.io](mailto:se
authentik reserves the right to reclassify CVSS as necessary. To determine severity, we will use the CVSS calculator from NVD (https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator). The calculated CVSS score will then be translated into one of the following categories:
| Score | Severity |
| ---------- | -------- |
| 0.0 | None |
| 0.1 3.9 | Low |
| 4.0 6.9 | Medium |
| 7.0 8.9 | High |
| Score | Severity |
| --- | --- |
| 0.0 | None |
| 0.1 3.9 | Low |
| 4.0 6.9 | Medium |
| 7.0 8.9 | High |
| 9.0 10.0 | Critical |
## Disclosure process

View File

@ -1,12 +1,12 @@
"""authentik root module"""
from os import environ
from typing import Optional
__version__ = "2024.4.0"
__version__ = "2023.8.4"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
def get_build_hash(fallback: str | None = None) -> str:
def get_build_hash(fallback: Optional[str] = None) -> str:
"""Get build hash"""
build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
return fallback if build_hash == "" and fallback else build_hash

View File

@ -1,8 +1,7 @@
"""Meta API"""
from drf_spectacular.utils import extend_schema
from rest_framework.fields import CharField
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
@ -22,7 +21,7 @@ class AppSerializer(PassiveSerializer):
class AppsViewSet(ViewSet):
"""Read-only view list all installed apps"""
permission_classes = [IsAuthenticated]
permission_classes = [IsAdminUser]
@extend_schema(responses={200: AppSerializer(many=True)})
def list(self, request: Request) -> Response:
@ -36,7 +35,7 @@ class AppsViewSet(ViewSet):
class ModelViewSet(ViewSet):
"""Read-only view list all installed models"""
permission_classes = [IsAuthenticated]
permission_classes = [IsAdminUser]
@extend_schema(responses={200: AppSerializer(many=True)})
def list(self, request: Request) -> Response:

View File

@ -1,12 +1,11 @@
"""authentik administration metrics"""
from datetime import timedelta
from django.db.models.functions import ExtractHour
from drf_spectacular.utils import extend_schema, extend_schema_field
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import IntegerField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@ -69,7 +68,7 @@ class LoginMetricsSerializer(PassiveSerializer):
class AdministrationMetricsViewSet(APIView):
"""Login Metrics per 1h"""
permission_classes = [IsAuthenticated]
permission_classes = [IsAdminUser]
@extend_schema(responses={200: LoginMetricsSerializer(many=False)})
def get(self, request: Request) -> Response:

View File

@ -1,5 +1,4 @@
"""authentik administration overview"""
import platform
from datetime import datetime
from sys import version as python_version
@ -9,16 +8,15 @@ 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.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_env
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
from authentik.rbac.permissions import HasPermission
class RuntimeDict(TypedDict):
@ -32,16 +30,15 @@ class RuntimeDict(TypedDict):
uname: str
class SystemInfoSerializer(PassiveSerializer):
class SystemSerializer(PassiveSerializer):
"""Get system information."""
http_headers = SerializerMethodField()
http_host = SerializerMethodField()
http_is_secure = SerializerMethodField()
runtime = SerializerMethodField()
brand = SerializerMethodField()
tenant = SerializerMethodField()
server_time = SerializerMethodField()
embedded_outpost_disabled = SerializerMethodField()
embedded_outpost_host = SerializerMethodField()
def get_http_headers(self, request: Request) -> dict[str, str]:
@ -72,18 +69,14 @@ class SystemInfoSerializer(PassiveSerializer):
"uname": " ".join(platform.uname()),
}
def get_brand(self, request: Request) -> str:
"""Currently active brand"""
return str(request._request.brand)
def get_tenant(self, request: Request) -> str:
"""Currently active tenant"""
return str(request._request.tenant)
def get_server_time(self, request: Request) -> datetime:
"""Current server time"""
return now()
def get_embedded_outpost_disabled(self, request: Request) -> bool:
"""Whether the embedded outpost is disabled"""
return CONFIG.get_bool("outposts.disable_embedded_outpost", False)
def get_embedded_outpost_host(self, request: Request) -> str:
"""Get the FQDN configured on the embedded outpost"""
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
@ -95,17 +88,17 @@ class SystemInfoSerializer(PassiveSerializer):
class SystemView(APIView):
"""Get system information."""
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
permission_classes = [IsAdminUser]
pagination_class = None
filter_backends = []
serializer_class = SystemInfoSerializer
serializer_class = SystemSerializer
@extend_schema(responses={200: SystemInfoSerializer(many=False)})
@extend_schema(responses={200: SystemSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Get system information."""
return Response(SystemInfoSerializer(request).data)
return Response(SystemSerializer(request).data)
@extend_schema(responses={200: SystemInfoSerializer(many=False)})
@extend_schema(responses={200: SystemSerializer(many=False)})
def post(self, request: Request) -> Response:
"""Get system information."""
return Response(SystemInfoSerializer(request).data)
return Response(SystemSerializer(request).data)

View File

@ -0,0 +1,132 @@
"""Tasks API"""
from importlib import import_module
from django.contrib import messages
from django.http.response import Http404
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.fields import (
CharField,
ChoiceField,
DateTimeField,
ListField,
SerializerMethodField,
)
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from structlog.stdlib import get_logger
from authentik.core.api.utils import PassiveSerializer
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
LOGGER = get_logger()
class TaskSerializer(PassiveSerializer):
"""Serialize TaskInfo and TaskResult"""
task_name = CharField()
task_description = CharField()
task_finish_timestamp = DateTimeField(source="finish_time")
task_duration = SerializerMethodField()
status = ChoiceField(
source="result.status.name",
choices=[(x.name, x.name) for x in TaskResultStatus],
)
messages = ListField(source="result.messages")
def get_task_duration(self, instance: TaskInfo) -> int:
"""Get the duration a task took to run"""
return max(instance.finish_timestamp - instance.start_timestamp, 0)
def to_representation(self, instance: TaskInfo):
"""When a new version of authentik adds fields to TaskInfo,
the API will fail with an AttributeError, as the classes
are pickled in cache. In that case, just delete the info"""
try:
return super().to_representation(instance)
# pylint: disable=broad-except
except Exception: # pragma: no cover
if isinstance(self.instance, list):
for inst in self.instance:
inst.delete()
else:
self.instance.delete()
return {}
class TaskViewSet(ViewSet):
"""Read-only view set that returns all background tasks"""
permission_classes = [IsAdminUser]
serializer_class = TaskSerializer
@extend_schema(
responses={
200: TaskSerializer(many=False),
404: OpenApiResponse(description="Task not found"),
},
parameters=[
OpenApiParameter(
"id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
required=True,
),
],
)
def retrieve(self, request: Request, pk=None) -> Response:
"""Get a single system task"""
task = TaskInfo.by_name(pk)
if not task:
raise Http404
return Response(TaskSerializer(task, many=False).data)
@extend_schema(responses={200: TaskSerializer(many=True)})
def list(self, request: Request) -> Response:
"""List system tasks"""
tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name)
return Response(TaskSerializer(tasks, many=True).data)
@extend_schema(
request=OpenApiTypes.NONE,
responses={
204: OpenApiResponse(description="Task retried successfully"),
404: OpenApiResponse(description="Task not found"),
500: OpenApiResponse(description="Failed to retry task"),
},
parameters=[
OpenApiParameter(
"id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
required=True,
),
],
)
@action(detail=True, methods=["post"])
def retry(self, request: Request, pk=None) -> Response:
"""Retry task"""
task = TaskInfo.by_name(pk)
if not task:
raise Http404
try:
task_module = import_module(task.task_call_module)
task_func = getattr(task_module, task.task_call_func)
LOGGER.debug("Running task", task=task_func)
task_func.delay(*task.task_call_args, **task.task_call_kwargs)
messages.success(
self.request,
_("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}),
)
return Response(status=204)
except (ImportError, AttributeError): # pragma: no cover
LOGGER.warning("Failed to run task, remove state", task=task)
# if we get an import error, the module path has probably changed
task.delete()
return Response(status=500)

View File

@ -1,5 +1,4 @@
"""authentik administration overview"""
from django.core.cache import cache
from drf_spectacular.utils import extend_schema
from packaging.version import parse
@ -10,7 +9,7 @@ from rest_framework.response import Response
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.admin.tasks import VERSION_CACHE_KEY, update_latest_version
from authentik.core.api.utils import PassiveSerializer
@ -19,7 +18,6 @@ class VersionSerializer(PassiveSerializer):
version_current = SerializerMethodField()
version_latest = SerializerMethodField()
version_latest_valid = SerializerMethodField()
build_hash = SerializerMethodField()
outdated = SerializerMethodField()
@ -39,10 +37,6 @@ class VersionSerializer(PassiveSerializer):
return __version__
return version_in_cache
def get_version_latest_valid(self, _) -> bool:
"""Check if latest version is valid"""
return cache.get(VERSION_CACHE_KEY) != VERSION_NULL
def get_outdated(self, instance) -> bool:
"""Check if we're running the latest version"""
return parse(self.get_version_current(instance)) < parse(self.get_version_latest(instance))

View File

@ -1,20 +1,19 @@
"""authentik administration overview"""
from django.conf import settings
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.fields import IntegerField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.rbac.permissions import HasPermission
from authentik.root.celery import CELERY_APP
class WorkerView(APIView):
"""Get currently connected worker count."""
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
permission_classes = [IsAdminUser]
@extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()}))
def get(self, request: Request) -> Response:

View File

@ -1,5 +1,4 @@
"""authentik admin app config"""
from prometheus_client import Gauge, Info
from authentik.blueprints.apps import ManagedAppConfig
@ -15,3 +14,7 @@ class AuthentikAdminConfig(ManagedAppConfig):
label = "authentik_admin"
verbose_name = "authentik Admin"
default = True
def reconcile_load_admin_signals(self):
"""Load admin signals"""
self.import_module("authentik.admin.signals")

View File

@ -1,5 +1,4 @@
"""authentik admin settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand

View File

@ -1,7 +1,7 @@
"""admin signals"""
from django.dispatch import receiver
from authentik.admin.api.tasks import TaskInfo
from authentik.admin.apps import GAUGE_WORKERS
from authentik.root.celery import CELERY_APP
from authentik.root.monitoring import monitoring_set
@ -12,3 +12,10 @@ def monitoring_set_workers(sender, **kwargs):
"""Set worker gauge"""
count = len(CELERY_APP.control.ping(timeout=0.5))
GAUGE_WORKERS.set(count)
@receiver(monitoring_set)
def monitoring_set_tasks(sender, **kwargs):
"""Set task gauges"""
for task in TaskInfo.all().values():
task.update_metrics()

View File

@ -1,5 +1,4 @@
"""authentik admin tasks"""
import re
from django.core.cache import cache
@ -12,13 +11,17 @@ from structlog.stdlib import get_logger
from authentik import __version__, get_build_hash
from authentik.admin.apps import PROM_INFO
from authentik.events.models import Event, EventAction, Notification
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_http_session
from authentik.root.celery import CELERY_APP
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
@ -51,13 +54,13 @@ def clear_update_notifications():
notification.delete()
@CELERY_APP.task(bind=True, base=SystemTask)
@CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task
def update_latest_version(self: SystemTask):
def update_latest_version(self: MonitoredTask):
"""Update latest version info"""
if CONFIG.get_bool("disable_update_check"):
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
self.set_status(TaskStatus.WARNING, "Version check disabled.")
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
self.set_status(TaskResult(TaskResultStatus.WARNING, messages=["Version check disabled."]))
return
try:
response = get_http_session().get(
@ -67,7 +70,9 @@ def update_latest_version(self: SystemTask):
data = response.json()
upstream_version = data.get("stable", {}).get("version")
cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT)
self.set_status(TaskStatus.SUCCESSFUL, "Successfully updated latest Version")
self.set_status(
TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"])
)
_set_prom_info()
# Check if upstream version is newer than what we're running,
# and if no event exists yet, create one.
@ -83,8 +88,8 @@ def update_latest_version(self: SystemTask):
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)
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
_set_prom_info()

View File

@ -1,5 +1,4 @@
"""test admin api"""
from json import loads
from django.test import TestCase
@ -8,6 +7,8 @@ from django.urls import reverse
from authentik import __version__
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Group, User
from authentik.core.tasks import clean_expired_models
from authentik.events.monitored_tasks import TaskResultStatus
from authentik.lib.generators import generate_id
@ -22,6 +23,53 @@ class TestAdminAPI(TestCase):
self.group.save()
self.client.force_login(self.user)
def test_tasks(self):
"""Test Task API"""
clean_expired_models.delay()
response = self.client.get(reverse("authentik_api:admin_system_tasks-list"))
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertTrue(any(task["task_name"] == "clean_expired_models" for task in body))
def test_tasks_single(self):
"""Test Task API (read single)"""
clean_expired_models.delay()
response = self.client.get(
reverse(
"authentik_api:admin_system_tasks-detail",
kwargs={"pk": "clean_expired_models"},
)
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(body["status"], TaskResultStatus.SUCCESSFUL.name)
self.assertEqual(body["task_name"], "clean_expired_models")
response = self.client.get(
reverse("authentik_api:admin_system_tasks-detail", kwargs={"pk": "qwerqwer"})
)
self.assertEqual(response.status_code, 404)
def test_tasks_retry(self):
"""Test Task API (retry)"""
clean_expired_models.delay()
response = self.client.post(
reverse(
"authentik_api:admin_system_tasks-retry",
kwargs={"pk": "clean_expired_models"},
)
)
self.assertEqual(response.status_code, 204)
def test_tasks_retry_404(self):
"""Test Task API (retry, 404)"""
response = self.client.post(
reverse(
"authentik_api:admin_system_tasks-retry",
kwargs={"pk": "qwerqewrqrqewrqewr"},
)
)
self.assertEqual(response.status_code, 404)
def test_version(self):
"""Test Version API"""
response = self.client.get(reverse("authentik_api:admin_version"))

View File

@ -1,5 +1,4 @@
"""test admin tasks"""
from django.core.cache import cache
from django.test import TestCase
from requests_mock import Mocker

View File

@ -1,14 +1,15 @@
"""API URLs"""
from django.urls import path
from authentik.admin.api.meta import AppsViewSet, ModelViewSet
from authentik.admin.api.metrics import AdministrationMetricsViewSet
from authentik.admin.api.system import SystemView
from authentik.admin.api.tasks import TaskViewSet
from authentik.admin.api.version import VersionView
from authentik.admin.api.workers import WorkerView
api_urlpatterns = [
("admin/system_tasks", TaskViewSet, "admin_system_tasks"),
("admin/apps", AppsViewSet, "apps"),
("admin/models", ModelViewSet, "models"),
path(

View File

@ -10,3 +10,26 @@ class AuthentikAPIConfig(AppConfig):
label = "authentik_api"
mountpoint = "api/"
verbose_name = "authentik API"
def ready(self) -> None:
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from authentik.api.authentication import TokenAuthentication
# Class is defined here as it needs to be created early enough that drf-spectacular will
# find it, but also won't cause any import issues
# pylint: disable=unused-variable
class TokenSchema(OpenApiAuthenticationExtension):
"""Auth schema"""
target_class = TokenAuthentication
name = "authentik"
def get_security_definition(self, auto_schema):
"""Auth schema"""
return {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"scheme": "bearer",
}

View File

@ -1,10 +1,8 @@
"""API Authentication"""
from hmac import compare_digest
from typing import Any
from typing import Any, Optional
from django.conf import settings
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
@ -18,7 +16,7 @@ from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
LOGGER = get_logger()
def validate_auth(header: bytes) -> str | None:
def validate_auth(header: bytes) -> Optional[str]:
"""Validate that the header is in a correct format,
returns type and credentials"""
auth_credentials = header.decode().strip()
@ -33,7 +31,7 @@ def validate_auth(header: bytes) -> str | None:
return auth_credentials
def bearer_auth(raw_header: bytes) -> User | None:
def bearer_auth(raw_header: bytes) -> Optional[User]:
"""raw_header in the Format of `Bearer ....`"""
user = auth_user_lookup(raw_header)
if not user:
@ -43,7 +41,7 @@ def bearer_auth(raw_header: bytes) -> User | None:
return user
def auth_user_lookup(raw_header: bytes) -> User | None:
def auth_user_lookup(raw_header: bytes) -> Optional[User]:
"""raw_header in the Format of `Bearer ....`"""
from authentik.providers.oauth2.models import AccessToken
@ -76,7 +74,7 @@ def auth_user_lookup(raw_header: bytes) -> User | None:
raise AuthenticationFailed("Token invalid/expired")
def token_secret_key(value: str) -> User | None:
def token_secret_key(value: str) -> Optional[User]:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST
@ -103,14 +101,3 @@ class TokenAuthentication(BaseAuthentication):
return None
return (user, None) # pragma: no cover
class TokenSchema(OpenApiAuthenticationExtension):
"""Auth schema"""
target_class = TokenAuthentication
name = "authentik"
def get_security_definition(self, auto_schema):
"""Auth schema"""
return {"type": "http", "scheme": "bearer"}

View File

@ -1,5 +1,4 @@
"""API Authorization"""
from django.conf import settings
from django.db.models import Model
from django.db.models.query import QuerySet
@ -8,9 +7,9 @@ from rest_framework.authentication import get_authorization_header
from rest_framework.filters import BaseFilterBackend
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.api.authentication import validate_auth
from authentik.rbac.filters import ObjectFilter
class OwnerFilter(BaseFilterBackend):
@ -27,14 +26,14 @@ class OwnerFilter(BaseFilterBackend):
class SecretKeyFilter(DjangoFilterBackend):
"""Allow access to all objects when authenticated with secret key as token.
Replaces both DjangoFilterBackend and ObjectFilter"""
Replaces both DjangoFilterBackend and ObjectPermissionsFilter"""
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
auth_header = get_authorization_header(request)
token = validate_auth(auth_header)
if token and token == settings.SECRET_KEY:
return queryset
queryset = ObjectFilter().filter_queryset(request, queryset, view)
queryset = ObjectPermissionsFilter().filter_queryset(request, queryset, view)
return super().filter_queryset(request, queryset, view)

View File

@ -0,0 +1,35 @@
"""API Decorators"""
from functools import wraps
from typing import Callable, Optional
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
LOGGER = get_logger()
def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None):
"""Check permissions for a single custom action"""
def wrapper_outter(func: Callable):
"""Check permissions for a single custom action"""
@wraps(func)
def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response:
if perm:
obj = self.get_object()
if not request.user.has_perm(perm, obj):
LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj)
return self.permission_denied(request)
if other_perms:
for other_perm in other_perms:
if not request.user.has_perm(other_perm):
LOGGER.debug("denying access for other", user=request.user, perm=perm)
return self.permission_denied(request)
return func(self, request, *args, **kwargs)
return wrapper
return wrapper_outter

View File

@ -1,5 +1,4 @@
"""Pagination which includes total pages and current page"""
from rest_framework import pagination
from rest_framework.response import Response
@ -78,10 +77,3 @@ class Pagination(pagination.PageNumberPagination):
},
"required": ["pagination", "results"],
}
class SmallerPagination(Pagination):
"""Smaller pagination for objects which might require a lot of queries
to retrieve all data for."""
max_page_size = 10

View File

@ -1,5 +1,4 @@
"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
from django.utils.translation import gettext_lazy as _
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.plumbing import (
@ -12,7 +11,6 @@ from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from rest_framework.settings import api_settings
from authentik.api.apps import AuthentikAPIConfig
from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA
@ -102,12 +100,3 @@ def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs):
comp = result["components"]["schemas"][component]
comp["additionalProperties"] = {}
return result
def preprocess_schema_exclude_non_api(endpoints, **kwargs):
"""Filter out all API Views which are not mounted under /api"""
return [
(path, path_regex, method, callback)
for path, path_regex, method, callback in endpoints
if path.startswith("/" + AuthentikAPIConfig.mountpoint)
]

View File

@ -3,7 +3,7 @@
{% load static %}
{% block title %}
API Browser - {{ brand.branding_title }}
API Browser - {{ tenant.branding_title }}
{% endblock %}
{% block head %}

View File

@ -1,5 +1,4 @@
"""Test API Authentication"""
import json
from base64 import b64encode
@ -13,8 +12,6 @@ from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Token, TokenIntents, User, UserTypes
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
@ -25,17 +22,17 @@ class TestAPIAuth(TestCase):
def test_invalid_type(self):
"""Test invalid type"""
with self.assertRaises(AuthenticationFailed):
bearer_auth(b"foo bar")
bearer_auth("foo bar".encode())
def test_invalid_empty(self):
"""Test invalid type"""
self.assertIsNone(bearer_auth(b"Bearer "))
self.assertIsNone(bearer_auth(b""))
self.assertIsNone(bearer_auth("Bearer ".encode()))
self.assertIsNone(bearer_auth("".encode()))
def test_invalid_no_token(self):
"""Test invalid with no token"""
with self.assertRaises(AuthenticationFailed):
auth = b64encode(b":abc").decode()
auth = b64encode(":abc".encode()).decode()
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
def test_bearer_valid(self):
@ -52,12 +49,8 @@ class TestAPIAuth(TestCase):
with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {token.key}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_fail(self):
def test_managed_outpost(self):
"""Test managed outpost"""
outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first()
outpost.user.delete()
outpost.delete()
with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())

View File

@ -1,5 +1,4 @@
"""Test config API"""
from json import loads
from django.urls import reverse

View File

@ -0,0 +1,34 @@
"""test decorators api"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Application, User
from authentik.lib.generators import generate_id
class TestAPIDecorators(APITestCase):
"""test decorators api"""
def setUp(self) -> None:
super().setUp()
self.user = User.objects.create(username="test-user")
def test_obj_perm_denied(self):
"""Test object perm denied"""
self.client.force_login(self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id())
response = self.client.get(
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 403)
def test_other_perm_denied(self):
"""Test other perm denied"""
self.client.force_login(self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id())
assign_perm("authentik_core.view_application", self.user, app)
response = self.client.get(
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 403)

View File

@ -1,5 +1,4 @@
"""Schema generation tests"""
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import safe_load

View File

@ -1,6 +1,5 @@
"""authentik API Modelviewset tests"""
from collections.abc import Callable
from typing import Callable
from django.test import TestCase
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
@ -17,7 +16,6 @@ def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
def tester(self: TestModelViewSets):
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
filterset_class = getattr(test_viewset, "filterset_class", None)
if not filterset_class:
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
@ -26,6 +24,6 @@ def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
for _, viewset, _ in router.registry:
if not issubclass(viewset, ModelViewSet | ReadOnlyModelViewSet):
if not issubclass(viewset, (ModelViewSet, ReadOnlyModelViewSet)):
continue
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))

View File

@ -1,5 +1,4 @@
"""authentik api urls"""
from django.urls import include, path
from authentik.api.v3.urls import urlpatterns as v3_urls

View File

@ -1,5 +1,4 @@
"""core Configs API"""
from pathlib import Path
from django.conf import settings
@ -20,7 +19,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer
from authentik.events.context_processors.base import get_context_processors
from authentik.events.geo import GEOIP_READER
from authentik.lib.config import CONFIG
capabilities = Signal()
@ -31,7 +30,6 @@ class Capabilities(models.TextChoices):
CAN_SAVE_MEDIA = "can_save_media"
CAN_GEO_IP = "can_geo_ip"
CAN_ASN = "can_asn"
CAN_IMPERSONATE = "can_impersonate"
CAN_DEBUG = "can_debug"
IS_ENTERPRISE = "is_enterprise"
@ -68,16 +66,11 @@ class ConfigView(APIView):
"""Get all capabilities this server instance supports"""
caps = []
deb_test = settings.DEBUG or settings.TEST
if (
CONFIG.get("storage.media.backend", "file") == "s3"
or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount()
or deb_test
):
if Path(settings.MEDIA_ROOT).is_mount() or deb_test:
caps.append(Capabilities.CAN_SAVE_MEDIA)
for processor in get_context_processors():
if cap := processor.capability():
caps.append(cap)
if self.request.tenant.impersonation:
if GEOIP_READER.enabled:
caps.append(Capabilities.CAN_GEO_IP)
if CONFIG.get_bool("impersonation"):
caps.append(Capabilities.CAN_IMPERSONATE)
if settings.DEBUG: # pragma: no cover
caps.append(Capabilities.CAN_DEBUG)
@ -100,10 +93,10 @@ class ConfigView(APIView):
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
},
"capabilities": self.get_capabilities(),
"cache_timeout": CONFIG.get_int("cache.timeout"),
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
"cache_timeout_reputation": CONFIG.get_int("cache.timeout_reputation"),
"cache_timeout": CONFIG.get_int("redis.cache_timeout"),
"cache_timeout_flows": CONFIG.get_int("redis.cache_timeout_flows"),
"cache_timeout_policies": CONFIG.get_int("redis.cache_timeout_policies"),
"cache_timeout_reputation": CONFIG.get_int("redis.cache_timeout_reputation"),
}
)

View File

@ -1,5 +1,4 @@
"""api v3 urls"""
from importlib import import_module
from django.urls import path
@ -22,9 +21,7 @@ _other_urls = []
for _authentik_app in get_apps():
try:
api_urls = import_module(f"{_authentik_app.name}.urls")
except ModuleNotFoundError:
continue
except ImportError as exc:
except (ModuleNotFoundError, ImportError) as exc:
LOGGER.warning("Could not import app's URLs", app_name=_authentik_app.name, exc=exc)
continue
if not hasattr(api_urls, "api_urlpatterns"):
@ -33,7 +30,7 @@ for _authentik_app in get_apps():
app_name=_authentik_app.name,
)
continue
urls: list = api_urls.api_urlpatterns
urls: list = getattr(api_urls, "api_urlpatterns")
for url in urls:
if isinstance(url, URLPattern):
_other_urls.append(url)

View File

@ -1,5 +1,4 @@
"""General API Views"""
from typing import Any
from django.urls import reverse

View File

@ -1,22 +1,22 @@
"""Serializer mixin for managed models"""
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField
from rest_framework.fields import CharField, DateTimeField, JSONField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.rbac.decorators import permission_required
from authentik.core.api.utils import PassiveSerializer
class ManagedSerializer:
@ -29,7 +29,7 @@ class MetadataSerializer(PassiveSerializer):
"""Serializer for blueprint metadata"""
name = CharField()
labels = JSONDictField()
labels = JSONField()
class BlueprintInstanceSerializer(ModelSerializer):
@ -49,12 +49,10 @@ class BlueprintInstanceSerializer(ModelSerializer):
if content == "":
return content
context = self.instance.context if self.instance else {}
valid, logs = Importer.from_string(content, context).validate()
valid, logs = Importer(content, context).validate()
if not valid:
text_logs = "\n".join([x["event"] for x in logs])
raise ValidationError(
_("Failed to validate blueprint: {logs}".format_map({"logs": text_logs}))
)
raise ValidationError(_("Failed to validate blueprint: %(logs)s" % {"logs": text_logs}))
return content
def validate(self, attrs: dict) -> dict:
@ -89,11 +87,11 @@ class BlueprintInstanceSerializer(ModelSerializer):
class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
"""Blueprint instances"""
permission_classes = [IsAdminUser]
serializer_class = BlueprintInstanceSerializer
queryset = BlueprintInstance.objects.all()
search_fields = ["name", "path"]
filterset_fields = ["name", "path"]
ordering = ["name"]
@extend_schema(
responses={

View File

@ -1,6 +1,5 @@
"""authentik Blueprints app"""
from collections.abc import Callable
from importlib import import_module
from inspect import ismethod
@ -8,100 +7,40 @@ from django.apps import AppConfig
from django.db import DatabaseError, InternalError, ProgrammingError
from structlog.stdlib import BoundLogger, get_logger
from authentik.root.signals import startup
class ManagedAppConfig(AppConfig):
"""Basic reconciliation logic for apps"""
logger: BoundLogger
RECONCILE_GLOBAL_CATEGORY: str = "global"
RECONCILE_TENANT_CATEGORY: str = "tenant"
_logger: BoundLogger
def __init__(self, app_name: str, *args, **kwargs) -> None:
super().__init__(app_name, *args, **kwargs)
self.logger = get_logger().bind(app_name=app_name)
self._logger = get_logger().bind(app_name=app_name)
def ready(self) -> None:
self.import_related()
startup.connect(self._on_startup_callback, dispatch_uid=self.label)
self.reconcile()
return super().ready()
def _on_startup_callback(self, sender, **_):
self._reconcile_global()
self._reconcile_tenant()
def import_related(self):
"""Automatically import related modules which rely on just being imported
to register themselves (mainly django signals and celery tasks)"""
def import_relative(rel_module: str):
try:
module_name = f"{self.name}.{rel_module}"
import_module(module_name)
self.logger.info("Imported related module", module=module_name)
except ModuleNotFoundError:
pass
import_relative("checks")
import_relative("tasks")
import_relative("signals")
def import_module(self, path: str):
"""Load module"""
import_module(path)
def _reconcile(self, prefix: str) -> None:
def reconcile(self) -> None:
"""reconcile ourselves"""
prefix = "reconcile_"
for meth_name in dir(self):
meth = getattr(self, meth_name)
if not ismethod(meth):
continue
category = getattr(meth, "_authentik_managed_reconcile", None)
if category != prefix:
if not meth_name.startswith(prefix):
continue
name = meth_name.replace(prefix, "")
try:
self.logger.debug("Starting reconciler", name=name)
self._logger.debug("Starting reconciler", name=name)
meth()
self.logger.debug("Successfully reconciled", name=name)
self._logger.debug("Successfully reconciled", name=name)
except (DatabaseError, ProgrammingError, InternalError) as exc:
self.logger.warning("Failed to run reconcile", name=name, exc=exc)
@staticmethod
def reconcile_tenant(func: Callable):
"""Mark a function to be called on startup (for each tenant)"""
func._authentik_managed_reconcile = ManagedAppConfig.RECONCILE_TENANT_CATEGORY
return func
@staticmethod
def reconcile_global(func: Callable):
"""Mark a function to be called on startup (globally)"""
func._authentik_managed_reconcile = ManagedAppConfig.RECONCILE_GLOBAL_CATEGORY
return func
def _reconcile_tenant(self) -> None:
"""reconcile ourselves for tenanted methods"""
from authentik.tenants.models import Tenant
try:
tenants = list(Tenant.objects.filter(ready=True))
except (DatabaseError, ProgrammingError, InternalError) as exc:
self.logger.debug("Failed to get tenants to run reconcile", exc=exc)
return
for tenant in tenants:
with tenant:
self._reconcile(self.RECONCILE_TENANT_CATEGORY)
def _reconcile_global(self) -> None:
"""
reconcile ourselves for global methods.
Used for signals, tasks, etc. Database queries should not be made in here.
"""
from django_tenants.utils import get_public_schema_name, schema_context
with schema_context(get_public_schema_name()):
self._reconcile(self.RECONCILE_GLOBAL_CATEGORY)
self._logger.debug("Failed to run reconcile", name=name, exc=exc)
class AuthentikBlueprintsConfig(ManagedAppConfig):
@ -112,13 +51,11 @@ class AuthentikBlueprintsConfig(ManagedAppConfig):
verbose_name = "authentik Blueprints"
default = True
@ManagedAppConfig.reconcile_global
def load_blueprints_v1_tasks(self):
def reconcile_load_blueprints_v1_tasks(self):
"""Load v1 tasks"""
self.import_module("authentik.blueprints.v1.tasks")
@ManagedAppConfig.reconcile_tenant
def blueprints_discovery(self):
def reconcile_blueprints_discovery(self):
"""Run blueprint discovery"""
from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints

View File

@ -1,5 +1,4 @@
"""Apply blueprint from commandline"""
from sys import exit as sys_exit
from django.core.management.base import BaseCommand, no_translations
@ -7,7 +6,6 @@ from structlog.stdlib import get_logger
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
from authentik.tenants.models import Tenant
LOGGER = get_logger()
@ -18,16 +16,14 @@ class Command(BaseCommand):
@no_translations
def handle(self, *args, **options):
"""Apply all blueprints in order, abort when one fails to import"""
for tenant in Tenant.objects.filter(ready=True):
with tenant:
for blueprint_path in options.get("blueprints", []):
content = BlueprintInstance(path=blueprint_path).retrieve()
importer = Importer.from_string(content)
valid, _ = importer.validate()
if not valid:
self.stderr.write("blueprint invalid")
sys_exit(1)
importer.apply()
for blueprint_path in options.get("blueprints", []):
content = BlueprintInstance(path=blueprint_path).retrieve()
importer = Importer(content)
valid, _ = importer.validate()
if not valid:
self.stderr.write("blueprint invalid")
sys_exit(1)
importer.apply()
def add_arguments(self, parser):
parser.add_argument("blueprints", nargs="+", type=str)

View File

@ -1,19 +1,17 @@
"""Export blueprint of current authentik install"""
from django.core.management.base import no_translations
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from authentik.blueprints.v1.exporter import Exporter
from authentik.tenants.management import TenantCommand
LOGGER = get_logger()
class Command(TenantCommand):
class Command(BaseCommand):
"""Export blueprint of current authentik install"""
@no_translations
def handle_per_tenant(self, *args, **options):
def handle(self, *args, **options):
"""Export blueprint of current authentik install"""
exporter = Exporter()
self.stdout.write(exporter.export_to_string())

View File

@ -1,18 +1,14 @@
"""Generate JSON Schema for blueprints"""
from json import dumps
from typing import Any
from django.core.management.base import BaseCommand, no_translations
from django.db.models import Model, fields
from drf_jsonschema_serializer.convert import converter, field_to_converter
from django.db.models import Model
from drf_jsonschema_serializer.convert import field_to_converter
from rest_framework.fields import Field, JSONField, UUIDField
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik import __version__
from authentik.blueprints.v1.common import BlueprintEntryDesiredState
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, is_model_allowed
from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry
from authentik.lib.models import SerializerModel
@ -20,23 +16,6 @@ from authentik.lib.models import SerializerModel
LOGGER = get_logger()
@converter
class PrimaryKeyRelatedFieldConverter:
"""Custom primary key field converter which is aware of non-integer based PKs
This is not an exhaustive fix for other non-int PKs, however in authentik we either
use UUIDs or ints"""
field_class = PrimaryKeyRelatedField
def convert(self, field: PrimaryKeyRelatedField):
model: Model = field.queryset.model
pk_field = model._meta.pk
if isinstance(pk_field, fields.UUIDField):
return {"type": "string", "format": "uuid"}
return {"type": "integer"}
class Command(BaseCommand):
"""Generate JSON Schema for blueprints"""
@ -48,7 +27,7 @@ class Command(BaseCommand):
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": f"authentik {__version__} Blueprint schema",
"title": "authentik Blueprint schema",
"required": ["version", "entries"],
"properties": {
"version": {
@ -131,7 +110,7 @@ class Command(BaseCommand):
"id": {"type": "string"},
"state": {
"type": "string",
"enum": [s.value for s in BlueprintEntryDesiredState],
"enum": ["absent", "present", "created"],
"default": "present",
},
"conditions": {"type": "array", "items": {"type": "boolean"}},

View File

@ -14,7 +14,7 @@ from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM
from authentik.lib.config import CONFIG
def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
def check_blueprint_v1_file(BlueprintInstance: type, path: Path):
"""Check if blueprint should be imported"""
from authentik.blueprints.models import BlueprintInstanceStatus
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
@ -29,9 +29,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if version != 1:
return
blueprint_file.seek(0)
instance: BlueprintInstance = (
BlueprintInstance.objects.using(db_alias).filter(path=path).first()
)
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first()
rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir")))
meta = None
if metadata:
@ -39,7 +37,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false":
return
if not instance:
BlueprintInstance.objects.using(db_alias).create(
instance = BlueprintInstance(
name=meta.name if meta else str(rel_path),
path=str(rel_path),
context={},
@ -49,6 +47,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
last_applied_hash="",
metadata=metadata or {},
)
instance.save()
def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
@ -57,7 +56,7 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
db_alias = schema_editor.connection.alias
for file in glob(f"{CONFIG.get('blueprints_dir')}/**/*.yaml", recursive=True):
check_blueprint_v1_file(BlueprintInstance, db_alias, Path(file))
check_blueprint_v1_file(BlueprintInstance, Path(file))
for blueprint in BlueprintInstance.objects.using(db_alias).all():
# If we already have flows (and we should always run before flow migrations)

View File

@ -1,5 +1,4 @@
"""blueprint models"""
from pathlib import Path
from uuid import uuid4
@ -71,19 +70,6 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
enabled = models.BooleanField(default=True)
managed_models = ArrayField(models.TextField(), default=list)
class Meta:
verbose_name = _("Blueprint Instance")
verbose_name_plural = _("Blueprint Instances")
unique_together = (
(
"name",
"path",
),
)
def __str__(self) -> str:
return f"Blueprint Instance {self.name}"
def retrieve_oci(self) -> str:
"""Get blueprint from an OCI registry"""
client = BlueprintOCIClient(self.path.replace(OCI_PREFIX, "https://"))
@ -102,7 +88,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
raise BlueprintRetrievalFailed("Invalid blueprint path")
with full_path.open("r", encoding="utf-8") as _file:
return _file.read()
except OSError as exc:
except (IOError, OSError) as exc:
raise BlueprintRetrievalFailed(exc) from exc
def retrieve(self) -> str:
@ -118,3 +104,16 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
from authentik.blueprints.api import BlueprintInstanceSerializer
return BlueprintInstanceSerializer
def __str__(self) -> str:
return f"Blueprint Instance {self.name}"
class Meta:
verbose_name = _("Blueprint Instance")
verbose_name_plural = _("Blueprint Instances")
unique_together = (
(
"name",
"path",
),
)

View File

@ -1,5 +1,4 @@
"""blueprint Settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand

View File

@ -1,7 +1,6 @@
"""Blueprint helpers"""
from collections.abc import Callable
from functools import wraps
from typing import Callable
from django.apps import apps
@ -21,7 +20,7 @@ def apply_blueprint(*files: str):
def wrapper(*args, **kwargs):
for file in files:
content = BlueprintInstance(path=file).retrieve()
Importer.from_string(content).apply()
Importer(content).apply()
return func(*args, **kwargs)
return wrapper
@ -39,7 +38,7 @@ def reconcile_app(app_name: str):
def wrapper(*args, **kwargs):
config = apps.get_app_config(app_name)
if isinstance(config, ManagedAppConfig):
config._on_startup_callback(None)
config.reconcile()
return func(*args, **kwargs)
return wrapper

View File

@ -1,5 +1,4 @@
"""authentik managed models tests"""
from django.test import TestCase
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed

View File

@ -1,5 +1,4 @@
"""Test blueprints OCI"""
from django.test import TransactionTestCase
from requests_mock import Mocker

View File

@ -1,23 +1,22 @@
"""test packaged blueprints"""
from collections.abc import Callable
from pathlib import Path
from typing import Callable
from django.test import TransactionTestCase
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.tests import apply_blueprint
from authentik.blueprints.v1.importer import Importer
from authentik.brands.models import Brand
from authentik.tenants.models import Tenant
class TestPackaged(TransactionTestCase):
"""Empty class, test methods are added dynamically"""
@apply_blueprint("default/default-brand.yaml")
@apply_blueprint("default/default-tenant.yaml")
def test_decorator_static(self):
"""Test @apply_blueprint decorator"""
self.assertTrue(Brand.objects.filter(domain="authentik-default").exists())
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
def blueprint_tester(file_name: Path) -> Callable:
@ -26,7 +25,7 @@ def blueprint_tester(file_name: Path) -> Callable:
def tester(self: TestPackaged):
base = Path("blueprints/")
rel_path = Path(file_name).relative_to(base)
importer = Importer.from_string(BlueprintInstance(path=str(rel_path)).retrieve())
importer = Importer(BlueprintInstance(path=str(rel_path)).retrieve())
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())

View File

@ -1,20 +1,18 @@
"""authentik managed models tests"""
from collections.abc import Callable
from typing import Callable, Type
from django.apps import apps
from django.test import TestCase
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.lib.models import SerializerModel
from authentik.providers.oauth2.models import RefreshToken
class TestModels(TestCase):
"""Test Models"""
def serializer_tester_factory(test_model: type[SerializerModel]) -> Callable:
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
"""Test serializer"""
def tester(self: TestModels):
@ -23,9 +21,6 @@ def serializer_tester_factory(test_model: type[SerializerModel]) -> Callable:
model_class = test_model()
self.assertTrue(isinstance(model_class, SerializerModel))
self.assertIsNotNone(model_class.serializer)
if model_class.serializer.Meta().model == RefreshToken:
return
self.assertEqual(model_class.serializer.Meta().model, test_model)
return tester

View File

@ -1,5 +1,4 @@
"""Test blueprints v1"""
from os import environ
from django.test import TransactionTestCase
@ -22,14 +21,14 @@ class TestBlueprintsV1(TransactionTestCase):
def test_blueprint_invalid_format(self):
"""Test blueprint with invalid format"""
importer = Importer.from_string('{"version": 3}')
importer = Importer('{"version": 3}')
self.assertFalse(importer.validate()[0])
importer = Importer.from_string(
importer = Importer(
'{"version": 1,"entries":[{"identifiers":{},"attrs":{},'
'"model": "authentik_core.User"}]}'
)
self.assertFalse(importer.validate()[0])
importer = Importer.from_string(
importer = Importer(
'{"version": 1, "entries": [{"attrs": {"name": "test"}, '
'"identifiers": {}, '
'"model": "authentik_core.Group"}]}'
@ -55,7 +54,7 @@ class TestBlueprintsV1(TransactionTestCase):
},
)
importer = Importer.from_string(
importer = Importer(
'{"version": 1, "entries": [{"attrs": {"name": "test999", "attributes": '
'{"key": ["updated_value"]}}, "identifiers": {"attributes": {"other_key": '
'["other_value"]}}, "model": "authentik_core.Group"}]}'
@ -104,7 +103,7 @@ class TestBlueprintsV1(TransactionTestCase):
self.assertEqual(len(export.entries), 3)
export_yaml = exporter.export_to_string()
importer = Importer.from_string(export_yaml)
importer = Importer(export_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
@ -114,14 +113,14 @@ class TestBlueprintsV1(TransactionTestCase):
"""Test export and import it twice"""
count_initial = Prompt.objects.filter(field_key="username").count()
importer = Importer.from_string(load_fixture("fixtures/static_prompt_export.yaml"))
importer = Importer(load_fixture("fixtures/static_prompt_export.yaml"))
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
count_before = Prompt.objects.filter(field_key="username").count()
self.assertEqual(count_initial + 1, count_before)
importer = Importer.from_string(load_fixture("fixtures/static_prompt_export.yaml"))
importer = Importer(load_fixture("fixtures/static_prompt_export.yaml"))
self.assertTrue(importer.apply())
self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before)
@ -131,7 +130,7 @@ class TestBlueprintsV1(TransactionTestCase):
ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").delete()
Group.objects.filter(name="test").delete()
environ["foo"] = generate_id()
importer = Importer.from_string(load_fixture("fixtures/tags.yaml"), {"bar": "baz"})
importer = Importer(load_fixture("fixtures/tags.yaml"), {"bar": "baz"})
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
policy = ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").first()
@ -249,7 +248,7 @@ class TestBlueprintsV1(TransactionTestCase):
exporter = FlowExporter(flow)
export_yaml = exporter.export_to_string()
importer = Importer.from_string(export_yaml)
importer = Importer(export_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
self.assertTrue(UserLoginStage.objects.filter(name=stage_name).exists())
@ -298,7 +297,7 @@ class TestBlueprintsV1(TransactionTestCase):
exporter = FlowExporter(flow)
export_yaml = exporter.export_to_string()
importer = Importer.from_string(export_yaml)
importer = Importer(export_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())

View File

@ -1,5 +1,4 @@
"""Test blueprints v1 api"""
from json import loads
from tempfile import NamedTemporaryFile, mkdtemp

View File

@ -1,5 +1,4 @@
"""Test blueprints v1"""
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
@ -19,7 +18,7 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
self.uid = generate_id()
import_yaml = load_fixture("fixtures/conditional_fields.yaml", uid=self.uid, user=user.pk)
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())

View File

@ -1,5 +1,4 @@
"""Test blueprints v1"""
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
@ -19,7 +18,7 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
"fixtures/conditions_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2
)
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
# Ensure objects exist
@ -36,7 +35,7 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
"fixtures/conditions_not_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2
)
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
# Ensure objects do not exist

View File

@ -1,5 +1,4 @@
"""Test blueprints v1"""
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
@ -16,7 +15,7 @@ class TestBlueprintsV1State(TransactionTestCase):
flow_slug = generate_id()
import_yaml = load_fixture("fixtures/state_present.yaml", id=flow_slug)
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
# Ensure object exists
@ -31,7 +30,7 @@ class TestBlueprintsV1State(TransactionTestCase):
self.assertEqual(flow.title, "bar")
# Ensure importer updates it
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first()
@ -42,7 +41,7 @@ class TestBlueprintsV1State(TransactionTestCase):
flow_slug = generate_id()
import_yaml = load_fixture("fixtures/state_created.yaml", id=flow_slug)
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
# Ensure object exists
@ -57,7 +56,7 @@ class TestBlueprintsV1State(TransactionTestCase):
self.assertEqual(flow.title, "bar")
# Ensure importer doesn't update it
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first()
@ -68,7 +67,7 @@ class TestBlueprintsV1State(TransactionTestCase):
flow_slug = generate_id()
import_yaml = load_fixture("fixtures/state_created.yaml", id=flow_slug)
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
# Ensure object exists
@ -76,7 +75,7 @@ class TestBlueprintsV1State(TransactionTestCase):
self.assertEqual(flow.slug, flow_slug)
import_yaml = load_fixture("fixtures/state_absent.yaml", id=flow_slug)
importer = Importer.from_string(import_yaml)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first()

View File

@ -1,5 +1,4 @@
"""Test blueprints v1 tasks"""
from hashlib import sha512
from tempfile import NamedTemporaryFile, mkdtemp
@ -54,7 +53,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
file.seek(0)
file_hash = sha512(file.read().encode()).hexdigest()
file.flush()
blueprints_discovery()
blueprints_discovery() # pylint: disable=no-value-for-parameter
instance = BlueprintInstance.objects.filter(name=blueprint_id).first()
self.assertEqual(instance.last_applied_hash, file_hash)
self.assertEqual(
@ -82,7 +81,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
)
)
file.flush()
blueprints_discovery()
blueprints_discovery() # pylint: disable=no-value-for-parameter
blueprint = BlueprintInstance.objects.filter(name="foo").first()
self.assertEqual(
blueprint.last_applied_hash,
@ -107,7 +106,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
)
)
file.flush()
blueprints_discovery()
blueprints_discovery() # pylint: disable=no-value-for-parameter
blueprint.refresh_from_db()
self.assertEqual(
blueprint.last_applied_hash,
@ -149,7 +148,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
instance.status,
BlueprintInstanceStatus.UNKNOWN,
)
apply_blueprint(instance.pk)
apply_blueprint(instance.pk) # pylint: disable=no-value-for-parameter
instance.refresh_from_db()
self.assertEqual(instance.last_applied_hash, "")
self.assertEqual(

View File

@ -1,5 +1,4 @@
"""API URLs"""
from authentik.blueprints.api import BlueprintInstanceViewSet
api_urlpatterns = [

View File

@ -1,20 +1,17 @@
"""transfer common classes"""
from collections import OrderedDict
from collections.abc import Iterable, Mapping
from copy import copy
from dataclasses import asdict, dataclass, field, is_dataclass
from enum import Enum
from functools import reduce
from operator import ixor
from os import getenv
from typing import Any, Literal, Union
from typing import Any, Iterable, Literal, Mapping, Optional, Union
from uuid import UUID
from deepmerge import always_merger
from django.apps import apps
from django.db.models import Model, Q
from rest_framework.exceptions import ValidationError
from rest_framework.fields import Field
from rest_framework.serializers import Serializer
from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode
@ -46,7 +43,7 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]:
class BlueprintEntryState:
"""State of a single instance"""
instance: Model | None = None
instance: Optional[Model] = None
class BlueprintEntryDesiredState(Enum):
@ -55,7 +52,6 @@ class BlueprintEntryDesiredState(Enum):
ABSENT = "absent"
PRESENT = "present"
CREATED = "created"
MUST_CREATED = "must_created"
@dataclass
@ -68,9 +64,9 @@ 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)
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
id: str | None = None
id: Optional[str] = None
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
@ -93,10 +89,10 @@ class BlueprintEntry:
attrs=all_attrs,
)
def get_tag_context(
def _get_tag_context(
self,
depth: int = 0,
context_tag_type: type["YAMLTagContext"] | tuple["YAMLTagContext", ...] | None = None,
context_tag_type: Optional[type["YAMLTagContext"] | tuple["YAMLTagContext", ...]] = None,
) -> "YAMLTagContext":
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
if depth < 0:
@ -109,8 +105,8 @@ class BlueprintEntry:
try:
return contexts[-(depth + 1)]
except IndexError as exc:
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}") from exc
except IndexError:
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}")
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
"""Check if we have any special tags that need handling"""
@ -171,7 +167,7 @@ class Blueprint:
entries: list[BlueprintEntry] = field(default_factory=list)
context: dict = field(default_factory=dict)
metadata: BlueprintMetadata | None = field(default=None)
metadata: Optional[BlueprintMetadata] = field(default=None)
class YAMLTag:
@ -210,8 +206,8 @@ class KeyOf(YAMLTag):
):
return _entry._state.instance.pbm_uuid
return _entry._state.instance.pk
raise EntryInvalidError.from_entry(
f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance", entry
raise EntryInvalidError(
f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance"
)
@ -219,7 +215,7 @@ class Env(YAMLTag):
"""Lookup environment variable with optional default"""
key: str
default: Any | None
default: Optional[Any]
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
@ -238,7 +234,7 @@ class Context(YAMLTag):
"""Lookup key from instance context"""
key: str
default: Any | None
default: Optional[Any]
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
@ -282,7 +278,7 @@ class Format(YAMLTag):
try:
return self.format_string % tuple(args)
except TypeError as exc:
raise EntryInvalidError.from_entry(exc, entry) from exc
raise EntryInvalidError(exc)
class Find(YAMLTag):
@ -359,15 +355,13 @@ class Condition(YAMLTag):
args.append(arg)
if not args:
raise EntryInvalidError.from_entry(
"At least one value is required after mode selection.", entry
)
raise EntryInvalidError("At least one value is required after mode selection.")
try:
comparator = self._COMPARATORS[self.mode.upper()]
return comparator(tuple(bool(x) for x in args))
except (TypeError, KeyError) as exc:
raise EntryInvalidError.from_entry(exc, entry) from exc
raise EntryInvalidError(exc)
class If(YAMLTag):
@ -399,7 +393,7 @@ class If(YAMLTag):
blueprint,
)
except TypeError as exc:
raise EntryInvalidError.from_entry(exc, entry) from exc
raise EntryInvalidError(exc)
class Enumerate(YAMLTag, YAMLTagContext):
@ -413,7 +407,9 @@ class Enumerate(YAMLTag, YAMLTagContext):
"SEQ": (list, lambda a, b: [*a, b]),
"MAP": (
dict,
lambda a, b: always_merger.merge(a, {b[0]: b[1]} if isinstance(b, tuple | list) else b),
lambda a, b: always_merger.merge(
a, {b[0]: b[1]} if isinstance(b, (tuple, list)) else b
),
),
}
@ -429,10 +425,9 @@ class Enumerate(YAMLTag, YAMLTagContext):
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0:
raise EntryInvalidError.from_entry(
raise EntryInvalidError(
f"{self.__class__.__name__} tag's iterable references this tag's context. "
"This is a noop. Check you are setting depth bigger than 0.",
entry,
"This is a noop. Check you are setting depth bigger than 0."
)
if isinstance(self.iterable, YAMLTag):
@ -441,10 +436,9 @@ class Enumerate(YAMLTag, YAMLTagContext):
iterable = self.iterable
if not isinstance(iterable, Iterable):
raise EntryInvalidError.from_entry(
raise EntryInvalidError(
f"{self.__class__.__name__}'s iterable must be an iterable "
"such as a sequence or a mapping",
entry,
"such as a sequence or a mapping"
)
if isinstance(iterable, Mapping):
@ -455,7 +449,7 @@ class Enumerate(YAMLTag, YAMLTagContext):
try:
output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()]
except KeyError as exc:
raise EntryInvalidError.from_entry(exc, entry) from exc
raise EntryInvalidError(exc)
result = output_class()
@ -467,8 +461,8 @@ class Enumerate(YAMLTag, YAMLTagContext):
resolved_body = entry.tag_resolver(self.item_body, blueprint)
result = add_fn(result, resolved_body)
if not isinstance(result, output_class):
raise EntryInvalidError.from_entry(
f"Invalid {self.__class__.__name__} item found: {resolved_body}", entry
raise EntryInvalidError(
f"Invalid {self.__class__.__name__} item found: {resolved_body}"
)
finally:
self.__current_context = tuple()
@ -483,27 +477,24 @@ class EnumeratedItem(YAMLTag):
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
def __init__(self, _loader: "BlueprintLoader", node: ScalarNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.depth = int(node.value)
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
try:
context_tag: Enumerate = entry.get_tag_context(
context_tag: Enumerate = entry._get_tag_context(
depth=self.depth,
context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS,
)
except ValueError as exc:
if self.depth == 0:
raise EntryInvalidError.from_entry(
raise EntryInvalidError(
f"{self.__class__.__name__} tags are only usable "
f"inside an {Enumerate.__name__} tag",
entry,
) from exc
f"inside an {Enumerate.__name__} tag"
)
raise EntryInvalidError.from_entry(
f"{self.__class__.__name__} tag: {exc}", entry
) from exc
raise EntryInvalidError(f"{self.__class__.__name__} tag: {exc}")
return context_tag.get_context(entry, blueprint)
@ -516,8 +507,8 @@ class Index(EnumeratedItem):
try:
return context[0]
except IndexError as exc: # pragma: no cover
raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc
except IndexError: # pragma: no cover
raise EntryInvalidError(f"Empty/invalid context: {context}")
class Value(EnumeratedItem):
@ -528,8 +519,8 @@ class Value(EnumeratedItem):
try:
return context[1]
except IndexError as exc: # pragma: no cover
raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc
except IndexError: # pragma: no cover
raise EntryInvalidError(f"Empty/invalid context: {context}")
class BlueprintDumper(SafeDumper):
@ -556,11 +547,7 @@ class BlueprintDumper(SafeDumper):
def factory(items):
final_dict = dict(items)
# Remove internal state variables
final_dict.pop("_state", None)
# Future-proof to only remove the ID if we don't set a value
if "id" in final_dict and final_dict.get("id") is None:
final_dict.pop("id")
return final_dict
data = asdict(data, dict_factory=factory)
@ -587,31 +574,8 @@ class BlueprintLoader(SafeLoader):
class EntryInvalidError(SentryIgnoredException):
"""Error raised when an entry is invalid"""
entry_model: str | None
entry_id: str | None
validation_error: ValidationError | None
serializer: Serializer | None = None
serializer_errors: Optional[dict]
def __init__(
self, *args: object, validation_error: ValidationError | None = None, **kwargs
) -> None:
def __init__(self, *args: object, serializer_errors: Optional[dict] = None) -> None:
super().__init__(*args)
self.entry_model = None
self.entry_id = None
self.validation_error = validation_error
for key, value in kwargs.items():
setattr(self, key, value)
@staticmethod
def from_entry(
msg_or_exc: str | Exception, entry: BlueprintEntry, *args, **kwargs
) -> "EntryInvalidError":
"""Create EntryInvalidError with the context of an entry"""
error = EntryInvalidError(msg_or_exc, *args, **kwargs)
if isinstance(msg_or_exc, ValidationError):
error.validation_error = msg_or_exc
# Make sure the model and id are strings, depending where the error happens
# they might still be YAMLTag instances
error.entry_model = str(entry.model)
error.entry_id = str(entry.id)
return error
self.serializer_errors = serializer_errors

View File

@ -1,6 +1,5 @@
"""Blueprint exporter"""
from collections.abc import Iterable
from typing import Iterable
from uuid import UUID
from django.apps import apps
@ -8,6 +7,7 @@ from django.contrib.auth import get_user_model
from django.db.models import Model, Q, QuerySet
from django.utils.timezone import now
from django.utils.translation import gettext as _
from guardian.shortcuts import get_anonymous_user
from yaml import dump
from authentik.blueprints.v1.common import (
@ -48,7 +48,7 @@ class Exporter:
"""Return a queryset for `model`. Can be used to filter some
objects on some models"""
if model == get_user_model():
return model.objects.exclude_anonymous()
return model.objects.exclude(pk=get_anonymous_user().pk)
return model.objects.all()
def _pre_export(self, blueprint: Blueprint):
@ -59,7 +59,7 @@ class Exporter:
blueprint = Blueprint()
self._pre_export(blueprint)
blueprint.metadata = BlueprintMetadata(
name=_("authentik Export - {date}".format_map({"date": str(now())})),
name=_("authentik Export - %(date)s" % {"date": str(now())}),
labels={
LABEL_AUTHENTIK_GENERATED: "true",
},
@ -74,7 +74,7 @@ class Exporter:
class FlowExporter(Exporter):
"""Exporter customized to only return objects related to `flow`"""
"""Exporter customised to only return objects related to `flow`"""
flow: Flow
with_policies: bool

View File

@ -1,24 +1,22 @@
"""Blueprint importer"""
from contextlib import contextmanager
from copy import deepcopy
from typing import Any
from typing import Any, Optional
from dacite.config import Config
from dacite.core import from_dict
from dacite.exceptions import DaciteError
from deepmerge import always_merger
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.db import transaction
from django.db.models import Model
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 rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
from structlog.testing import capture_logs
from structlog.types import EventDict
from yaml import load
from authentik.blueprints.v1.common import (
@ -37,43 +35,26 @@ from authentik.core.models import (
Source,
UserSourceConnection,
)
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.models import SystemTask
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.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 SCIMGroup, SCIMUser
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context
# Update website/developer-docs/blueprints/v1/models.md when used
SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry"
def excluded_models() -> list[type[Model]]:
"""Return a list of all excluded models that shouldn't be exposed via API
or other means (internal only, base classes, non-used objects, etc)"""
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
# pylint: disable=imported-auth-user
from django.contrib.auth.models import Group as DjangoGroup
from django.contrib.auth.models import User as DjangoUser
return (
# Django only classes
excluded_models = (
DjangoUser,
DjangoGroup,
ContentType,
Permission,
UserObjectPermission,
# Base classes
Provider,
Source,
@ -87,92 +68,59 @@ def excluded_models() -> list[type[Model]]:
AuthenticatedSession,
# Classes which are only internally managed
FlowToken,
LicenseUsage,
SCIMGroup,
SCIMUser,
Tenant,
SystemTask,
ConnectionToken,
AuthorizationCode,
AccessToken,
RefreshToken,
Reputation,
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
)
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel)
class DoRollback(SentryIgnoredException):
"""Exception to trigger a rollback"""
return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel))
@contextmanager
def transaction_rollback():
"""Enters an atomic transaction and always triggers a rollback at the end of the block."""
try:
with atomic():
yield
raise DoRollback()
except DoRollback:
pass
atomic = transaction.atomic()
# pylint: disable=unnecessary-dunder-call
atomic.__enter__()
yield
atomic.__exit__(IntegrityError, None, None)
class Importer:
"""Import Blueprint from raw dict or YAML/JSON"""
"""Import Blueprint from YAML"""
logger: BoundLogger
_import: Blueprint
def __init__(self, blueprint: Blueprint, context: dict | None = None):
def __init__(self, yaml_input: str, context: Optional[dict] = None):
self.__pk_map: dict[Any, Model] = {}
self._import = blueprint
self.logger = get_logger()
ctx = self.default_context()
always_merger.merge(ctx, self._import.context)
if context:
always_merger.merge(ctx, context)
self._import.context = ctx
def default_context(self):
"""Default context"""
return {"goauthentik.io/enterprise/licensed": LicenseKey.get_total().is_valid()}
@staticmethod
def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
"""Parse YAML string and create blueprint importer from it"""
import_dict = load(yaml_input, BlueprintLoader)
try:
_import = from_dict(
self.__import = from_dict(
Blueprint, import_dict, config=Config(cast=[BlueprintEntryDesiredState])
)
except DaciteError as exc:
raise EntryInvalidError from exc
return Importer(_import, context)
ctx = {}
always_merger.merge(ctx, self.__import.context)
if context:
always_merger.merge(ctx, context)
self.__import.context = ctx
@property
def blueprint(self) -> Blueprint:
"""Get imported blueprint"""
return self._import
return self.__import
def __update_pks_for_attrs(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Replace any value if it is a known primary key of an other object"""
def updater(value) -> Any:
if value in self.__pk_map:
self.logger.debug("Updating reference in entry", value=value)
self.logger.debug("updating reference in entry", value=value)
return self.__pk_map[value]
return value
for key, value in attrs.items():
try:
if isinstance(value, dict):
for _, _inner_key in enumerate(value):
for idx, _inner_key in enumerate(value):
value[_inner_key] = updater(value[_inner_key])
elif isinstance(value, list):
for idx, _inner_value in enumerate(value):
@ -201,21 +149,22 @@ class Importer:
return main_query | sub_query
def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer | None:
# pylint: disable-msg=too-many-locals
def _validate_single(self, entry: BlueprintEntry) -> Optional[BaseSerializer]:
"""Validate a single entry"""
if not entry.check_all_conditions_match(self._import):
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(".")
model_app_label, model_name = entry.get_model(self.__import).split(".")
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
# Don't use isinstance since we don't want to check for inheritance
if not is_model_allowed(model):
raise EntryInvalidError.from_entry(f"Model {model} not allowed", entry)
raise EntryInvalidError(f"Model {model} not allowed")
if issubclass(model, BaseMetaModel):
serializer_class: type[Serializer] = model.serializer()
serializer = serializer_class(
data=entry.get_attrs(self._import),
data=entry.get_attrs(self.__import),
context={
SERIALIZER_CONTEXT_BLUEPRINT: entry,
},
@ -223,10 +172,8 @@ class Importer:
try:
serializer.is_valid(raise_exception=True)
except ValidationError as exc:
raise EntryInvalidError.from_entry(
f"Serializer errors {serializer.errors}",
validation_error=exc,
entry=entry,
raise EntryInvalidError(
f"Serializer errors {serializer.errors}", serializer_errors=serializer.errors
) from exc
return serializer
@ -235,7 +182,7 @@ class Importer:
# the full serializer for later usage
# Because a model might have multiple unique columns, we chain all identifiers together
# to create an OR query.
updated_identifiers = self.__update_pks_for_attrs(entry.get_identifiers(self._import))
updated_identifiers = self.__update_pks_for_attrs(entry.get_identifiers(self.__import))
for key, value in list(updated_identifiers.items()):
if isinstance(value, dict) and "pk" in value:
del updated_identifiers[key]
@ -243,35 +190,27 @@ class Importer:
query = self.__query_from_identifier(updated_identifiers)
if not query:
raise EntryInvalidError.from_entry("No or invalid identifiers", entry)
raise EntryInvalidError("No or invalid identifiers")
try:
existing_models = model.objects.filter(query)
except FieldError as exc:
raise EntryInvalidError.from_entry(f"Invalid identifier field: {exc}", entry) from exc
raise EntryInvalidError(f"Invalid identifier field: {exc}") from exc
serializer_kwargs = {}
model_instance = existing_models.first()
if not isinstance(model(), BaseMetaModel) and model_instance:
self.logger.debug(
"Initialise serializer with instance",
"initialise serializer with instance",
model=model,
instance=model_instance,
pk=model_instance.pk,
)
serializer_kwargs["instance"] = model_instance
serializer_kwargs["partial"] = True
elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED:
raise EntryInvalidError.from_entry(
(
f"State is set to {BlueprintEntryDesiredState.MUST_CREATED} "
"and object exists already",
),
entry,
)
else:
self.logger.debug(
"Initialised new serializer instance",
"initialised new serializer instance",
model=model,
**cleanse_dict(updated_identifiers),
)
@ -281,12 +220,9 @@ class Importer:
model_instance.pk = updated_identifiers["pk"]
serializer_kwargs["instance"] = model_instance
try:
full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import))
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(exc) from exc
always_merger.merge(full_data, updated_identifiers)
serializer_kwargs["data"] = full_data
@ -299,18 +235,15 @@ class Importer:
try:
serializer.is_valid(raise_exception=True)
except ValidationError as exc:
raise EntryInvalidError.from_entry(
f"Serializer errors {serializer.errors}",
validation_error=exc,
entry=entry,
serializer=serializer,
raise EntryInvalidError(
f"Serializer errors {serializer.errors}", serializer_errors=serializer.errors
) from exc
return serializer
def apply(self) -> bool:
"""Apply (create/update) models yaml, in database transaction"""
try:
with atomic():
with transaction.atomic():
if not self._apply_models():
self.logger.debug("Reverting changes due to error")
raise IntegrityError
@ -319,40 +252,32 @@ class Importer:
self.logger.debug("Committing changes")
return True
def _apply_models(self, raise_errors=False) -> bool:
def _apply_models(self) -> bool:
"""Apply (create/update) models yaml"""
self.__pk_map = {}
for entry in self._import.entries:
model_app_label, model_name = entry.get_model(self._import).split(".")
for entry in self.__import.entries:
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:
self.logger.warning(
"App or Model does not exist", app=model_app_label, model=model_name
"app or model does not exist", app=model_app_label, model=model_name
)
return False
# Validate each single entry
serializer = None
try:
serializer = self._validate_single(entry)
except EntryInvalidError as exc:
# For deleting objects we don't need the serializer to be valid
if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT:
serializer = exc.serializer
else:
self.logger.warning(f"Entry invalid: {exc}", entry=entry, error=exc)
if raise_errors:
raise exc
return False
if entry.get_state(self.__import) == BlueprintEntryDesiredState.ABSENT:
continue
self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc)
return False
if not serializer:
continue
state = entry.get_state(self._import)
if state in [
BlueprintEntryDesiredState.PRESENT,
BlueprintEntryDesiredState.CREATED,
BlueprintEntryDesiredState.MUST_CREATED,
]:
state = entry.get_state(self.__import)
if state in [BlueprintEntryDesiredState.PRESENT, BlueprintEntryDesiredState.CREATED]:
instance = serializer.instance
if (
instance
@ -360,41 +285,43 @@ class Importer:
and state == BlueprintEntryDesiredState.CREATED
):
self.logger.debug(
"Instance exists, skipping",
"instance exists, skipping",
model=model,
instance=instance,
pk=instance.pk,
)
else:
instance = serializer.save()
self.logger.debug("Updated model", model=instance)
self.logger.debug("updated model", model=instance)
if "pk" in entry.identifiers:
self.__pk_map[entry.identifiers["pk"]] = instance.pk
entry._state = BlueprintEntryState(instance)
elif state == BlueprintEntryDesiredState.ABSENT:
instance: Model | None = serializer.instance
instance: Optional[Model] = serializer.instance
if instance.pk:
instance.delete()
self.logger.debug("Deleted model", mode=instance)
self.logger.debug("deleted model", mode=instance)
continue
self.logger.debug("Entry to delete with no instance, skipping")
self.logger.debug("entry to delete with no instance, skipping")
return True
def validate(self, raise_validation_errors=False) -> tuple[bool, list[LogEvent]]:
def validate(self) -> tuple[bool, list[EventDict]]:
"""Validate loaded blueprint export, ensure all models are allowed
and serializers have no errors"""
self.logger.debug("Starting blueprint import validation")
orig_import = deepcopy(self._import)
if self._import.version != 1:
orig_import = deepcopy(self.__import)
if self.__import.version != 1:
self.logger.warning("Invalid blueprint version")
return False, [{"event": "Invalid blueprint version"}]
with (
transaction_rollback(),
capture_logs() as logs,
):
successful = self._apply_models(raise_errors=raise_validation_errors)
successful = self._apply_models()
if not successful:
self.logger.warning("Blueprint validation failed")
self.logger.debug("Blueprint validation failed")
for log in logs:
getattr(self.logger, log.get("log_level"))(**log)
self.logger.debug("Finished blueprint import validation")
self._import = orig_import
self.__import = orig_import
return successful, logs

View File

@ -1,13 +1,12 @@
"""Apply Blueprint meta model"""
from typing import TYPE_CHECKING
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField
from rest_framework.fields import BooleanField, JSONField
from structlog.stdlib import get_logger
from authentik.blueprints.v1.meta.registry import BaseMetaModel, MetaResult, registry
from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.core.api.utils import PassiveSerializer, is_dict
if TYPE_CHECKING:
from authentik.blueprints.models import BlueprintInstance
@ -18,7 +17,7 @@ LOGGER = get_logger()
class ApplyBlueprintMetaSerializer(PassiveSerializer):
"""Serializer for meta apply blueprint model"""
identifiers = JSONDictField()
identifiers = JSONField(validators=[is_dict])
required = BooleanField(default=True)
# We cannot override `instance` as that will confuse rest_framework
@ -43,7 +42,7 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
LOGGER.info("Blueprint does not exist, but not required")
return MetaResult()
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
# pylint: disable=no-value-for-parameter
apply_blueprint(str(self.blueprint_instance.pk))
return MetaResult()

View File

@ -1,5 +1,4 @@
"""Base models"""
from django.apps import apps
from django.db.models import Model
from rest_framework.serializers import Serializer
@ -8,15 +7,15 @@ from rest_framework.serializers import Serializer
class BaseMetaModel(Model):
"""Base models"""
class Meta:
abstract = True
@staticmethod
def serializer() -> Serializer:
"""Serializer similar to SerializerModel, but as a static method since
this is an abstract model"""
raise NotImplementedError
class Meta:
abstract = True
class MetaResult:
"""Result returned by Meta Models' serializers. Empty class but we can't return none as

View File

@ -1,5 +1,4 @@
"""OCI Client"""
from typing import Any
from urllib.parse import ParseResult, urlparse

View File

@ -1,9 +1,8 @@
"""v1 blueprints tasks"""
from dataclasses import asdict, dataclass, field
from hashlib import sha512
from pathlib import Path
from sys import platform
from typing import Optional
from dacite.core import from_dict
from django.db import DatabaseError, InternalError, ProgrammingError
@ -30,13 +29,15 @@ from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata, E
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.events.logs import capture_logs
from authentik.events.models import TaskStatus
from authentik.events.system_tasks import SystemTask, prefill_task
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.events.utils import sanitize_dict
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP
from authentik.tenants.models import Tenant
LOGGER = get_logger()
_file_watcher_started = False
@ -50,23 +51,18 @@ class BlueprintFile:
version: int
hash: str
last_m: int
meta: BlueprintMetadata | None = field(default=None)
meta: Optional[BlueprintMetadata] = field(default=None)
def start_blueprint_watcher():
"""Start blueprint watcher, if it's not running already."""
# This function might be called twice since it's called on celery startup
global _file_watcher_started # noqa: PLW0603
# pylint: disable=global-statement
global _file_watcher_started
if _file_watcher_started:
return
observer = Observer()
kwargs = {}
if platform.startswith("linux"):
kwargs["event_filter"] = (FileCreatedEvent, FileModifiedEvent)
observer.schedule(
BlueprintEventHandler(), CONFIG.get("blueprints_dir"), recursive=True, **kwargs
)
observer.schedule(BlueprintEventHandler(), CONFIG.get("blueprints_dir"), recursive=True)
observer.start()
_file_watcher_started = True
@ -74,36 +70,21 @@ def start_blueprint_watcher():
class BlueprintEventHandler(FileSystemEventHandler):
"""Event handler for blueprint events"""
# We only ever get creation and modification events.
# See the creation of the Observer instance above for the event filtering.
# Even though we filter to only get file events, we might still get
# directory events as some implementations such as inotify do not support
# filtering on file/directory.
def dispatch(self, event: FileSystemEvent) -> None:
"""Call specific event handler method. Ignores directory changes."""
def on_any_event(self, event: FileSystemEvent):
if not isinstance(event, (FileCreatedEvent, FileModifiedEvent)):
return
if event.is_directory:
return None
return super().dispatch(event)
def on_created(self, event: FileSystemEvent):
"""Process file creation"""
LOGGER.debug("new blueprint file created, starting discovery")
for tenant in Tenant.objects.filter(ready=True):
with tenant:
blueprints_discovery.delay()
def on_modified(self, event: FileSystemEvent):
"""Process file modification"""
path = Path(event.src_path)
root = Path(CONFIG.get("blueprints_dir")).absolute()
rel_path = str(path.relative_to(root))
for tenant in Tenant.objects.filter(ready=True):
with tenant:
for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True):
LOGGER.debug("modified blueprint file, starting apply", instance=instance)
apply_blueprint.delay(instance.pk.hex)
return
if isinstance(event, FileCreatedEvent):
LOGGER.debug("new blueprint file created, starting discovery")
blueprints_discovery.delay()
if isinstance(event, FileModifiedEvent):
path = Path(event.src_path)
root = Path(CONFIG.get("blueprints_dir")).absolute()
rel_path = str(path.relative_to(root))
for instance in BlueprintInstance.objects.filter(path=rel_path):
LOGGER.debug("modified blueprint file, starting apply", instance=instance)
apply_blueprint.delay(instance.pk.hex)
@CELERY_APP.task(
@ -117,49 +98,57 @@ def blueprints_find_dict():
return blueprints
def blueprints_find() -> list[BlueprintFile]:
def blueprints_find():
"""Find blueprints and return valid ones"""
blueprints = []
root = Path(CONFIG.get("blueprints_dir"))
for path in root.rglob("**/*.yaml"):
rel_path = path.relative_to(root)
# Check if any part in the path starts with a dot and assume a hidden file
if any(part for part in path.parts if part.startswith(".")):
continue
with open(path, encoding="utf-8") as blueprint_file:
LOGGER.debug("found blueprint", path=str(path))
with open(path, "r", encoding="utf-8") as blueprint_file:
try:
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
except YAMLError as exc:
raw_blueprint = None
LOGGER.warning("failed to parse blueprint", exc=exc, path=str(rel_path))
LOGGER.warning("failed to parse blueprint", exc=exc, path=str(path))
if not raw_blueprint:
continue
metadata = raw_blueprint.get("metadata", None)
version = raw_blueprint.get("version", 1)
if version != 1:
LOGGER.warning("invalid blueprint version", version=version, path=str(rel_path))
LOGGER.warning("invalid blueprint version", version=version, path=str(path))
continue
file_hash = sha512(path.read_bytes()).hexdigest()
blueprint = BlueprintFile(str(rel_path), version, file_hash, int(path.stat().st_mtime))
blueprint = BlueprintFile(
str(path.relative_to(root)), version, file_hash, int(path.stat().st_mtime)
)
blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None
blueprints.append(blueprint)
LOGGER.debug(
"parsed & loaded blueprint",
hash=file_hash,
path=str(path),
)
return blueprints
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError), base=SystemTask, bind=True
throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True
)
@prefill_task
def blueprints_discovery(self: SystemTask, path: str | None = None):
def blueprints_discovery(self: MonitoredTask):
"""Find blueprints and check if they need to be created in the database"""
count = 0
for blueprint in blueprints_find():
if path and blueprint.path != path:
continue
check_blueprint_v1_file(blueprint)
count += 1
self.set_status(
TaskStatus.SUCCESSFUL, _("Successfully imported %(count)d files." % {"count": count})
TaskResult(
TaskResultStatus.SUCCESSFUL,
messages=[_("Successfully imported %(count)d files." % {"count": count})],
)
)
@ -182,22 +171,18 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
metadata={},
)
instance.save()
LOGGER.info(
"Creating new blueprint instance from file", instance=instance, path=instance.path
)
if instance.last_applied_hash != blueprint.hash:
LOGGER.info("Applying blueprint due to changed file", instance=instance, path=instance.path)
apply_blueprint.delay(str(instance.pk))
@CELERY_APP.task(
bind=True,
base=SystemTask,
base=MonitoredTask,
)
def apply_blueprint(self: SystemTask, instance_pk: str):
def apply_blueprint(self: MonitoredTask, instance_pk: str):
"""Apply single blueprint"""
self.save_on_success = False
instance: BlueprintInstance | None = None
instance: Optional[BlueprintInstance] = None
try:
instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
if not instance or not instance.enabled:
@ -205,37 +190,36 @@ def apply_blueprint(self: SystemTask, instance_pk: str):
self.set_uid(slugify(instance.name))
blueprint_content = instance.retrieve()
file_hash = sha512(blueprint_content.encode()).hexdigest()
importer = Importer.from_string(blueprint_content, instance.context)
importer = Importer(blueprint_content, instance.context)
if importer.blueprint.metadata:
instance.metadata = asdict(importer.blueprint.metadata)
valid, logs = importer.validate()
if not valid:
instance.status = BlueprintInstanceStatus.ERROR
instance.save()
self.set_status(TaskStatus.ERROR, *logs)
self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs]))
return
applied = importer.apply()
if not applied:
instance.status = BlueprintInstanceStatus.ERROR
instance.save()
self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply"))
return
with capture_logs() as logs:
applied = importer.apply()
if not applied:
instance.status = BlueprintInstanceStatus.ERROR
instance.save()
self.set_status(TaskStatus.ERROR, *logs)
return
instance.status = BlueprintInstanceStatus.SUCCESSFUL
instance.last_applied_hash = file_hash
instance.last_applied = now()
self.set_status(TaskStatus.SUCCESSFUL)
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
except (
OSError,
DatabaseError,
ProgrammingError,
InternalError,
IOError,
BlueprintRetrievalFailed,
EntryInvalidError,
) as exc:
if instance:
instance.status = BlueprintInstanceStatus.ERROR
self.set_error(exc)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
finally:
if instance:
instance.save()

View File

@ -1,11 +0,0 @@
"""authentik brands app"""
from django.apps import AppConfig
class AuthentikBrandsConfig(AppConfig):
"""authentik Brand app"""
name = "authentik.brands"
label = "authentik_brands"
verbose_name = "authentik Brands"

View File

@ -1,27 +0,0 @@
"""Inject brand into current request"""
from collections.abc import Callable
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.utils.translation import activate
from authentik.brands.utils import get_brand_for_request
class BrandMiddleware:
"""Add current brand to http request"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
if not hasattr(request, "brand"):
brand = get_brand_for_request(request)
request.brand = brand
locale = brand.default_locale
if locale != "":
activate(locale)
return self.get_response(request)

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